claude-code-hub 1.11.0 → 1.12.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/README.md +3 -3
- package/lib/apps.js +125 -25
- package/lib/ext.js +470 -0
- package/lib/hub-config.js +15 -0
- package/lib/install.js +60 -19
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +25 -10
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +2 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensions.md +56 -0
- package/public/app.js +231 -76
- package/public/index.html +20 -13
- package/server.js +90 -43
package/public/app.js
CHANGED
|
@@ -13,6 +13,9 @@ const guardedApps = new Set();
|
|
|
13
13
|
// App id → the topics its hub:hello subscribes to. An app is live from its hello until the next
|
|
14
14
|
// iframe load: e.source is the same WindowProxy across reloads, so a new document starts unknown.
|
|
15
15
|
const liveApps = new Map();
|
|
16
|
+
// Window → {appId, topics}: an app's page framed inside another app's page, such as a cck pane
|
|
17
|
+
// (protocol §5.1). A pane does not reload when its parent does, so a closed window is dropped on send.
|
|
18
|
+
const nestedApps = new Map();
|
|
16
19
|
// Apps whose next document the hub loads from an action URL.
|
|
17
20
|
const linkLoads = new Set();
|
|
18
21
|
const HUB_PROTOCOLS = [1];
|
|
@@ -48,7 +51,7 @@ const palette = {
|
|
|
48
51
|
rows: [],
|
|
49
52
|
sel: 0,
|
|
50
53
|
query: '',
|
|
51
|
-
|
|
54
|
+
confirmOp: null,
|
|
52
55
|
message: null,
|
|
53
56
|
};
|
|
54
57
|
|
|
@@ -80,10 +83,14 @@ function themePayload() {
|
|
|
80
83
|
return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
function
|
|
86
|
+
function themeEvent() {
|
|
84
87
|
const payload = themePayload();
|
|
85
88
|
const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
|
|
86
|
-
|
|
89
|
+
return vars ? { ...payload, vars } : payload;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sendTheme(appId) {
|
|
93
|
+
sendTopic(appId, 'theme.changed', themeEvent());
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
// {<themeId>: {dark, light}}, each a map of the apps' core CSS variables, from /api/config, which
|
|
@@ -160,6 +167,36 @@ function appIdOf(source) {
|
|
|
160
167
|
return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
|
|
161
168
|
}
|
|
162
169
|
|
|
170
|
+
function appIdByOrigin(origin) {
|
|
171
|
+
return Object.keys(apps).find((id) => originOf(id) === origin);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A frame below a tab's frame. A cross-origin WindowProxy still exposes parent, and a popup or
|
|
175
|
+
// another top window never reaches a tab.
|
|
176
|
+
function underTab(source) {
|
|
177
|
+
for (let w = source?.parent; w && w !== window; w = w.parent) {
|
|
178
|
+
if (appIdOf(w)) return true;
|
|
179
|
+
if (w === w.parent) return false;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function postNested(win, message) {
|
|
185
|
+
const nested = nestedApps.get(win);
|
|
186
|
+
if (!nested) return;
|
|
187
|
+
if (win.closed) {
|
|
188
|
+
nestedApps.delete(win);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
win.postMessage(message, originOf(nested.appId));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sendNestedTheme(win, payload = themeEvent()) {
|
|
195
|
+
if (nestedApps.get(win)?.topics.has('theme.changed')) {
|
|
196
|
+
postNested(win, { type: 'hub:event', topic: 'theme.changed', payload });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
// The last value lives in themeState and projectState, so a replay after welcome is a send.
|
|
164
201
|
function sendTopic(appId, topic, payload) {
|
|
165
202
|
if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
|
|
@@ -243,7 +280,8 @@ async function init() {
|
|
|
243
280
|
applyHubTheme();
|
|
244
281
|
buildIframes();
|
|
245
282
|
const saved = storedTab();
|
|
246
|
-
|
|
283
|
+
const tabs = tabIds();
|
|
284
|
+
switchTab(tabs.includes(saved) ? saved : (tabs[0] ?? Object.keys(apps)[0]));
|
|
247
285
|
listenMessages();
|
|
248
286
|
listenKeys();
|
|
249
287
|
bindPalette();
|
|
@@ -306,16 +344,17 @@ function hideToast(toast) {
|
|
|
306
344
|
}
|
|
307
345
|
|
|
308
346
|
// Null on success. A 409 with live terminals asks for force: true on the next call.
|
|
309
|
-
|
|
347
|
+
// op is restart or stop.
|
|
348
|
+
async function requestAppOp(id, op, force) {
|
|
310
349
|
try {
|
|
311
|
-
await sendJson('POST', `/api/apps/${encodeURIComponent(id)}
|
|
350
|
+
await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/${op}`, { force });
|
|
312
351
|
return null;
|
|
313
352
|
} catch (err) {
|
|
314
353
|
const name = apps[id]?.name ?? id;
|
|
315
354
|
const n = err.status === 409 && err.data?.terminals;
|
|
316
|
-
if (!n) return { message: `${name} did not
|
|
355
|
+
if (!n) return { message: `${name} did not ${op}: ${err.message}` };
|
|
317
356
|
const them = n === 1 ? 'it' : 'them';
|
|
318
|
-
return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A
|
|
357
|
+
return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A ${op} ends ${them}.` };
|
|
319
358
|
}
|
|
320
359
|
}
|
|
321
360
|
|
|
@@ -361,6 +400,7 @@ function reloadIframes() {
|
|
|
361
400
|
projectState = null;
|
|
362
401
|
published.clear();
|
|
363
402
|
missed.clear();
|
|
403
|
+
nestedApps.clear();
|
|
364
404
|
// A new element, because assigning src is only a fragment navigation when the app's URL
|
|
365
405
|
// differs from the new one by the #t= fragment alone, and the old dir's document stays.
|
|
366
406
|
for (const id of Object.keys(iframes)) replaceIframe(id);
|
|
@@ -432,12 +472,19 @@ function makeIframe(id) {
|
|
|
432
472
|
return iframe;
|
|
433
473
|
}
|
|
434
474
|
|
|
475
|
+
// A hidden app (`hidden` in config.json) has no tab keys, so its frame loads only when the
|
|
476
|
+
// launcher or an action opens it.
|
|
477
|
+
function tabIds() {
|
|
478
|
+
return Object.keys(apps).filter((id) => !apps[id].hidden);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function addIframe(id) {
|
|
482
|
+
iframes[id] = makeIframe(id);
|
|
483
|
+
document.getElementById('iframe-container').appendChild(iframes[id]);
|
|
484
|
+
}
|
|
485
|
+
|
|
435
486
|
function buildIframes() {
|
|
436
|
-
const
|
|
437
|
-
for (const id of Object.keys(apps)) {
|
|
438
|
-
iframes[id] = makeIframe(id);
|
|
439
|
-
container.appendChild(iframes[id]);
|
|
440
|
-
}
|
|
487
|
+
for (const id of tabIds()) addIframe(id);
|
|
441
488
|
}
|
|
442
489
|
|
|
443
490
|
const TAB_KEY = 'hub-tab';
|
|
@@ -452,6 +499,7 @@ function storedTab() {
|
|
|
452
499
|
|
|
453
500
|
function switchTab(appId) {
|
|
454
501
|
if (!apps[appId]) return;
|
|
502
|
+
if (!iframes[appId]) addIframe(appId);
|
|
455
503
|
if (appId !== activeApp) previousApp = activeApp;
|
|
456
504
|
activeApp = appId;
|
|
457
505
|
try {
|
|
@@ -476,18 +524,26 @@ function onIframeLoad(appId) {
|
|
|
476
524
|
if (appId === activeApp) hideLoading();
|
|
477
525
|
}
|
|
478
526
|
|
|
479
|
-
function
|
|
527
|
+
function welcomeFor(data) {
|
|
480
528
|
const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
|
|
481
|
-
if (!common.length) return;
|
|
482
|
-
|
|
483
|
-
postTo(appId, {
|
|
529
|
+
if (!common.length) return null;
|
|
530
|
+
return {
|
|
484
531
|
type: 'hub:welcome',
|
|
485
532
|
protocol: Math.max(...common),
|
|
486
533
|
forward: Object.keys(bindings()),
|
|
487
534
|
keys: actionKeys(),
|
|
488
535
|
themes: hubThemes,
|
|
489
536
|
actions: Object.keys(actions),
|
|
490
|
-
}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
|
|
541
|
+
|
|
542
|
+
function onHello(appId, data) {
|
|
543
|
+
const welcome = welcomeFor(data);
|
|
544
|
+
if (!welcome) return;
|
|
545
|
+
liveApps.set(appId, helloTopics(data));
|
|
546
|
+
postTo(appId, welcome);
|
|
491
547
|
sendTheme(appId);
|
|
492
548
|
// A document the hub loaded from an action URL got its project from that URL. The hub's own
|
|
493
549
|
// project would undo the link, so it goes out only with the next change.
|
|
@@ -522,9 +578,9 @@ function fillUrl(template, params) {
|
|
|
522
578
|
}
|
|
523
579
|
|
|
524
580
|
// Protocol §7. The result does not wait for the handler: the hub queues nothing.
|
|
525
|
-
function onInvoke(
|
|
581
|
+
function onInvoke({ id, action, params = {} }, send) {
|
|
526
582
|
if (typeof id !== 'string') return;
|
|
527
|
-
const reply = (result) =>
|
|
583
|
+
const reply = (result) => send({ type: 'hub:result', id, ...result });
|
|
528
584
|
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
529
585
|
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
530
586
|
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
@@ -546,12 +602,13 @@ function listenMessages() {
|
|
|
546
602
|
});
|
|
547
603
|
window.addEventListener('message', (e) => {
|
|
548
604
|
const appId = appIdOf(e.source);
|
|
549
|
-
if (!appId
|
|
605
|
+
if (!appId) return onNestedMessage(e);
|
|
606
|
+
if (e.origin !== originOf(appId)) return;
|
|
550
607
|
const data = e.data ?? {};
|
|
551
608
|
if (data.type === 'hub:hello') {
|
|
552
609
|
onHello(appId, data);
|
|
553
610
|
} else if (data.type === 'hub:invoke') {
|
|
554
|
-
onInvoke(appId,
|
|
611
|
+
onInvoke(data, (m) => postTo(appId, m));
|
|
555
612
|
} else if (data.type === 'hub:publish') {
|
|
556
613
|
onPublish(appId, data);
|
|
557
614
|
} else if (data.type === 'hub:keydown') {
|
|
@@ -570,35 +627,71 @@ function listenMessages() {
|
|
|
570
627
|
})
|
|
571
628
|
.catch(() => {});
|
|
572
629
|
} else if (data.type === 'hub:openExternal') {
|
|
573
|
-
|
|
574
|
-
// nothing, so the SDK hands external links up to the top frame instead.
|
|
575
|
-
let url;
|
|
576
|
-
try {
|
|
577
|
-
url = new URL(String(data.url));
|
|
578
|
-
} catch (_) {
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
582
|
-
window.open(url.href, '_blank', 'noopener');
|
|
630
|
+
openExternal(data);
|
|
583
631
|
} else if (data.type === 'hub:theme') {
|
|
584
|
-
|
|
585
|
-
if (data.theme !== 'light' && data.theme !== 'dark') return;
|
|
586
|
-
const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
|
|
587
|
-
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
|
|
588
|
-
if (!changed) return;
|
|
589
|
-
themeState.theme = data.theme;
|
|
590
|
-
if (hasColor) {
|
|
591
|
-
themeState.colorTheme = data.colorTheme;
|
|
592
|
-
if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
|
|
593
|
-
}
|
|
594
|
-
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
595
|
-
applyHubTheme();
|
|
596
|
-
// The sender gets the echo too, for the vars.
|
|
597
|
-
for (const id of Object.keys(iframes)) sendTheme(id);
|
|
632
|
+
onThemeMessage(data);
|
|
598
633
|
}
|
|
599
634
|
});
|
|
600
635
|
}
|
|
601
636
|
|
|
637
|
+
// Protocol §5.1. A nested page gets the welcome and theme.changed, and may send hello, invoke,
|
|
638
|
+
// keydown, openExternal and theme. hub:active, the other topics, publish, closeGuard and the
|
|
639
|
+
// terminal token belong to the app's tab, which keeps them per app id.
|
|
640
|
+
function onNestedMessage(e) {
|
|
641
|
+
const data = e.data ?? {};
|
|
642
|
+
const known = nestedApps.get(e.source);
|
|
643
|
+
const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
|
|
644
|
+
if (!appId || e.origin !== originOf(appId)) return;
|
|
645
|
+
if (data.type === 'hub:hello') {
|
|
646
|
+
const welcome = welcomeFor(data);
|
|
647
|
+
if (!welcome) return;
|
|
648
|
+
const topics = helloTopics(data);
|
|
649
|
+
nestedApps.set(e.source, { appId, topics });
|
|
650
|
+
postNested(e.source, welcome);
|
|
651
|
+
sendNestedTheme(e.source);
|
|
652
|
+
} else if (data.type === 'hub:invoke') {
|
|
653
|
+
onInvoke(data, (m) => postNested(e.source, m));
|
|
654
|
+
} else if (data.type === 'hub:keydown') {
|
|
655
|
+
handleForwardedKey(data);
|
|
656
|
+
} else if (data.type === 'hub:openExternal') {
|
|
657
|
+
openExternal(data);
|
|
658
|
+
} else if (data.type === 'hub:theme') {
|
|
659
|
+
onThemeMessage(data);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// In the installed PWA window a framed sub-app's own target=_blank opens
|
|
664
|
+
// nothing, so the SDK hands external links up to the top frame instead.
|
|
665
|
+
function openExternal(data) {
|
|
666
|
+
let url;
|
|
667
|
+
try {
|
|
668
|
+
url = new URL(String(data.url));
|
|
669
|
+
} catch (_) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
673
|
+
window.open(url.href, '_blank', 'noopener');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function onThemeMessage(data) {
|
|
677
|
+
// colorTheme is optional and sticky.
|
|
678
|
+
if (data.theme !== 'light' && data.theme !== 'dark') return;
|
|
679
|
+
const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
|
|
680
|
+
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
|
|
681
|
+
if (!changed) return;
|
|
682
|
+
themeState.theme = data.theme;
|
|
683
|
+
if (hasColor) {
|
|
684
|
+
themeState.colorTheme = data.colorTheme;
|
|
685
|
+
if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
|
|
686
|
+
}
|
|
687
|
+
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
688
|
+
applyHubTheme();
|
|
689
|
+
// The sender gets the echo too, for the vars.
|
|
690
|
+
for (const id of Object.keys(iframes)) sendTheme(id);
|
|
691
|
+
const payload = themeEvent();
|
|
692
|
+
for (const win of nestedApps.keys()) sendNestedTheme(win, payload);
|
|
693
|
+
}
|
|
694
|
+
|
|
602
695
|
function handleForwardedKey(d) {
|
|
603
696
|
const e = {
|
|
604
697
|
key: d.key,
|
|
@@ -624,7 +717,7 @@ function hubActions() {
|
|
|
624
717
|
{
|
|
625
718
|
id: 'hub.appByNumber',
|
|
626
719
|
combo: `${IS_MAC ? 'ctrl+alt' : 'alt'}+{n}`,
|
|
627
|
-
run: (i) => switchTab(
|
|
720
|
+
run: (i) => switchTab(tabIds()[i]),
|
|
628
721
|
count: 9,
|
|
629
722
|
},
|
|
630
723
|
];
|
|
@@ -636,7 +729,7 @@ function hubActions() {
|
|
|
636
729
|
// that has it by default.
|
|
637
730
|
function bindings() {
|
|
638
731
|
if (keymap) return keymap;
|
|
639
|
-
const tabs =
|
|
732
|
+
const tabs = tabIds().length;
|
|
640
733
|
const own = (b) => Object.hasOwn(userKeys, b.id);
|
|
641
734
|
const expand = (action) => {
|
|
642
735
|
const combo = comboFor(action);
|
|
@@ -678,10 +771,9 @@ function comboLabel(combo) {
|
|
|
678
771
|
}
|
|
679
772
|
|
|
680
773
|
function cycleTab(delta) {
|
|
681
|
-
const ids =
|
|
774
|
+
const ids = tabIds();
|
|
682
775
|
const idx = ids.indexOf(activeApp);
|
|
683
|
-
|
|
684
|
-
switchTab(next);
|
|
776
|
+
switchTab(idx < 0 ? ids.at(delta > 0 ? 0 : -1) : ids[(idx + delta + ids.length) % ids.length]);
|
|
685
777
|
}
|
|
686
778
|
|
|
687
779
|
function listenKeys() {
|
|
@@ -881,6 +973,26 @@ const APP_ICONS = {
|
|
|
881
973
|
['circle', { cx: 12, cy: 12, r: 3 }],
|
|
882
974
|
['path', { d: 'm16 16-1.9-1.9' }],
|
|
883
975
|
],
|
|
976
|
+
network: [
|
|
977
|
+
['rect', { x: 16, y: 16, width: 6, height: 6, rx: 1 }],
|
|
978
|
+
['rect', { x: 2, y: 16, width: 6, height: 6, rx: 1 }],
|
|
979
|
+
['rect', { x: 9, y: 2, width: 6, height: 6, rx: 1 }],
|
|
980
|
+
['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
|
|
981
|
+
['path', { d: 'M12 12V8' }],
|
|
982
|
+
],
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const ROW_ICONS = {
|
|
986
|
+
copy: [
|
|
987
|
+
['rect', { width: 14, height: 14, x: 8, y: 8, rx: 2 }],
|
|
988
|
+
['path', { d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' }],
|
|
989
|
+
],
|
|
990
|
+
restart: [
|
|
991
|
+
['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
|
|
992
|
+
['path', { d: 'M21 3v5h-5' }],
|
|
993
|
+
],
|
|
994
|
+
start: [['polygon', { points: '6 3 20 12 6 21 6 3' }]],
|
|
995
|
+
stop: [['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2 }]],
|
|
884
996
|
};
|
|
885
997
|
|
|
886
998
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
@@ -891,9 +1003,7 @@ function svgNode(tag, attrs) {
|
|
|
891
1003
|
return node;
|
|
892
1004
|
}
|
|
893
1005
|
|
|
894
|
-
function
|
|
895
|
-
const shapes = APP_ICONS[app.icon];
|
|
896
|
-
if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
|
|
1006
|
+
function lucideSvg(shapes) {
|
|
897
1007
|
const svg = svgNode('svg', {
|
|
898
1008
|
viewBox: '0 0 24 24',
|
|
899
1009
|
fill: 'none',
|
|
@@ -904,13 +1014,20 @@ function appIcon(app) {
|
|
|
904
1014
|
'aria-hidden': 'true',
|
|
905
1015
|
});
|
|
906
1016
|
svg.append(...shapes.map(([tag, attrs]) => svgNode(tag, attrs)));
|
|
1017
|
+
return svg;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function appIcon(app) {
|
|
1021
|
+
const shapes = APP_ICONS[app.icon];
|
|
1022
|
+
if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
|
|
907
1023
|
const box = el('span', 'palette-icon');
|
|
908
|
-
box.append(
|
|
1024
|
+
box.append(lucideSvg(shapes));
|
|
909
1025
|
return box;
|
|
910
1026
|
}
|
|
911
1027
|
|
|
912
1028
|
function appRows(q) {
|
|
913
|
-
const
|
|
1029
|
+
const tabs = tabIds();
|
|
1030
|
+
const list = Object.entries(apps).map(([id, a]) => ({ kind: 'app', id, app: a, index: tabs.indexOf(id) }));
|
|
914
1031
|
if (!q) return list;
|
|
915
1032
|
const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
|
|
916
1033
|
return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
|
|
@@ -918,7 +1035,8 @@ function appRows(q) {
|
|
|
918
1035
|
|
|
919
1036
|
// A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
|
|
920
1037
|
function rowButton(className, glyph, key, title, label) {
|
|
921
|
-
const btn = el('button', className
|
|
1038
|
+
const btn = el('button', `palette-btn ${className}`);
|
|
1039
|
+
btn.append(ROW_ICONS[glyph] ? lucideSvg(ROW_ICONS[glyph]) : glyph);
|
|
922
1040
|
btn.type = 'button';
|
|
923
1041
|
btn.title = title;
|
|
924
1042
|
btn.setAttribute('aria-label', label);
|
|
@@ -974,23 +1092,41 @@ async function copyToHint(text) {
|
|
|
974
1092
|
}
|
|
975
1093
|
}
|
|
976
1094
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1095
|
+
function toggleFromPalette(id) {
|
|
1096
|
+
return appOpFromPalette(id, palette.appStats?.[id]?.stopped ? 'start' : 'stop');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const APP_OPS = {
|
|
1100
|
+
restart: { key: 'R', verb: 'Restarting' },
|
|
1101
|
+
start: { key: 'S', verb: 'Starting' },
|
|
1102
|
+
stop: { key: 'S', verb: 'Stopping' },
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// The terminal provider answers 409 with a terminal count; the same key again on the same app sends force.
|
|
1106
|
+
async function appOpFromPalette(id, op) {
|
|
1107
|
+
const confirm = `${op}\n${id}`;
|
|
1108
|
+
const force = palette.confirmOp === confirm;
|
|
1109
|
+
palette.confirmOp = null;
|
|
1110
|
+
const name = apps[id]?.name ?? id;
|
|
1111
|
+
setPaletteHint(`${APP_OPS[op].verb} ${name}…`);
|
|
1112
|
+
const failed = await withPaletteBusy(() => requestAppOp(id, op, force));
|
|
983
1113
|
if (failed?.terminals) {
|
|
984
|
-
palette.
|
|
985
|
-
setPaletteHint(`${failed.message} Press Ctrl
|
|
1114
|
+
palette.confirmOp = confirm;
|
|
1115
|
+
setPaletteHint(`${failed.message} Press Ctrl+${APP_OPS[op].key} again to ${op}.`);
|
|
986
1116
|
return;
|
|
987
1117
|
}
|
|
988
1118
|
if (failed) {
|
|
989
1119
|
setPaletteHint(failed.message);
|
|
990
1120
|
return;
|
|
991
1121
|
}
|
|
1122
|
+
if (iframes[id]) replaceIframe(id);
|
|
1123
|
+
if (op === 'stop') {
|
|
1124
|
+
if (palette.appStats) palette.appStats[id] = { ...palette.appStats[id], stopped: true, rss: 0 };
|
|
1125
|
+
setPaletteHint(`Stopped ${name}`);
|
|
1126
|
+
renderPalette();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
992
1129
|
closePalette();
|
|
993
|
-
replaceIframe(id);
|
|
994
1130
|
switchTab(id);
|
|
995
1131
|
}
|
|
996
1132
|
|
|
@@ -1060,10 +1196,10 @@ const PALETTE_MODES = {
|
|
|
1060
1196
|
placeholder: 'Search apps...',
|
|
1061
1197
|
label: 'Switch app',
|
|
1062
1198
|
get hint() {
|
|
1063
|
-
const keys = 'Enter switch · Ctrl+C copy
|
|
1199
|
+
const keys = 'Enter switch · Ctrl+C copy URL · Ctrl+R restart · Ctrl+S start/stop';
|
|
1064
1200
|
if (!hubInfo.version) return keys;
|
|
1065
1201
|
const plugin = hubInfo.plugin ? [pluginNode(hubInfo.plugin), ' · '] : [];
|
|
1066
|
-
return [`
|
|
1202
|
+
return [`Hub ${hubInfo.version} · `, ...plugin, keys];
|
|
1067
1203
|
},
|
|
1068
1204
|
empty: 'No matching app',
|
|
1069
1205
|
rows: (_typed, q) => appRows(q),
|
|
@@ -1077,23 +1213,36 @@ const PALETTE_MODES = {
|
|
|
1077
1213
|
const s = palette.appStats?.[row.id];
|
|
1078
1214
|
if (s?.version) li.append(el('span', 'ver', s.version));
|
|
1079
1215
|
if (s?.plugin) li.append(pluginNode(s.plugin));
|
|
1216
|
+
if (s?.linked) {
|
|
1217
|
+
const badge = el('span', 'linked', 'linked');
|
|
1218
|
+
badge.title = 'Runs from a local folder (ext install). Restart (Ctrl+R) loads its code changes.';
|
|
1219
|
+
li.append(badge);
|
|
1220
|
+
}
|
|
1080
1221
|
if (s?.slowMs) {
|
|
1081
1222
|
const badge = el('span', 'slow', 'slow port');
|
|
1082
1223
|
badge.title = `Its port delays requests by up to ${(s.slowMs / 1000).toFixed(1)} s. Restart (Ctrl+R) moves it to a new port.`;
|
|
1083
1224
|
li.append(badge);
|
|
1084
1225
|
}
|
|
1085
|
-
const
|
|
1226
|
+
const usage = s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '';
|
|
1227
|
+
const stats = el('span', 'stats', s?.stopped ? 'stopped' : usage);
|
|
1086
1228
|
stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
|
|
1087
1229
|
li.append(stats);
|
|
1088
1230
|
const combo = appNumberCombo(row.index);
|
|
1089
|
-
const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : '';
|
|
1231
|
+
const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : row.app.hidden ? 'hidden' : '';
|
|
1090
1232
|
li.append(el('span', 'age', key));
|
|
1091
1233
|
li.append(
|
|
1092
|
-
rowButton('palette-copy', '
|
|
1093
|
-
rowButton('palette-restart', '
|
|
1234
|
+
rowButton('palette-copy', 'copy', 'c', 'Copy the URL (Ctrl+C)', `Copy the URL of ${row.app.name}`),
|
|
1235
|
+
rowButton('palette-restart', 'restart', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
|
|
1236
|
+
s?.stopped
|
|
1237
|
+
? rowButton('palette-toggle', 'start', 's', 'Start the app (Ctrl+S)', `Start ${row.app.name}`)
|
|
1238
|
+
: rowButton('palette-toggle', 'stop', 's', 'Stop the app (Ctrl+S)', `Stop ${row.app.name}`),
|
|
1094
1239
|
);
|
|
1095
1240
|
},
|
|
1096
|
-
rowKeys: {
|
|
1241
|
+
rowKeys: {
|
|
1242
|
+
c: (row) => copyToHint(apps[row.id].url),
|
|
1243
|
+
r: (row) => appOpFromPalette(row.id, 'restart'),
|
|
1244
|
+
s: (row) => toggleFromPalette(row.id),
|
|
1245
|
+
},
|
|
1097
1246
|
commit(row) {
|
|
1098
1247
|
closePalette();
|
|
1099
1248
|
switchTab(row.id);
|
|
@@ -1151,7 +1300,7 @@ function openPalette(mode) {
|
|
|
1151
1300
|
palette.mode = mode;
|
|
1152
1301
|
palette.query = '';
|
|
1153
1302
|
palette.sel = null;
|
|
1154
|
-
palette.
|
|
1303
|
+
palette.confirmOp = null;
|
|
1155
1304
|
palette.message = null;
|
|
1156
1305
|
input.value = '';
|
|
1157
1306
|
input.placeholder = spec.placeholder;
|
|
@@ -1294,12 +1443,18 @@ function bindPalette() {
|
|
|
1294
1443
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
|
1295
1444
|
e.preventDefault();
|
|
1296
1445
|
movePaletteSel(-1);
|
|
1297
|
-
} else if (
|
|
1446
|
+
} else if (
|
|
1447
|
+
e.ctrlKey &&
|
|
1448
|
+
!e.altKey &&
|
|
1449
|
+
!e.shiftKey &&
|
|
1450
|
+
!e.metaKey &&
|
|
1451
|
+
['c', 'd', 'r', 's'].includes(e.key.toLowerCase())
|
|
1452
|
+
) {
|
|
1298
1453
|
const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
|
|
1299
1454
|
// Ctrl+C with text selected in the input keeps the browser's copy.
|
|
1300
1455
|
if (e.key.toLowerCase() === 'c' && (!run || input.selectionStart !== input.selectionEnd)) return;
|
|
1301
1456
|
// Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
|
|
1302
|
-
// Ctrl+R would otherwise reload the hub page.
|
|
1457
|
+
// Ctrl+R would otherwise reload the hub page, and Ctrl+S save it.
|
|
1303
1458
|
e.preventDefault();
|
|
1304
1459
|
const row = palette.rows[palette.sel];
|
|
1305
1460
|
if (run && row && row.kind !== 'literal') run(row);
|
package/public/index.html
CHANGED
|
@@ -290,6 +290,15 @@
|
|
|
290
290
|
font-size: 11px;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.palette-row .linked {
|
|
294
|
+
margin-left: 10px;
|
|
295
|
+
padding: 0 6px;
|
|
296
|
+
border-radius: 4px;
|
|
297
|
+
color: var(--text-dim);
|
|
298
|
+
border: 1px solid var(--text-dim);
|
|
299
|
+
font-size: 11px;
|
|
300
|
+
}
|
|
301
|
+
|
|
293
302
|
.palette-row .ver,
|
|
294
303
|
.palette-hint .ver {
|
|
295
304
|
color: var(--text-dim);
|
|
@@ -334,9 +343,10 @@
|
|
|
334
343
|
|
|
335
344
|
.palette-row.selected .palette-icon { color: var(--accent); }
|
|
336
345
|
|
|
337
|
-
.palette-row .palette-
|
|
338
|
-
|
|
339
|
-
|
|
346
|
+
.palette-row .palette-btn {
|
|
347
|
+
display: inline-flex;
|
|
348
|
+
align-items: center;
|
|
349
|
+
align-self: center;
|
|
340
350
|
margin-left: auto;
|
|
341
351
|
padding: 0 6px;
|
|
342
352
|
border: none;
|
|
@@ -349,20 +359,17 @@
|
|
|
349
359
|
opacity: 0;
|
|
350
360
|
}
|
|
351
361
|
|
|
352
|
-
.palette-row:hover .palette-
|
|
353
|
-
.palette-row.selected .palette-
|
|
354
|
-
|
|
355
|
-
.palette-row
|
|
356
|
-
.palette-row:hover .palette-restart,
|
|
357
|
-
.palette-row.selected .palette-restart { opacity: 1; }
|
|
362
|
+
.palette-row:hover .palette-btn,
|
|
363
|
+
.palette-row.selected .palette-btn { opacity: 1; }
|
|
364
|
+
|
|
365
|
+
.palette-row .palette-btn:hover { color: var(--accent); }
|
|
358
366
|
|
|
359
367
|
.palette-row .palette-remove:hover { color: #e5484d; }
|
|
360
368
|
|
|
361
|
-
.palette-row .age + .palette-
|
|
362
|
-
.palette-row .palette-
|
|
369
|
+
.palette-row .age + .palette-btn,
|
|
370
|
+
.palette-row .palette-btn + .palette-btn { margin-left: 0; }
|
|
363
371
|
|
|
364
|
-
.palette-row .palette-
|
|
365
|
-
.palette-row .palette-restart:hover { color: var(--accent); }
|
|
372
|
+
.palette-row .palette-btn svg { width: 14px; height: 14px; }
|
|
366
373
|
|
|
367
374
|
.palette-hint {
|
|
368
375
|
padding: 8px 14px;
|