claude-code-hub 1.13.0 → 1.14.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/apps.js +4 -2
- package/lib/ext.js +1 -16
- package/lib/hub-config.js +18 -1
- package/lib/keymap.js +2 -1
- package/lib/settings.js +93 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/keys.md +2 -1
- package/public/app.js +294 -24
- package/public/index.html +128 -1
- package/server.js +124 -17
package/lib/apps.js
CHANGED
|
@@ -24,7 +24,8 @@ const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x)
|
|
|
24
24
|
// unlisted ones follow in default order, so an app added in a later release shows up without an edit.
|
|
25
25
|
// An entry with a `path` runs the app in that folder: a fork in place of a built-in app, or a new app.
|
|
26
26
|
// A relative `path` is resolved against `base`, the folder of config.json.
|
|
27
|
-
|
|
27
|
+
// `includeOff` keeps the apps with `enabled: false` and gives every app `enabled`.
|
|
28
|
+
function selectApps(entries = [], base = process.cwd(), { includeOff = false } = {}) {
|
|
28
29
|
const listed = entries.filter((e) => typeof e?.id === 'string');
|
|
29
30
|
const entryOf = (id) => listed.find((e) => e.id === id);
|
|
30
31
|
const pathOf = (id) => {
|
|
@@ -37,11 +38,12 @@ function selectApps(entries = [], base = process.cwd()) {
|
|
|
37
38
|
const order = new Set([...listed.map((e) => e.id), ...APPS.map((a) => a.id)]);
|
|
38
39
|
const apps = [...order]
|
|
39
40
|
.map(byId)
|
|
40
|
-
.filter((a) => a && !off.has(a.id))
|
|
41
|
+
.filter((a) => a && (includeOff || !off.has(a.id)))
|
|
41
42
|
.map((a) => {
|
|
42
43
|
const e = entryOf(a.id);
|
|
43
44
|
return {
|
|
44
45
|
...a,
|
|
46
|
+
...(includeOff && { enabled: !off.has(a.id) }),
|
|
45
47
|
port: e?.port,
|
|
46
48
|
path: pathOf(a.id),
|
|
47
49
|
hidden: e?.hidden === true,
|
package/lib/ext.js
CHANGED
|
@@ -6,7 +6,7 @@ const path = require('node:path');
|
|
|
6
6
|
const { parseArgs } = require('node:util');
|
|
7
7
|
const { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, readApp } = require('./apps');
|
|
8
8
|
const { isContained } = require('./contain');
|
|
9
|
-
const { writeConfigFile } = require('./hub-config');
|
|
9
|
+
const { readConfig, writeConfigFile } = require('./hub-config');
|
|
10
10
|
const { claudeEnv, hasClaude, installPlugin, reinstallPlugin, uninstallPlugin, report, run } = require('./install');
|
|
11
11
|
const { pluginChecker } = require('./plugin-status');
|
|
12
12
|
const { isNewer } = require('./update-check');
|
|
@@ -28,21 +28,6 @@ const USAGE = `Usage: claude-code-hub ext <command>
|
|
|
28
28
|
const RESTART = ' Restart the hub to load the change.';
|
|
29
29
|
const REGISTRY = 'https://nikiforovall.blog/claude-code-hub/extensions.json';
|
|
30
30
|
|
|
31
|
-
function readConfig(hubDir) {
|
|
32
|
-
const file = path.join(hubDir, 'config.json');
|
|
33
|
-
let raw;
|
|
34
|
-
try {
|
|
35
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
36
|
-
} catch (e) {
|
|
37
|
-
if (e.code === 'ENOENT') return { file, config: {} };
|
|
38
|
-
throw e;
|
|
39
|
-
}
|
|
40
|
-
try {
|
|
41
|
-
return { file, config: JSON.parse(raw) };
|
|
42
|
-
} catch (e) {
|
|
43
|
-
throw new Error(`${file}: ${e.message}`);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
31
|
|
|
47
32
|
function checkRequires(ctx, { id, requires }, config) {
|
|
48
33
|
if (!requires) return;
|
package/lib/hub-config.js
CHANGED
|
@@ -12,4 +12,21 @@ function writeConfigFile(file, config) {
|
|
|
12
12
|
fs.writeFileSync(file, serializeConfig(config));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// A missing file reads as {}. Bad JSON throws, so a caller does not write over a hand edit.
|
|
16
|
+
function readConfig(hubDir) {
|
|
17
|
+
const file = path.join(hubDir, 'config.json');
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (e.code === 'ENOENT') return { file, config: {} };
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return { file, config: JSON.parse(raw) };
|
|
27
|
+
} catch (e) {
|
|
28
|
+
throw new Error(`${file}: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { readConfig, serializeConfig, writeConfigFile };
|
package/lib/keymap.js
CHANGED
|
@@ -6,6 +6,7 @@ const HUB_ACTIONS = [
|
|
|
6
6
|
'hub.projectPicker',
|
|
7
7
|
'hub.configDirPicker',
|
|
8
8
|
'hub.appLauncher',
|
|
9
|
+
'hub.settings',
|
|
9
10
|
'hub.prevApp',
|
|
10
11
|
'hub.nextApp',
|
|
11
12
|
'hub.appByNumber',
|
|
@@ -15,7 +16,7 @@ const PATTERN_ACTIONS = new Set(['hub.appByNumber']);
|
|
|
15
16
|
const MODS = ['ctrl', 'alt', 'shift', 'meta'];
|
|
16
17
|
|
|
17
18
|
// A combo name as the SDK's comboOf() gives it (protocol section 8), with one modifier at least.
|
|
18
|
-
const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9]|[A-Z][A-Za-z0-9]+)$/;
|
|
19
|
+
const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9,]|[A-Z][A-Za-z0-9]+)$/;
|
|
19
20
|
|
|
20
21
|
// "Ctrl+Alt+O" → "ctrl+alt+o", the form comboOf() gives. Null when the text cannot name a combo.
|
|
21
22
|
function normalizeCombo(text) {
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { selectApps } = require('./apps');
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR_SWITCH = ['palette', 'swap'];
|
|
6
|
+
const DEFAULT_POOL_SIZE = 3;
|
|
7
|
+
const MAX_POOL_SIZE = 8;
|
|
8
|
+
const APP_STATES = ['shown', 'hidden', 'off'];
|
|
9
|
+
|
|
10
|
+
const validPoolSize = (n) => Number.isInteger(n) && n >= 1 && n <= MAX_POOL_SIZE;
|
|
11
|
+
|
|
12
|
+
function configDirSwitch(saved) {
|
|
13
|
+
return CONFIG_DIR_SWITCH.includes(saved?.configDirSwitch) ? saved.configDirSwitch : 'palette';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function poolSize(saved, flag) {
|
|
17
|
+
if (flag !== null && flag !== undefined) return Math.max(1, parseInt(flag, 10) || DEFAULT_POOL_SIZE);
|
|
18
|
+
return validPoolSize(saved?.poolSize) ? saved.poolSize : DEFAULT_POOL_SIZE;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The keys the running hub applies at once, so it keeps them in memory.
|
|
22
|
+
const LIVE_SETTINGS = ['configDirSwitch', 'poolSize'];
|
|
23
|
+
|
|
24
|
+
function copyLive(from, to) {
|
|
25
|
+
for (const k of LIVE_SETTINGS) if (from[k] !== undefined) to[k] = from[k];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Every app the `apps` list can turn on, in tab order, with the state its entry gives it.
|
|
29
|
+
function appSettings(entries = [], base) {
|
|
30
|
+
return selectApps(entries, base, { includeOff: true }).apps.map((a) => ({
|
|
31
|
+
...a,
|
|
32
|
+
state: !a.enabled ? 'off' : a.hidden ? 'hidden' : 'shown',
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// What the running hub would do differently after a restart: the apps it spawns and how.
|
|
37
|
+
function appsShape(apps) {
|
|
38
|
+
return JSON.stringify(apps.filter((a) => a.state !== 'off').map((a) => [a.id, a.state, a.deferredStart]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// `saved` is config.json as on disk. Returns the new config, or {error}. Only the keys the
|
|
42
|
+
// patch names change; an app entry keeps its other fields (port, path, source).
|
|
43
|
+
function applySettings(saved, patch, base) {
|
|
44
|
+
if (!patch || typeof patch !== 'object') return { error: 'body must be an object' };
|
|
45
|
+
const next = { ...saved };
|
|
46
|
+
if (patch.configDirSwitch !== undefined) {
|
|
47
|
+
if (!CONFIG_DIR_SWITCH.includes(patch.configDirSwitch))
|
|
48
|
+
return { error: `configDirSwitch must be one of ${CONFIG_DIR_SWITCH.join(', ')}` };
|
|
49
|
+
next.configDirSwitch = patch.configDirSwitch;
|
|
50
|
+
}
|
|
51
|
+
if (patch.poolSize !== undefined) {
|
|
52
|
+
if (!validPoolSize(patch.poolSize)) return { error: `poolSize must be an integer from 1 to ${MAX_POOL_SIZE}` };
|
|
53
|
+
next.poolSize = patch.poolSize;
|
|
54
|
+
}
|
|
55
|
+
if (patch.app !== undefined) {
|
|
56
|
+
const { id, state, deferredStart } = patch.app ?? {};
|
|
57
|
+
const current = appSettings(saved.apps, base);
|
|
58
|
+
if (!current.some((a) => a.id === id)) return { error: `Unknown app "${id}"` };
|
|
59
|
+
if (state !== undefined && !APP_STATES.includes(state))
|
|
60
|
+
return { error: `state must be one of ${APP_STATES.join(', ')}` };
|
|
61
|
+
if (deferredStart !== undefined && typeof deferredStart !== 'boolean')
|
|
62
|
+
return { error: 'deferredStart must be a boolean' };
|
|
63
|
+
if (state === 'off' && current.every((a) => a.id === id || a.state === 'off'))
|
|
64
|
+
return { error: 'At least one app must stay on' };
|
|
65
|
+
let entries = Array.isArray(saved.apps) ? [...saved.apps] : [];
|
|
66
|
+
// Listed apps come first, so a new entry appended at the end would move its tab. Listing
|
|
67
|
+
// every app in its current order keeps the order.
|
|
68
|
+
if (!entries.some((e) => e?.id === id)) {
|
|
69
|
+
const others = entries.filter((e) => !current.some((a) => a.id === e?.id));
|
|
70
|
+
entries = [...current.map((a) => entries.find((e) => e?.id === a.id) ?? { id: a.id }), ...others];
|
|
71
|
+
}
|
|
72
|
+
const i = entries.findIndex((e) => e?.id === id);
|
|
73
|
+
const entry = { ...entries[i] };
|
|
74
|
+
if (state !== undefined) {
|
|
75
|
+
entry.enabled = state !== 'off';
|
|
76
|
+
entry.hidden = state === 'hidden';
|
|
77
|
+
}
|
|
78
|
+
if (deferredStart !== undefined) entry.deferredStart = deferredStart;
|
|
79
|
+
entries[i] = entry;
|
|
80
|
+
next.apps = entries;
|
|
81
|
+
}
|
|
82
|
+
return { config: next };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
copyLive,
|
|
87
|
+
MAX_POOL_SIZE,
|
|
88
|
+
appSettings,
|
|
89
|
+
appsShape,
|
|
90
|
+
applySettings,
|
|
91
|
+
configDirSwitch,
|
|
92
|
+
poolSize,
|
|
93
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"claude-code-cost": "1.3.2",
|
|
50
|
-
"claude-code-kanban": "7.
|
|
50
|
+
"claude-code-kanban": "7.10.0",
|
|
51
51
|
"claude-code-marketplace": "1.5.2",
|
|
52
52
|
"claude-code-memory-explorer": "1.3.2",
|
|
53
53
|
"express": "^4.21.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Claude Code Hub integration: a skill to build on and extend the hub, with links to its docs, the extension mechanism and custom themes",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NikiforovAll"
|
|
@@ -35,13 +35,14 @@ Ctrl+Alt+O opens the project palette, Ctrl+Alt+H and Ctrl+Alt+L move between too
|
|
|
35
35
|
| `hub.projectPicker` | `ctrl+alt+p` | `ctrl+alt+p` |
|
|
36
36
|
| `hub.configDirPicker` | `ctrl+alt+w` | `ctrl+alt+w` |
|
|
37
37
|
| `hub.appLauncher` | `ctrl+alt+a` | `ctrl+alt+a` |
|
|
38
|
+
| `hub.settings` | `ctrl+,` | `ctrl+,` |
|
|
38
39
|
| `hub.prevApp` | `ctrl+alt+ArrowLeft` | `ctrl+alt+ArrowLeft` |
|
|
39
40
|
| `hub.nextApp` | `ctrl+alt+ArrowRight` | `ctrl+alt+ArrowRight` |
|
|
40
41
|
| `hub.appByNumber` | `alt+{n}` | `ctrl+alt+{n}` |
|
|
41
42
|
|
|
42
43
|
## Rules
|
|
43
44
|
|
|
44
|
-
- **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
|
|
45
|
+
- **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, `,`, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
|
|
45
46
|
- **`{n}`.** `hub.appByNumber` ends in `+{n}`, and the hub binds it for 1 to 9. It is the only action with `{n}`.
|
|
46
47
|
- **`null`** leaves the action with no key.
|
|
47
48
|
- **Taking a default.** A combo that is another action's default moves to the new action, and the other action has no key until it gets one: `"hub.nextApp": "ctrl+alt+a"` leaves the app launcher with no key. Say so to the user.
|
package/public/app.js
CHANGED
|
@@ -40,6 +40,8 @@ const missed = new Map();
|
|
|
40
40
|
const MAX_EVENT_CHARS = 16 * 1024;
|
|
41
41
|
// The config dir the hub falls back to (~/.claude). Stays out of the window title.
|
|
42
42
|
let defaultConfigDir = null;
|
|
43
|
+
// What Ctrl+Alt+W does: 'palette' opens the config-dir palette, 'swap' switches to the previous dir.
|
|
44
|
+
let configDirSwitch = 'palette';
|
|
43
45
|
// The app launcher's footer: the hub version from /api/config and its plugin from /api/apps/stats.
|
|
44
46
|
const hubInfo = { version: null, plugin: null };
|
|
45
47
|
// mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
|
|
@@ -47,7 +49,7 @@ const palette = {
|
|
|
47
49
|
open: false,
|
|
48
50
|
mode: 'project',
|
|
49
51
|
projects: [],
|
|
50
|
-
configDirs: { dirs: [], active: null },
|
|
52
|
+
configDirs: { dirs: [], active: null, previous: null },
|
|
51
53
|
rows: [],
|
|
52
54
|
sel: 0,
|
|
53
55
|
query: '',
|
|
@@ -68,9 +70,11 @@ function loadThemeState() {
|
|
|
68
70
|
|
|
69
71
|
// The color theme of the active config dir, falling back to the last one picked anywhere.
|
|
70
72
|
// An id the registry lacks shows as Ember everywhere, so the hub page and every app agree.
|
|
73
|
+
const DEFAULT_COLOR_THEME = 'ember';
|
|
74
|
+
|
|
71
75
|
function activeColorTheme() {
|
|
72
76
|
const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
|
|
73
|
-
return id && hubThemes.length && !themeVars[id] ?
|
|
77
|
+
return id && hubThemes.length && !themeVars[id] ? DEFAULT_COLOR_THEME : id;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
function osTheme() {
|
|
@@ -85,7 +89,7 @@ function themePayload() {
|
|
|
85
89
|
|
|
86
90
|
function themeEvent() {
|
|
87
91
|
const payload = themePayload();
|
|
88
|
-
const vars = themeVars[payload.colorTheme ??
|
|
92
|
+
const vars = themeVars[payload.colorTheme ?? DEFAULT_COLOR_THEME]?.[payload.theme];
|
|
89
93
|
return vars ? { ...payload, vars } : payload;
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -122,8 +126,10 @@ function applyHubTheme() {
|
|
|
122
126
|
const mode = themeState.theme ?? osTheme();
|
|
123
127
|
const root = document.documentElement;
|
|
124
128
|
root.classList.toggle('light', mode === 'light');
|
|
125
|
-
|
|
126
|
-
const
|
|
129
|
+
// No app has named a theme yet: paint the one every app starts with.
|
|
130
|
+
const theme = activeColorTheme() ?? DEFAULT_COLOR_THEME;
|
|
131
|
+
const key = `${theme}/${mode}`;
|
|
132
|
+
const core = themeVars[theme]?.[mode];
|
|
127
133
|
let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
|
|
128
134
|
try {
|
|
129
135
|
if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
|
|
@@ -273,6 +279,7 @@ async function init() {
|
|
|
273
279
|
setApps(config.apps);
|
|
274
280
|
defaultConfigDir = config.defaultConfigDir ?? null;
|
|
275
281
|
activeConfigDir = config.activeConfigDir ?? null;
|
|
282
|
+
configDirSwitch = config.configDirSwitch ?? 'palette';
|
|
276
283
|
applyTitle(config.activeConfigDir);
|
|
277
284
|
themeVars = config.themeVars ?? {};
|
|
278
285
|
hubThemes = config.themes ?? [];
|
|
@@ -285,6 +292,7 @@ async function init() {
|
|
|
285
292
|
listenMessages();
|
|
286
293
|
listenKeys();
|
|
287
294
|
bindPalette();
|
|
295
|
+
bindSettings();
|
|
288
296
|
registerSW();
|
|
289
297
|
bindErrorToast();
|
|
290
298
|
showUpdateToast();
|
|
@@ -710,8 +718,14 @@ function handleForwardedKey(d) {
|
|
|
710
718
|
function hubActions() {
|
|
711
719
|
return [
|
|
712
720
|
{ id: 'hub.projectPicker', combo: 'ctrl+alt+p', run: () => togglePalette('project'), inPalette: true },
|
|
713
|
-
{
|
|
721
|
+
{
|
|
722
|
+
id: 'hub.configDirPicker',
|
|
723
|
+
combo: 'ctrl+alt+w',
|
|
724
|
+
run: () => (configDirSwitch === 'swap' ? swapConfigDir() : togglePalette('configDir')),
|
|
725
|
+
inPalette: true,
|
|
726
|
+
},
|
|
714
727
|
{ id: 'hub.appLauncher', combo: 'ctrl+alt+a', run: () => togglePalette('app'), inPalette: true },
|
|
728
|
+
{ id: 'hub.settings', combo: 'ctrl+,', run: () => toggleSettings(), inPalette: true },
|
|
715
729
|
{ id: 'hub.prevApp', combo: 'ctrl+alt+ArrowLeft', run: () => cycleTab(-1) },
|
|
716
730
|
{ id: 'hub.nextApp', combo: 'ctrl+alt+ArrowRight', run: () => cycleTab(1) },
|
|
717
731
|
{
|
|
@@ -784,13 +798,14 @@ function listenKeys() {
|
|
|
784
798
|
binding.run();
|
|
785
799
|
return;
|
|
786
800
|
}
|
|
787
|
-
// While the palette is open
|
|
788
|
-
// mid-path (Alt+digit especially, since Windows paths contain digits).
|
|
789
|
-
// bound here as well as on the input so it works wherever focus landed in
|
|
790
|
-
|
|
801
|
+
// While the palette or the settings modal is open, its input or buttons own the keyboard — don't
|
|
802
|
+
// let tab shortcuts fire mid-path (Alt+digit especially, since Windows paths contain digits).
|
|
803
|
+
// Escape still closes: bound here as well as on the input so it works wherever focus landed in
|
|
804
|
+
// the hub document.
|
|
805
|
+
if (openOverlay) {
|
|
791
806
|
if (e.key === 'Escape') {
|
|
792
807
|
e.preventDefault();
|
|
793
|
-
|
|
808
|
+
openOverlay.close();
|
|
794
809
|
}
|
|
795
810
|
return;
|
|
796
811
|
}
|
|
@@ -1174,9 +1189,11 @@ const PALETTE_MODES = {
|
|
|
1174
1189
|
if (looksLikePath(typed) && !dirs.some((d) => d.toLowerCase() === q)) rows.push({ kind: 'literal', path: typed });
|
|
1175
1190
|
return rows;
|
|
1176
1191
|
},
|
|
1177
|
-
// Enter on the default row must switch, so the cursor lands on the dir
|
|
1178
|
-
// wrapping. With two dirs the palette then behaves like a toggle.
|
|
1192
|
+
// Enter on the default row must switch, so the cursor lands on the previous dir, else on the
|
|
1193
|
+
// dir before the active one, wrapping. With two dirs the palette then behaves like a toggle.
|
|
1179
1194
|
initialSel(rows) {
|
|
1195
|
+
const previous = rows.findIndex((r) => r.path === palette.configDirs.previous);
|
|
1196
|
+
if (previous >= 0) return previous;
|
|
1180
1197
|
const active = rows.findIndex((r) => r.active);
|
|
1181
1198
|
if (active < 0 || rows.length < 2) return 0;
|
|
1182
1199
|
return (active - 1 + rows.length) % rows.length;
|
|
@@ -1250,6 +1267,225 @@ const PALETTE_MODES = {
|
|
|
1250
1267
|
},
|
|
1251
1268
|
};
|
|
1252
1269
|
|
|
1270
|
+
// The hub settings modal (Ctrl+,). Each change is saved at once; data is the last /api/settings answer.
|
|
1271
|
+
const settingsModal = { open: false, data: null, error: null, busy: false };
|
|
1272
|
+
|
|
1273
|
+
const SWITCH_CHOICES = [
|
|
1274
|
+
['palette', 'Open the list', 'Opens the config-dir list. Pick a dir, or type a path to add one.'],
|
|
1275
|
+
[
|
|
1276
|
+
'swap',
|
|
1277
|
+
'Swap to last dir',
|
|
1278
|
+
'Switches straight to the dir you used before, with no list. With one dir only, it opens the list.',
|
|
1279
|
+
],
|
|
1280
|
+
];
|
|
1281
|
+
const SHOW_CHOICES = [
|
|
1282
|
+
['shown', 'Tab'],
|
|
1283
|
+
['hidden', 'Launcher only'],
|
|
1284
|
+
['off', 'Off'],
|
|
1285
|
+
];
|
|
1286
|
+
const START_CHOICES = [
|
|
1287
|
+
[false, 'With hub'],
|
|
1288
|
+
[true, 'On demand'],
|
|
1289
|
+
];
|
|
1290
|
+
|
|
1291
|
+
function toggleSettings() {
|
|
1292
|
+
if (settingsModal.open) closeSettings();
|
|
1293
|
+
else openSettings();
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function openSettings() {
|
|
1297
|
+
settingsModal.open = true;
|
|
1298
|
+
settingsModal.error = null;
|
|
1299
|
+
showOverlay('settings', closeSettings);
|
|
1300
|
+
renderSettings();
|
|
1301
|
+
document.querySelector('#settings .settings-box').focus();
|
|
1302
|
+
try {
|
|
1303
|
+
settingsModal.data = await sendJson('GET', '/api/settings');
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
settingsModal.error = err.message;
|
|
1306
|
+
}
|
|
1307
|
+
if (settingsModal.open) renderSettings();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function closeSettings() {
|
|
1311
|
+
settingsModal.open = false;
|
|
1312
|
+
hideOverlay('settings');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function saveSetting(patch) {
|
|
1316
|
+
if (settingsModal.busy) return;
|
|
1317
|
+
settingsModal.busy = true;
|
|
1318
|
+
try {
|
|
1319
|
+
settingsModal.data = await sendJson('POST', '/api/settings', patch);
|
|
1320
|
+
settingsModal.error = null;
|
|
1321
|
+
configDirSwitch = settingsModal.data.configDirSwitch;
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
settingsModal.error = err.message;
|
|
1324
|
+
} finally {
|
|
1325
|
+
settingsModal.busy = false;
|
|
1326
|
+
}
|
|
1327
|
+
if (settingsModal.open) renderSettings();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// A row of buttons that acts as one radio group: Tab reaches the checked one, ← and → change it.
|
|
1331
|
+
function segmented(name, choices, current, onPick, disabledOf = () => null) {
|
|
1332
|
+
const group = el('span', 'seg');
|
|
1333
|
+
group.setAttribute('role', 'radiogroup');
|
|
1334
|
+
group.setAttribute('aria-label', name);
|
|
1335
|
+
for (const [value, label] of choices) {
|
|
1336
|
+
const btn = el('button', value === current ? 'on' : '', label);
|
|
1337
|
+
btn.type = 'button';
|
|
1338
|
+
btn.setAttribute('role', 'radio');
|
|
1339
|
+
btn.setAttribute('aria-checked', String(value === current));
|
|
1340
|
+
btn.tabIndex = value === current ? 0 : -1;
|
|
1341
|
+
const why = disabledOf(value);
|
|
1342
|
+
if (why) {
|
|
1343
|
+
btn.disabled = true;
|
|
1344
|
+
btn.title = why;
|
|
1345
|
+
}
|
|
1346
|
+
btn.addEventListener('click', () => value !== current && onPick(value));
|
|
1347
|
+
group.append(btn);
|
|
1348
|
+
}
|
|
1349
|
+
return group;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function settingRow(label, control, help) {
|
|
1353
|
+
const row = el('div', 'set-row');
|
|
1354
|
+
row.append(typeof label === 'string' ? el('span', 'set-label', label) : label, control);
|
|
1355
|
+
if (help) row.append(el('span', 'set-help', help));
|
|
1356
|
+
return row;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function renderSettings() {
|
|
1360
|
+
const body = document.getElementById('settings-body');
|
|
1361
|
+
const foot = document.getElementById('settings-foot');
|
|
1362
|
+
const s = settingsModal.data;
|
|
1363
|
+
const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
|
|
1364
|
+
if (!s) {
|
|
1365
|
+
body.replaceChildren(el('div', 'set-help', settingsModal.error ?? 'Loading…'));
|
|
1366
|
+
foot.replaceChildren();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const dirs = el('section', 'set-sec');
|
|
1371
|
+
dirs.append(el('h4', '', 'Config dirs'));
|
|
1372
|
+
const key = actionKeys()['hub.configDirPicker'];
|
|
1373
|
+
const label = el('span', 'set-label');
|
|
1374
|
+
if (key) label.append(kbd(comboLabel(key)), ' does');
|
|
1375
|
+
else label.textContent = 'Config-dir key does';
|
|
1376
|
+
const sw = SWITCH_CHOICES.find(([v]) => v === s.configDirSwitch);
|
|
1377
|
+
const switchSeg = segmented('Config-dir key', SWITCH_CHOICES, s.configDirSwitch, (v) =>
|
|
1378
|
+
saveSetting({ configDirSwitch: v }),
|
|
1379
|
+
);
|
|
1380
|
+
switchSeg.dataset.focus = 'switch';
|
|
1381
|
+
dirs.append(settingRow(label, switchSeg, sw[2]));
|
|
1382
|
+
|
|
1383
|
+
const step = el('span', 'step');
|
|
1384
|
+
const stepBtn = (text, delta, aria) => {
|
|
1385
|
+
const b = el('button', '', text);
|
|
1386
|
+
b.type = 'button';
|
|
1387
|
+
b.setAttribute('aria-label', aria);
|
|
1388
|
+
const n = s.poolSize + delta;
|
|
1389
|
+
b.disabled = s.poolSizeFlag || n < 1 || n > s.maxPoolSize;
|
|
1390
|
+
b.dataset.focus = `pool${delta}`;
|
|
1391
|
+
b.addEventListener('click', () => saveSetting({ poolSize: n }));
|
|
1392
|
+
return b;
|
|
1393
|
+
};
|
|
1394
|
+
step.append(stepBtn('−', -1, 'Fewer'), el('b', '', String(s.poolSize)), stepBtn('+', 1, 'More'), ' dirs');
|
|
1395
|
+
const poolHelp = s.poolSizeFlag
|
|
1396
|
+
? 'Set by --pool-size for this run.'
|
|
1397
|
+
: s.poolSize === 1
|
|
1398
|
+
? 'Only the active dir runs its apps. Every switch waits a few seconds while the apps start.'
|
|
1399
|
+
: `The ${s.poolSize} dirs you used last keep their apps running, so going back to them is instant. Each one runs its own copy of every app. Others start again when you open them (a few seconds).`;
|
|
1400
|
+
dirs.append(settingRow('Instant switch-back', step, poolHelp));
|
|
1401
|
+
|
|
1402
|
+
const manage = el('button', '', 'Manage…');
|
|
1403
|
+
manage.type = 'button';
|
|
1404
|
+
manage.dataset.focus = 'manage';
|
|
1405
|
+
manage.addEventListener('click', () => openPalette('configDir'));
|
|
1406
|
+
dirs.append(settingRow('Config dirs', manage));
|
|
1407
|
+
|
|
1408
|
+
const appsSec = el('section', 'set-sec');
|
|
1409
|
+
appsSec.append(el('h4', '', 'Apps'));
|
|
1410
|
+
const table = el('table', 'set-apps');
|
|
1411
|
+
const head = el('tr', '');
|
|
1412
|
+
head.append(el('th', '', 'App'), el('th', '', 'Show'), el('th', '', 'Start'));
|
|
1413
|
+
table.append(head);
|
|
1414
|
+
const onCount = s.apps.filter((a) => a.state !== 'off').length;
|
|
1415
|
+
for (const a of s.apps) {
|
|
1416
|
+
const tr = el('tr', '');
|
|
1417
|
+
const show = segmented(
|
|
1418
|
+
`Show ${a.name}`,
|
|
1419
|
+
SHOW_CHOICES,
|
|
1420
|
+
a.state,
|
|
1421
|
+
(v) => saveSetting({ app: { id: a.id, state: v } }),
|
|
1422
|
+
(v) => (v === 'off' && a.state !== 'off' && onCount === 1 ? 'At least one app must stay on' : null),
|
|
1423
|
+
);
|
|
1424
|
+
show.dataset.focus = `show:${a.id}`;
|
|
1425
|
+
const start = segmented(
|
|
1426
|
+
`Start ${a.name}`,
|
|
1427
|
+
START_CHOICES,
|
|
1428
|
+
a.deferredStart,
|
|
1429
|
+
(v) => saveSetting({ app: { id: a.id, deferredStart: v } }),
|
|
1430
|
+
() => (a.state === 'off' ? 'The app is off' : null),
|
|
1431
|
+
);
|
|
1432
|
+
start.dataset.focus = `start:${a.id}`;
|
|
1433
|
+
const showTd = el('td', '');
|
|
1434
|
+
showTd.append(show);
|
|
1435
|
+
const startTd = el('td', '');
|
|
1436
|
+
startTd.append(start);
|
|
1437
|
+
tr.append(el('td', '', a.name), showTd, startTd);
|
|
1438
|
+
table.append(tr);
|
|
1439
|
+
}
|
|
1440
|
+
appsSec.append(
|
|
1441
|
+
table,
|
|
1442
|
+
el(
|
|
1443
|
+
'span',
|
|
1444
|
+
'set-help',
|
|
1445
|
+
'“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.',
|
|
1446
|
+
),
|
|
1447
|
+
);
|
|
1448
|
+
body.replaceChildren(dirs, appsSec);
|
|
1449
|
+
|
|
1450
|
+
let status = el('span', '', 'Saved as you change it');
|
|
1451
|
+
if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
|
|
1452
|
+
else if (s.restartNeeded) status = el('span', 'set-restart', 'Restart the hub to apply app changes');
|
|
1453
|
+
const keys = el('span', 'set-keys');
|
|
1454
|
+
keys.append(kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
|
|
1455
|
+
foot.replaceChildren(status, keys);
|
|
1456
|
+
|
|
1457
|
+
// A save redraws the modal, so put focus back on the control that had it.
|
|
1458
|
+
if (focusKey) {
|
|
1459
|
+
const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
|
|
1460
|
+
// A radio group gets its checked button; a button that became disabled hands focus to its neighbour.
|
|
1461
|
+
const focusable = target?.matches('button:not(:disabled)')
|
|
1462
|
+
? target
|
|
1463
|
+
: (target?.querySelector('[aria-checked="true"]:not(:disabled)') ??
|
|
1464
|
+
target?.parentElement.querySelector('button:not(:disabled)'));
|
|
1465
|
+
(focusable ?? document.querySelector('#settings .settings-box')).focus();
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function kbd(text) {
|
|
1470
|
+
return el('kbd', '', text);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function bindSettings() {
|
|
1474
|
+
const overlay = document.getElementById('settings');
|
|
1475
|
+
overlay.addEventListener('mousedown', (e) => {
|
|
1476
|
+
if (e.target === overlay) closeSettings();
|
|
1477
|
+
});
|
|
1478
|
+
overlay.addEventListener('keydown', (e) => {
|
|
1479
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
1480
|
+
const group = e.target.closest('[role="radiogroup"]');
|
|
1481
|
+
if (!group) return;
|
|
1482
|
+
e.preventDefault();
|
|
1483
|
+
const radios = [...group.querySelectorAll('[role="radio"]:not(:disabled)')];
|
|
1484
|
+
const i = radios.indexOf(e.target);
|
|
1485
|
+
radios[i + (e.key === 'ArrowRight' ? 1 : -1)]?.click();
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1253
1489
|
function renderPalette() {
|
|
1254
1490
|
const list = document.getElementById('palette-list');
|
|
1255
1491
|
const spec = PALETTE_MODES[palette.mode];
|
|
@@ -1306,11 +1542,7 @@ function openPalette(mode) {
|
|
|
1306
1542
|
input.placeholder = spec.placeholder;
|
|
1307
1543
|
document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
|
|
1308
1544
|
drawPaletteHint();
|
|
1309
|
-
|
|
1310
|
-
// Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
|
|
1311
|
-
// or take focus back, and then Escape is handled inside it — the palette stays open and only the
|
|
1312
|
-
// sub-app's own focus visibly changes.
|
|
1313
|
-
setIframesInert(true);
|
|
1545
|
+
showOverlay('palette', closePalette);
|
|
1314
1546
|
renderPalette();
|
|
1315
1547
|
input.focus();
|
|
1316
1548
|
// Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
|
|
@@ -1328,7 +1560,25 @@ function openPalette(mode) {
|
|
|
1328
1560
|
|
|
1329
1561
|
function closePalette() {
|
|
1330
1562
|
palette.open = false;
|
|
1331
|
-
|
|
1563
|
+
hideOverlay('palette');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// The palette and the settings modal: one is open at a time, and Escape closes it.
|
|
1567
|
+
let openOverlay = null;
|
|
1568
|
+
|
|
1569
|
+
function showOverlay(id, close) {
|
|
1570
|
+
if (openOverlay && openOverlay.id !== id) openOverlay.close();
|
|
1571
|
+
openOverlay = { id, close };
|
|
1572
|
+
document.getElementById(id).hidden = false;
|
|
1573
|
+
// Hub keys usually arrive forwarded from a focused iframe. Without inert the sub-app can keep
|
|
1574
|
+
// or take focus back, and then Escape is handled inside it — the overlay stays open and only the
|
|
1575
|
+
// sub-app's own focus visibly changes.
|
|
1576
|
+
setIframesInert(true);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function hideOverlay(id) {
|
|
1580
|
+
if (openOverlay?.id === id) openOverlay = null;
|
|
1581
|
+
document.getElementById(id).hidden = true;
|
|
1332
1582
|
setIframesInert(false);
|
|
1333
1583
|
iframes[activeApp]?.focus();
|
|
1334
1584
|
}
|
|
@@ -1394,19 +1644,39 @@ async function commitConfigDir(row) {
|
|
|
1394
1644
|
closePalette();
|
|
1395
1645
|
// The server would no-op too, but the client would still reload every iframe.
|
|
1396
1646
|
if (target === palette.configDirs.active) return;
|
|
1647
|
+
await switchConfigDir(() => sendJson('POST', '/api/config-dirs/activate', { path: target }));
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// The previous dir, with no palette. With none (one dir only) it opens the palette to add one.
|
|
1651
|
+
let swapping = false;
|
|
1652
|
+
async function swapConfigDir() {
|
|
1653
|
+
if (swapping) return;
|
|
1654
|
+
swapping = true;
|
|
1655
|
+
openOverlay?.close();
|
|
1656
|
+
try {
|
|
1657
|
+
await switchConfigDir(() => sendJson('POST', '/api/config-dirs/swap'));
|
|
1658
|
+
} finally {
|
|
1659
|
+
swapping = false;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function switchConfigDir(request) {
|
|
1397
1664
|
showLoading();
|
|
1398
1665
|
try {
|
|
1399
|
-
|
|
1666
|
+
const { apps: list, active, previous } = await request();
|
|
1667
|
+
setApps(list);
|
|
1400
1668
|
// The palette renders from this cache before its refetch lands, and initialSel keys off it.
|
|
1401
|
-
palette.configDirs.
|
|
1402
|
-
|
|
1403
|
-
|
|
1669
|
+
palette.configDirs.previous = previous;
|
|
1670
|
+
palette.configDirs.active = active;
|
|
1671
|
+
activeConfigDir = active;
|
|
1672
|
+
applyTitle(active);
|
|
1404
1673
|
// Each iframe gets the new dir's color theme from its load handler; this repaints the palette.
|
|
1405
1674
|
applyHubTheme();
|
|
1406
1675
|
reloadIframes();
|
|
1407
1676
|
} catch (err) {
|
|
1408
|
-
showErrorToast('Could not switch config dir', err);
|
|
1409
1677
|
hideLoading();
|
|
1678
|
+
if (err.status === 409) openPalette('configDir');
|
|
1679
|
+
else showErrorToast('Could not switch config dir', err);
|
|
1410
1680
|
}
|
|
1411
1681
|
}
|
|
1412
1682
|
|
package/public/index.html
CHANGED
|
@@ -238,8 +238,15 @@
|
|
|
238
238
|
/* Bar over the neutral hover background, no accent tint: the faintest theme accents
|
|
239
239
|
(monokai light #c08a00, vitesse light #1c6b48) barely register as a tint, but read
|
|
240
240
|
clearly as a 2px bar. */
|
|
241
|
-
.palette-row.selected
|
|
241
|
+
.palette-row.selected,
|
|
242
|
+
.set-row:focus-within,
|
|
243
|
+
.set-apps tr:focus-within td {
|
|
242
244
|
background: var(--surface-hover);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.palette-row.selected,
|
|
248
|
+
.set-row:focus-within,
|
|
249
|
+
.set-apps tr:focus-within td:first-child {
|
|
243
250
|
box-shadow: inset 2px 0 0 var(--accent);
|
|
244
251
|
}
|
|
245
252
|
|
|
@@ -380,6 +387,118 @@
|
|
|
380
387
|
|
|
381
388
|
.palette-hint:empty { display: none; }
|
|
382
389
|
|
|
390
|
+
/* Built from the palette's parts so the two read as one: same box, row padding, selected-row
|
|
391
|
+
bar (on the row that holds focus) and footer. Controls follow cck's quiet ones: an accent
|
|
392
|
+
tint for the chosen option, not a solid fill. */
|
|
393
|
+
.settings-box {
|
|
394
|
+
max-height: 76vh;
|
|
395
|
+
overflow-y: auto;
|
|
396
|
+
color: var(--text);
|
|
397
|
+
font-size: 13px;
|
|
398
|
+
outline: none;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.settings-bar {
|
|
402
|
+
display: flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
gap: 10px;
|
|
405
|
+
padding: 12px 14px;
|
|
406
|
+
border-bottom: 1px solid var(--border);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.settings-bar h3 { font-size: 14px; font-weight: 600; }
|
|
410
|
+
.settings-bar kbd { margin-left: auto; }
|
|
411
|
+
|
|
412
|
+
.set-sec { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|
413
|
+
.set-sec:last-child { border-bottom: none; }
|
|
414
|
+
|
|
415
|
+
.set-sec h4 {
|
|
416
|
+
padding: 6px 14px 4px;
|
|
417
|
+
font-size: 11px;
|
|
418
|
+
font-weight: 600;
|
|
419
|
+
letter-spacing: 0.05em;
|
|
420
|
+
text-transform: uppercase;
|
|
421
|
+
color: var(--text-dim);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.set-row {
|
|
425
|
+
display: grid;
|
|
426
|
+
grid-template-columns: 1fr auto;
|
|
427
|
+
gap: 2px 12px;
|
|
428
|
+
align-items: center;
|
|
429
|
+
padding: 8px 14px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
.set-label { font-weight: 600; }
|
|
434
|
+
.set-help { grid-column: 1 / -1; color: var(--text-dim); font-size: 12px; }
|
|
435
|
+
.set-sec > .set-help { display: block; padding: 6px 14px 8px; }
|
|
436
|
+
#settings-body > .set-help { display: block; padding: 12px 14px; }
|
|
437
|
+
|
|
438
|
+
.settings-box button {
|
|
439
|
+
padding: 2px 8px;
|
|
440
|
+
border: 1px solid var(--border);
|
|
441
|
+
border-radius: 4px;
|
|
442
|
+
background: transparent;
|
|
443
|
+
color: var(--text-dim);
|
|
444
|
+
font: inherit;
|
|
445
|
+
font-size: 12px;
|
|
446
|
+
cursor: pointer;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.settings-box button:hover:not(:disabled) { color: var(--text); }
|
|
450
|
+
.settings-box button:disabled { opacity: 0.4; cursor: default; }
|
|
451
|
+
|
|
452
|
+
.settings-box button:focus-visible {
|
|
453
|
+
outline: none;
|
|
454
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.seg {
|
|
458
|
+
display: inline-flex;
|
|
459
|
+
gap: 2px;
|
|
460
|
+
padding: 2px;
|
|
461
|
+
border: 1px solid var(--border);
|
|
462
|
+
border-radius: 6px;
|
|
463
|
+
white-space: nowrap;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.seg button { border-color: transparent; }
|
|
467
|
+
|
|
468
|
+
.seg button.on {
|
|
469
|
+
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
|
470
|
+
color: var(--text);
|
|
471
|
+
font-weight: 600;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.step { display: inline-flex; align-items: center; gap: 8px; color: var(--text-dim); }
|
|
475
|
+
.step button { width: 24px; padding: 2px 0; }
|
|
476
|
+
.step b { min-width: 12px; text-align: center; color: var(--text); font-variant-numeric: tabular-nums; }
|
|
477
|
+
|
|
478
|
+
.set-apps { width: 100%; border-collapse: collapse; }
|
|
479
|
+
.set-apps th, .set-apps td { padding: 5px 6px; text-align: left; white-space: nowrap; }
|
|
480
|
+
.set-apps th:first-child, .set-apps td:first-child { padding-left: 14px; width: 100%; }
|
|
481
|
+
.set-apps th:last-child, .set-apps td:last-child { padding-right: 14px; }
|
|
482
|
+
.set-apps th { color: var(--text-dim); font-size: 11px; font-weight: 400; }
|
|
483
|
+
.set-apps td:first-child { font-weight: 600; }
|
|
484
|
+
|
|
485
|
+
.settings-foot { display: flex; gap: 12px; }
|
|
486
|
+
|
|
487
|
+
.settings-foot .set-keys { margin-left: auto; }
|
|
488
|
+
.set-restart { color: var(--warn); }
|
|
489
|
+
.set-restart::before { content: '●'; margin-right: 6px; }
|
|
490
|
+
.set-error { color: #e5484d; }
|
|
491
|
+
|
|
492
|
+
.settings-box kbd {
|
|
493
|
+
padding: 0 4px;
|
|
494
|
+
border: 1px solid var(--border);
|
|
495
|
+
border-bottom-width: 2px;
|
|
496
|
+
border-radius: 4px;
|
|
497
|
+
background: var(--bg);
|
|
498
|
+
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
499
|
+
color: var(--text-dim);
|
|
500
|
+
}
|
|
501
|
+
|
|
383
502
|
.update-toast {
|
|
384
503
|
position: fixed;
|
|
385
504
|
right: 16px;
|
|
@@ -495,6 +614,14 @@
|
|
|
495
614
|
</div>
|
|
496
615
|
</div>
|
|
497
616
|
|
|
617
|
+
<div class="palette" id="settings" hidden>
|
|
618
|
+
<div class="palette-box settings-box" role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
|
619
|
+
<div class="settings-bar"><h3 id="settings-title">Hub settings</h3><kbd>Esc</kbd></div>
|
|
620
|
+
<div id="settings-body"></div>
|
|
621
|
+
<div class="palette-hint settings-foot" id="settings-foot"></div>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
498
625
|
<div class="update-toast" id="update-toast" role="status" hidden>
|
|
499
626
|
<div class="update-toast-body">
|
|
500
627
|
<div class="update-toast-title">Claude Code Hub <span id="update-latest"></span> is available</div>
|
package/server.js
CHANGED
|
@@ -9,7 +9,7 @@ const tcp = require('net');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { actionTable } = require('./lib/actions');
|
|
12
|
-
const { selectApps, loadApp, conflicts } = require('./lib/apps');
|
|
12
|
+
const { HUB_ROOT, selectApps, appRoot, loadApp, conflicts, readApp } = require('./lib/apps');
|
|
13
13
|
const { userKeys } = require('./lib/keymap');
|
|
14
14
|
const { stripCookie } = require('./lib/cookies');
|
|
15
15
|
const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
@@ -21,7 +21,16 @@ const { createUpdateCheck } = require('./lib/update-check');
|
|
|
21
21
|
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
22
22
|
const { HUB_PLUGIN } = require('./lib/install');
|
|
23
23
|
const { pluginChecker } = require('./lib/plugin-status');
|
|
24
|
-
const { serializeConfig, writeConfigFile } = require('./lib/hub-config');
|
|
24
|
+
const { readConfig, serializeConfig, writeConfigFile } = require('./lib/hub-config');
|
|
25
|
+
const {
|
|
26
|
+
appSettings,
|
|
27
|
+
appsShape,
|
|
28
|
+
applySettings,
|
|
29
|
+
configDirSwitch,
|
|
30
|
+
poolSize,
|
|
31
|
+
copyLive,
|
|
32
|
+
MAX_POOL_SIZE,
|
|
33
|
+
} = require('./lib/settings');
|
|
25
34
|
const { version: HUB_VERSION } = require('./package.json');
|
|
26
35
|
|
|
27
36
|
function getArg(name) {
|
|
@@ -36,7 +45,7 @@ const DEFAULT_HUB_PORT = 3540;
|
|
|
36
45
|
const HUB_PORT = parseInt(getArg('port') || process.env.PORT || DEFAULT_HUB_PORT, 10);
|
|
37
46
|
let hubPort = HUB_PORT;
|
|
38
47
|
|
|
39
|
-
const
|
|
48
|
+
const POOL_SIZE_FLAG = getArg('pool-size');
|
|
40
49
|
const RESTART_DELAY_MS = 500;
|
|
41
50
|
// A rate window, not a total: a child may die and recover all session, but one that cannot stay up
|
|
42
51
|
// stops being restarted. A consecutive counter cannot express that — anything that starts at all
|
|
@@ -150,6 +159,9 @@ function resolveHubConfig() {
|
|
|
150
159
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
151
160
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
152
161
|
if (saved.keys !== undefined) config.keys = saved.keys;
|
|
162
|
+
copyLive(saved, config);
|
|
163
|
+
const savedPrevious = typeof saved.previousConfigDir === 'string' ? canonical(saved.previousConfigDir) : null;
|
|
164
|
+
if (dirs.includes(savedPrevious) && savedPrevious !== active) config.previousConfigDir = savedPrevious;
|
|
153
165
|
return { config, raw, fallback };
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -161,12 +173,22 @@ function writeHubConfig(config) {
|
|
|
161
173
|
function saveHubConfig() {
|
|
162
174
|
let saved = hubConfig;
|
|
163
175
|
try {
|
|
164
|
-
saved =
|
|
176
|
+
saved = readSavedConfig();
|
|
165
177
|
} catch {}
|
|
166
|
-
writeHubConfig({
|
|
178
|
+
writeHubConfig({
|
|
179
|
+
...saved,
|
|
180
|
+
configDirs: hubConfig.configDirs,
|
|
181
|
+
activeConfigDir: hubConfig.activeConfigDir,
|
|
182
|
+
previousConfigDir: hubConfig.previousConfigDir,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readSavedConfig() {
|
|
187
|
+
return readConfig(path.dirname(HUB_CONFIG_FILE)).config;
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
const hubConfig = loadHubConfig();
|
|
191
|
+
const currentPoolSize = () => poolSize(hubConfig, POOL_SIZE_FLAG);
|
|
170
192
|
|
|
171
193
|
const { apps: selectedApps, unknown: unknownApps } = selectApps(hubConfig.apps, path.dirname(HUB_CONFIG_FILE));
|
|
172
194
|
const ENABLED_APPS = selectedApps.map((a) => loadApp(a, { flagPort: getArg(`${a.id}-port`) })).filter(Boolean);
|
|
@@ -380,7 +402,7 @@ function withTimeout(promise, ms) {
|
|
|
380
402
|
}
|
|
381
403
|
|
|
382
404
|
// Returns the live pool for a dir, spawning one when absent and evicting the least recently used
|
|
383
|
-
// pool past
|
|
405
|
+
// pool past the pool size. Resolves once every child has printed its port banner (or given up), so
|
|
384
406
|
// the caller can hand the client URLs that are actually live.
|
|
385
407
|
async function ensurePool(dir) {
|
|
386
408
|
let pool = pools.get(dir);
|
|
@@ -389,7 +411,7 @@ async function ensurePool(dir) {
|
|
|
389
411
|
pools.set(dir, pool);
|
|
390
412
|
return pool.ready;
|
|
391
413
|
}
|
|
392
|
-
if (pools.size >=
|
|
414
|
+
if (pools.size >= currentPoolSize()) await evictPools(dir);
|
|
393
415
|
pool = pools.get(dir);
|
|
394
416
|
if (pool) return pool.ready;
|
|
395
417
|
const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
|
|
@@ -423,10 +445,10 @@ async function evictPools(keepDir) {
|
|
|
423
445
|
const counts = await Promise.all(candidates.map(terminalCount));
|
|
424
446
|
pinned = new Set(candidates.filter((_, i) => counts[i]).map((p) => p.dir));
|
|
425
447
|
}
|
|
426
|
-
while (pools.size >=
|
|
448
|
+
while (pools.size >= currentPoolSize()) {
|
|
427
449
|
const victim = [...pools.keys()].find((d) => d !== keepDir && !pinned.has(d));
|
|
428
450
|
if (!victim) {
|
|
429
|
-
console.log(`Pool size ${
|
|
451
|
+
console.log(`Pool size ${currentPoolSize()} exceeded: every other pool has live terminals`);
|
|
430
452
|
return;
|
|
431
453
|
}
|
|
432
454
|
console.log(`Evicting children for ${victim}`);
|
|
@@ -931,13 +953,20 @@ app.get('/api/config', (_req, res) => {
|
|
|
931
953
|
keys: USER_KEYS,
|
|
932
954
|
activeConfigDir: hubConfig.activeConfigDir,
|
|
933
955
|
defaultConfigDir,
|
|
956
|
+
configDirSwitch: configDirSwitch(hubConfig),
|
|
934
957
|
version: HUB_VERSION,
|
|
935
958
|
});
|
|
936
959
|
});
|
|
937
960
|
|
|
961
|
+
// The dir a swap goes to: the one active before the current one, else the other of exactly two.
|
|
962
|
+
function swapTarget() {
|
|
963
|
+
const { configDirs: dirs, activeConfigDir: active, previousConfigDir: previous } = hubConfig;
|
|
964
|
+
return previous ?? (dirs.length === 2 ? dirs.find((d) => d !== active) : null);
|
|
965
|
+
}
|
|
966
|
+
|
|
938
967
|
app.get('/api/config-dirs', (_req, res) => {
|
|
939
968
|
res.setHeader('Cache-Control', 'no-store');
|
|
940
|
-
res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
|
|
969
|
+
res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir, previous: swapTarget() });
|
|
941
970
|
});
|
|
942
971
|
|
|
943
972
|
// Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
|
|
@@ -976,11 +1005,23 @@ app.delete('/api/config-dirs', (req, res) => {
|
|
|
976
1005
|
return;
|
|
977
1006
|
}
|
|
978
1007
|
hubConfig.configDirs = hubConfig.configDirs.filter((d) => d !== target);
|
|
1008
|
+
if (hubConfig.previousConfigDir === target) delete hubConfig.previousConfigDir;
|
|
979
1009
|
saveHubConfig();
|
|
980
1010
|
dropPool(target);
|
|
981
|
-
res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
|
|
1011
|
+
res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir, previous: swapTarget() });
|
|
982
1012
|
});
|
|
983
1013
|
|
|
1014
|
+
async function activateConfigDir(target) {
|
|
1015
|
+
if (target !== hubConfig.activeConfigDir) {
|
|
1016
|
+
console.log(`Switching CLAUDE_CONFIG_DIR -> ${target}`);
|
|
1017
|
+
await ensurePool(target);
|
|
1018
|
+
hubConfig.previousConfigDir = hubConfig.activeConfigDir;
|
|
1019
|
+
hubConfig.activeConfigDir = target;
|
|
1020
|
+
saveHubConfig();
|
|
1021
|
+
}
|
|
1022
|
+
return { apps: appsConfig(), active: target, previous: swapTarget() };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
984
1025
|
// The response carries the app URLs so the client reloads every iframe against the new set.
|
|
985
1026
|
app.post('/api/config-dirs/activate', async (req, res) => {
|
|
986
1027
|
const target = typeof req.body?.path === 'string' ? req.body.path : '';
|
|
@@ -988,13 +1029,79 @@ app.post('/api/config-dirs/activate', async (req, res) => {
|
|
|
988
1029
|
res.status(404).json({ error: 'Unknown config dir; add it first' });
|
|
989
1030
|
return;
|
|
990
1031
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1032
|
+
res.json(await activateConfigDir(target));
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
app.post('/api/config-dirs/swap', async (_req, res) => {
|
|
1036
|
+
const target = swapTarget();
|
|
1037
|
+
if (!target) {
|
|
1038
|
+
res.status(409).json({ error: 'No previous config dir' });
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
res.json(await activateConfigDir(target));
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// Apps changes need a hub restart: the app list is read once at startup.
|
|
1045
|
+
const CONFIG_BASE = path.dirname(HUB_CONFIG_FILE);
|
|
1046
|
+
const RUNNING_APPS = appsShape(appSettings(hubConfig.apps, CONFIG_BASE));
|
|
1047
|
+
const appNames = new Map(ENABLED_APPS.map((a) => [a.id, a.name]));
|
|
1048
|
+
|
|
1049
|
+
// An app that is off has no loaded manifest, so its name is read on first use.
|
|
1050
|
+
function appName(a) {
|
|
1051
|
+
if (!appNames.has(a.id)) {
|
|
1052
|
+
let name = a.id;
|
|
1053
|
+
try {
|
|
1054
|
+
name = readApp(appRoot(a, HUB_ROOT, require.resolve), a.id).manifest.name;
|
|
1055
|
+
} catch {}
|
|
1056
|
+
appNames.set(a.id, name);
|
|
1057
|
+
}
|
|
1058
|
+
return appNames.get(a.id);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function settingsView(saved) {
|
|
1062
|
+
const apps = appSettings(Array.isArray(saved.apps) ? saved.apps : [], CONFIG_BASE);
|
|
1063
|
+
return {
|
|
1064
|
+
configDirSwitch: configDirSwitch(hubConfig),
|
|
1065
|
+
poolSize: currentPoolSize(),
|
|
1066
|
+
poolSizeFlag: POOL_SIZE_FLAG !== null,
|
|
1067
|
+
maxPoolSize: MAX_POOL_SIZE,
|
|
1068
|
+
apps: apps.map((a) => ({ id: a.id, state: a.state, deferredStart: a.deferredStart, name: appName(a) })),
|
|
1069
|
+
restartNeeded: appsShape(apps) !== RUNNING_APPS,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function readSettingsConfig(res) {
|
|
1074
|
+
try {
|
|
1075
|
+
return readSavedConfig();
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
res.status(500).json({ error: e.message });
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
app.get('/api/settings', (_req, res) => {
|
|
1083
|
+
const saved = readSettingsConfig(res);
|
|
1084
|
+
if (!saved) return;
|
|
1085
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1086
|
+
res.json(settingsView(saved));
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// Writes config.json from its copy on disk, so `ext` and hand edits made while the hub runs stay.
|
|
1090
|
+
app.post('/api/settings', (req, res) => {
|
|
1091
|
+
if (req.body?.poolSize !== undefined && POOL_SIZE_FLAG !== null) {
|
|
1092
|
+
res.status(409).json({ error: '--pool-size sets the pool size for this run' });
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const saved = readSettingsConfig(res);
|
|
1096
|
+
if (!saved) return;
|
|
1097
|
+
const r = applySettings(saved, req.body, CONFIG_BASE);
|
|
1098
|
+
if (r.error) {
|
|
1099
|
+
res.status(400).json({ error: r.error });
|
|
1100
|
+
return;
|
|
996
1101
|
}
|
|
997
|
-
|
|
1102
|
+
writeHubConfig(r.config);
|
|
1103
|
+
copyLive(r.config, hubConfig);
|
|
1104
|
+
res.json(settingsView(r.config));
|
|
998
1105
|
});
|
|
999
1106
|
|
|
1000
1107
|
// The stalls hit a few percent of requests, so each round sends several probes per app.
|