@shendeguize/remote-dsh-center 0.4.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
@@ -0,0 +1,577 @@
1
+ /**
2
+ * 顶部标签栏 + 右键/长按菜单(10 §3.1 / UI-10、UI-11)。
3
+ *
4
+ * 可见性与菜单裁剪都是纯函数(visibleTabs / menuItems),单测直接覆盖。
5
+ */
6
+
7
+ import {
8
+ ACTION_LABEL, clear, copyText, el, phaseMeta,
9
+ } from '../utils.js';
10
+ import {
11
+ isHostActionAllowed, isHostEnabled, primaryHosts,
12
+ } from '../host-rules.js';
13
+ import { hostPhaseHint, hostStatusText } from '../host-presentation.js';
14
+ import { hostPanelId, hostTabId } from './iframe-pane.js';
15
+
16
+ const LONG_PRESS_MS = 550;
17
+
18
+ /** ready fallback 独占的稳定 panel ID,避免与 iframe/启动占位的 ID 同时存在。 */
19
+ export function hostFallbackPanelId(name) {
20
+ return `host-fallback-panel-${encodeURIComponent(String(name))}`;
21
+ }
22
+
23
+ /**
24
+ * 日常主机常驻区:只按「已启用 + 可用态」判断,不再要求先从管理台打开。
25
+ * @param {Iterable<object>} hosts
26
+ */
27
+ export function visibleTabs(hosts) {
28
+ return primaryHosts(hosts);
29
+ }
30
+
31
+ /** 主标签之外的主机集中到 +N 菜单,保证不可用/禁用主机仍然找得到。 */
32
+ export function overflowHosts(hosts) {
33
+ const all = [...hosts];
34
+ const primary = new Set(visibleTabs(all).map((host) => host.name));
35
+ return all.filter((host) => !primary.has(host.name)).sort((a, b) => a.name.localeCompare(b.name));
36
+ }
37
+
38
+ /** 贴边时留的这点余量,纯为让人看出「菜单到边了」而不是被裁掉半截。 */
39
+ const MENU_EDGE_GAP = 4;
40
+
41
+ /**
42
+ * 把菜单坐标夹进视口(issue #67)。菜单是 `position: fixed`,越出去的那一截没有
43
+ * 滚动可言——鼠标压根落不上去。所以右边不够就朝左翻、下边不够就朝上翻;翻过去还是
44
+ * 装不下(视口比菜单还小)就贴边。
45
+ *
46
+ * 纯函数:真实尺寸由调用方量好传进来,单测直接覆盖各个方位。
47
+ * @param {{x:number, y:number, menuW:number, menuH:number, viewW:number, viewH:number}} at
48
+ * @returns {{left:number, top:number}}
49
+ */
50
+ export function clampMenuPosition({ x, y, menuW, menuH, viewW, viewH }) {
51
+ const fit = (pos, size, view) => {
52
+ if (pos + size <= view) return Math.max(MENU_EDGE_GAP, pos);
53
+ // 朝反方向翻:菜单的那一边贴着光标,这是各家原生菜单的做法
54
+ const flipped = pos - size;
55
+ if (flipped >= 0) return flipped;
56
+ return Math.max(MENU_EDGE_GAP, view - size - MENU_EDGE_GAP); // 视口比菜单还小 → 贴边
57
+ };
58
+ return { left: fit(x, menuW, viewW), top: fit(y, menuH, viewH) };
59
+ }
60
+
61
+ /**
62
+ * 方向键在标签环里落到哪一格(issue #110)。`role="tablist"` 对辅助技术就是一句
63
+ * 承诺:左右移动、Home/End 跳首尾、到头环绕。纯函数,边界(空标签栏、焦点不在环上)
64
+ * 一并在这里收口。
65
+ * @param {string} key 按下的键
66
+ * @param {number} current 当前焦点所在下标,不在环上传 -1
67
+ * @param {number} count 标签总数
68
+ * @returns {number|null} 目标下标;null = 这个键与标签环无关
69
+ */
70
+ export function nextTabIndex(key, current, count) {
71
+ if (count <= 0) return null;
72
+ const from = current >= 0 ? current : 0;
73
+ switch (key) {
74
+ case 'ArrowRight': return (from + 1) % count;
75
+ case 'ArrowLeft': return (from - 1 + count) % count;
76
+ case 'Home': return 0;
77
+ case 'End': return count - 1;
78
+ default: return null;
79
+ }
80
+ }
81
+
82
+ /** 菜单项按 phase / 归属裁剪(无效项禁用而非隐藏,位置稳定)。 */
83
+ export function menuItems(host) {
84
+ return [
85
+ { action: 'restart', label: ACTION_LABEL.restart, enabled: isHostActionAllowed(host, 'restart'), needsWrite: true },
86
+ { action: 'stop', label: ACTION_LABEL.stop, enabled: isHostActionAllowed(host, 'stop'), needsWrite: true },
87
+ { action: 'reconnect', label: ACTION_LABEL.reconnect, enabled: isHostActionAllowed(host, 'reconnect'), needsWrite: true },
88
+ { action: 'copy-address', label: '复制本机映射地址', enabled: Boolean(host?.mappedUrl), needsWrite: false },
89
+ { action: 'view-manage', label: '在管理台查看', enabled: Boolean(host), needsWrite: false },
90
+ { action: 'open-new-window', label: '在新窗口打开', enabled: Boolean(host?.mappedUrl), needsWrite: false },
91
+ ];
92
+ }
93
+
94
+ export function createTabbar({
95
+ store, actions, panes = null, trailing = null,
96
+ }) {
97
+ const hostTabs = el('div.host-tabs', { role: 'tablist' });
98
+ const overflowBtn = el('button.icon-button.tab-overflow', {
99
+ type: 'button',
100
+ text: '+0 ▾',
101
+ hidden: true,
102
+ 'aria-label': '查看不可用或已禁用的主机',
103
+ 'aria-haspopup': 'menu',
104
+ 'aria-expanded': 'false',
105
+ on: {
106
+ click: () => toggleOverflowMenu(),
107
+ keydown: (e) => {
108
+ if (e.key !== 'ArrowDown') return;
109
+ e.preventDefault();
110
+ openOverflowMenu();
111
+ },
112
+ },
113
+ });
114
+ const manageBtn = el('button.tab.tab-manage', {
115
+ type: 'button', text: '⌂ 管理', on: { click: () => actions.navigate('#/manage') },
116
+ });
117
+ const right = el('div.tabbar-actions', {}, [overflowBtn, manageBtn, trailing]);
118
+ const root = el('nav.tabbar', { 'aria-label': '工作区' }, [hostTabs, right]);
119
+
120
+ const menu = el('menu.context-menu', { hidden: true, role: 'menu', 'aria-label': '主机操作' });
121
+ const overflowMenu = el('menu.context-menu.overflow-menu', {
122
+ hidden: true, role: 'menu', 'aria-label': '其他主机',
123
+ });
124
+ let menuHost = null;
125
+ let pressCycle = null;
126
+
127
+ // ── 菜单 ─────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * 收菜单。`restoreFocus` 时把焦点还给它是从哪个标签开出来的——菜单里的按钮
131
+ * 一旦随菜单隐藏,焦点就掉回 body,选完一项的人于是被丢到文档顶端。
132
+ */
133
+ function closeMenu({ restoreFocus = false } = {}) {
134
+ const from = menuHost;
135
+ menu.hidden = true;
136
+ menuHost = null;
137
+ if (restoreFocus && from) root.querySelector(`[data-host="${CSS.escape(from)}"]`)?.focus();
138
+ }
139
+
140
+ function closeOverflowMenu({ restoreFocus = false } = {}) {
141
+ overflowMenu.hidden = true;
142
+ overflowBtn.setAttribute('aria-expanded', 'false');
143
+ if (restoreFocus && !overflowBtn.hidden) overflowBtn.focus();
144
+ }
145
+
146
+ function openMappedUrl(name) {
147
+ const url = store.getHost(name)?.mappedUrl;
148
+ if (!url) return;
149
+ let popup = null;
150
+ try {
151
+ popup = typeof window.open === 'function' ? window.open(url, '_blank') : null;
152
+ if (popup) popup.opener = null;
153
+ } catch {
154
+ popup = null;
155
+ }
156
+ if (!popup) {
157
+ store.addToast({ level: 'warn', summary: '新窗口被浏览器拦截,请允许弹出窗口后重试' });
158
+ }
159
+ }
160
+
161
+ function openMenu(name, x, y) {
162
+ const host = store.getHost(name);
163
+ if (!host) return;
164
+ closeOverflowMenu();
165
+ menuHost = name;
166
+ clear(menu);
167
+ for (const item of menuItems(host)) {
168
+ menu.append(el('li', { role: 'none' }, [
169
+ el('button', {
170
+ type: 'button',
171
+ role: 'menuitem',
172
+ text: item.label,
173
+ disabled: !item.enabled || (item.needsWrite && !store.canWrite()),
174
+ on: {
175
+ click: async () => {
176
+ closeMenu({ restoreFocus: true });
177
+ if (item.action === 'copy-address') {
178
+ const url = store.getHost(name)?.mappedUrl;
179
+ if (!url) return;
180
+ const ok = await copyText(url);
181
+ store.addToast({ level: ok ? 'success' : 'warn', summary: ok ? `已复制 ${url}` : '复制失败,请手动选择地址' });
182
+ return;
183
+ }
184
+ if (item.action === 'view-manage') {
185
+ actions.viewHostInManage(name);
186
+ return;
187
+ }
188
+ if (item.action === 'open-new-window') {
189
+ openMappedUrl(name);
190
+ return;
191
+ }
192
+ actions.hostAction(item.action, name);
193
+ },
194
+ },
195
+ }),
196
+ ]));
197
+ }
198
+ // 先摆到目标点再量:菜单宽高取决于这次的项数与文案,隐藏状态下量不出来。
199
+ // 两次赋值在同一帧内,看不出跳动。
200
+ menu.hidden = false;
201
+ menu.style.left = `${x}px`;
202
+ menu.style.top = `${y}px`;
203
+ const at = clampToViewport(x, y);
204
+ menu.style.left = `${at.left}px`;
205
+ menu.style.top = `${at.top}px`;
206
+ menu.querySelector('button:not(:disabled)')?.focus();
207
+ }
208
+
209
+ function fillOverflowMenu(hosts) {
210
+ clear(overflowMenu);
211
+ for (const host of hosts) {
212
+ const hint = hostPhaseHint(host);
213
+ const status = hostStatusText(host, { disabled: !isHostEnabled(host) });
214
+ overflowMenu.append(el('li', { role: 'none' }, [
215
+ el('span', { text: `${host.name} — ${status}${hint ? ` · ${hint}` : ''}` }),
216
+ el('button', {
217
+ type: 'button',
218
+ role: 'menuitem',
219
+ text: `探测 ${host.name}`,
220
+ dataset: { host: host.name, action: 'probe' },
221
+ disabled: !store.canWrite() || store.isPending('probe', host.name),
222
+ on: {
223
+ click: () => {
224
+ closeOverflowMenu({ restoreFocus: true });
225
+ actions.hostAction('probe', host.name);
226
+ },
227
+ },
228
+ }),
229
+ el('button', {
230
+ type: 'button',
231
+ role: 'menuitem',
232
+ text: `在管理台查看 ${host.name}`,
233
+ dataset: { host: host.name, action: 'view-manage' },
234
+ on: {
235
+ click: () => {
236
+ closeOverflowMenu({ restoreFocus: true });
237
+ actions.viewHostInManage(host.name);
238
+ },
239
+ },
240
+ }),
241
+ ]));
242
+ }
243
+ }
244
+
245
+ function positionOverflowMenu() {
246
+ const at = anchorOf(overflowBtn);
247
+ overflowMenu.style.left = `${at.x}px`;
248
+ overflowMenu.style.top = `${at.y}px`;
249
+ const clamped = clampToViewport(at.x, at.y, overflowMenu);
250
+ overflowMenu.style.left = `${clamped.left}px`;
251
+ overflowMenu.style.top = `${clamped.top}px`;
252
+ }
253
+
254
+ function overflowFocusKey(node) {
255
+ if (!node || !overflowMenu.contains(node)) return null;
256
+ const { host, action } = node.dataset ?? {};
257
+ return host && action ? { host, action } : null;
258
+ }
259
+
260
+ function restoreOverflowFocus(key) {
261
+ if (!key) return;
262
+ const buttons = [...overflowMenu.querySelectorAll('button')];
263
+ const exact = buttons.find((node) => (
264
+ node.dataset.host === key.host && node.dataset.action === key.action
265
+ ));
266
+ if (exact && !exact.disabled) {
267
+ exact.focus();
268
+ return;
269
+ }
270
+ const sameHost = buttons.find((node) => node.dataset.host === key.host && !node.disabled);
271
+ if (sameHost) {
272
+ sameHost.focus();
273
+ return;
274
+ }
275
+ if (!overflowBtn.hidden) overflowBtn.focus();
276
+ }
277
+
278
+ function openOverflowMenu() {
279
+ const hosts = overflowHosts(store.listHosts());
280
+ if (hosts.length === 0) return;
281
+ closeMenu();
282
+ fillOverflowMenu(hosts);
283
+ overflowMenu.hidden = false;
284
+ overflowBtn.setAttribute('aria-expanded', 'true');
285
+ positionOverflowMenu();
286
+ overflowMenu.querySelector('button:not(:disabled)')?.focus();
287
+ }
288
+
289
+ function toggleOverflowMenu() {
290
+ if (overflowMenu.hidden) openOverflowMenu();
291
+ else closeOverflowMenu({ restoreFocus: true });
292
+ }
293
+
294
+ /** 量出菜单实际尺寸后夹进视口;垫片环境没有布局(尺寸全 0),原样返回。 */
295
+ function clampToViewport(x, y, target = menu) {
296
+ const rect = target.getBoundingClientRect?.();
297
+ const viewW = window.innerWidth ?? 0;
298
+ const viewH = window.innerHeight ?? 0;
299
+ if (!rect?.width || !viewW || !viewH) return { left: x, top: y };
300
+ return clampMenuPosition({
301
+ x, y, menuW: rect.width, menuH: rect.height, viewW, viewH,
302
+ });
303
+ }
304
+
305
+ /** 键盘开菜单时把它挂在标签下沿;垫片环境没有布局,退回原点即可。 */
306
+ function anchorOf(node) {
307
+ const rect = node.getBoundingClientRect?.();
308
+ return rect ? { x: rect.left, y: rect.bottom } : { x: 0, y: 0 };
309
+ }
310
+
311
+ const onDocPointerDown = (e) => {
312
+ if (!menu.hidden && !menu.contains(e.target)) closeMenu();
313
+ if (!overflowMenu.hidden && !overflowMenu.contains(e.target) && e.target !== overflowBtn) closeOverflowMenu();
314
+ };
315
+
316
+ // 菜单内上下键移动焦点(UI-27:菜单必须能纯键盘操作)
317
+ function attachMenuKeyboard(target) {
318
+ target.addEventListener('keydown', (e) => {
319
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
320
+ e.preventDefault();
321
+ // 必须摊成数组:真 DOM 给的是 NodeList,没有 indexOf——照着 NodeList 用
322
+ // 会当场抛 TypeError,整个方向键在真浏览器里一个都不动(垫片给的是数组,
323
+ // 所以单测一路绿;冒烟那条判据又因为开菜单时焦点已在首项而判不出来)。
324
+ const items = [...target.querySelectorAll('button:not(:disabled)')];
325
+ if (items.length === 0) return;
326
+ const at = items.indexOf(document.activeElement);
327
+ const next = {
328
+ ArrowDown: at + 1 >= items.length ? 0 : at + 1,
329
+ ArrowUp: at <= 0 ? items.length - 1 : at - 1,
330
+ Home: 0,
331
+ End: items.length - 1,
332
+ }[e.key];
333
+ items[next].focus();
334
+ });
335
+ }
336
+ attachMenuKeyboard(menu);
337
+ attachMenuKeyboard(overflowMenu);
338
+ /**
339
+ * 标签环里的方向键(issue #110)。走**手动激活**:方向键只移焦点,Enter/Space 才切
340
+ * 页——切到没打开过的主机会新建 iframe 去拉远端页面,一路划过去不该顺手拉起十个。
341
+ * ArrowDown 已被「开操作菜单」占着,这里不碰。
342
+ */
343
+ hostTabs.addEventListener('keydown', (e) => {
344
+ const tabs = [...hostTabs.querySelectorAll('.tab')];
345
+ const next = nextTabIndex(e.key, tabs.indexOf(document.activeElement), tabs.length);
346
+ if (next === null) return;
347
+ e.preventDefault();
348
+ focusTab(tabs[next]);
349
+ });
350
+
351
+ const onKeyDown = (e) => {
352
+ if (e.key === 'Escape' && !menu.hidden) closeMenu({ restoreFocus: true });
353
+ else if (e.key === 'Escape' && !overflowMenu.hidden) closeOverflowMenu({ restoreFocus: true });
354
+ };
355
+ const closeMenus = () => {
356
+ closeMenu();
357
+ closeOverflowMenu();
358
+ };
359
+ document.addEventListener('pointerdown', onDocPointerDown);
360
+ document.addEventListener('keydown', onKeyDown);
361
+ window.addEventListener('resize', closeMenus);
362
+ window.addEventListener('scroll', closeMenus, true);
363
+
364
+ // ── 标签渲染 ─────────────────────────────────────────────────────────
365
+
366
+ /**
367
+ * 焦点跟着 tabindex 一起走(roving tabindex):tablist 按 ARIA 只该占**一个** Tab
368
+ * 落点,否则 24 台就得按 24 次 Tab 才走得过标签栏。落点跟着焦点挪,人 Tab 出去再
369
+ * 回来还在原地。
370
+ */
371
+ function focusTab(node) {
372
+ if (!node) return;
373
+ for (const tab of hostTabs.querySelectorAll('.tab')) tab.setAttribute('tabindex', tab === node ? '0' : '-1');
374
+ node.focus();
375
+ }
376
+
377
+ function samePointerCycle(event, cycle) {
378
+ return event.pointerId == null || cycle.pointerId == null || event.pointerId === cycle.pointerId;
379
+ }
380
+
381
+ function clearPressCycle(cycle = pressCycle) {
382
+ if (!cycle || pressCycle !== cycle) return;
383
+ if (cycle.timer) clearTimeout(cycle.timer);
384
+ if (cycle.expiry) clearTimeout(cycle.expiry);
385
+ pressCycle = null;
386
+ }
387
+
388
+ function beginPress(host, event) {
389
+ clearPressCycle();
390
+ const cycle = {
391
+ host: host.name,
392
+ pointerId: event.pointerId,
393
+ openedMenu: false,
394
+ timer: null,
395
+ expiry: null,
396
+ };
397
+ cycle.timer = setTimeout(() => {
398
+ if (pressCycle !== cycle) return;
399
+ cycle.timer = null;
400
+ cycle.openedMenu = true;
401
+ openMenu(host.name, event.clientX, event.clientY);
402
+ }, LONG_PRESS_MS);
403
+ pressCycle = cycle;
404
+ }
405
+
406
+ function finishPress(event) {
407
+ const cycle = pressCycle;
408
+ if (!cycle || !samePointerCycle(event, cycle)) return;
409
+ if (cycle.timer) clearTimeout(cycle.timer);
410
+ cycle.timer = null;
411
+ if (!cycle.openedMenu) {
412
+ clearPressCycle(cycle);
413
+ return;
414
+ }
415
+ // touch 生成的 click 紧跟 pointerup;只把这个周期留到该 click,下一轮任务自动作废。
416
+ cycle.expiry = setTimeout(() => clearPressCycle(cycle), 0);
417
+ }
418
+
419
+ function cancelPress(event) {
420
+ const cycle = pressCycle;
421
+ if (cycle && samePointerCycle(event, cycle)) clearPressCycle(cycle);
422
+ }
423
+
424
+ function consumeLongPressClick(host, event) {
425
+ const cycle = pressCycle;
426
+ if (!cycle || !cycle.openedMenu || cycle.host !== host || !samePointerCycle(event, cycle)) return false;
427
+ clearPressCycle(cycle);
428
+ return true;
429
+ }
430
+
431
+ function tabNode(host) {
432
+ const meta = phaseMeta(host.phase);
433
+ const selected = store.state.route.host === host.name;
434
+ const usesFallback = selected && host.phase === 'ready' && !store.isPending('start', host.name);
435
+
436
+ const node = el('button.tab', {
437
+ id: hostTabId(host.name),
438
+ type: 'button',
439
+ role: 'tab',
440
+ dataset: { host: host.name },
441
+ title: `${host.name} — ${meta.label}${host.local === true ? ' — 本机' : ''}`,
442
+ 'aria-selected': String(selected),
443
+ 'aria-controls': usesFallback ? hostFallbackPanelId(host.name) : hostPanelId(host.name),
444
+ 'aria-haspopup': 'menu',
445
+ class: selected ? 'is-active' : '',
446
+ on: {
447
+ click: (event) => {
448
+ if (consumeLongPressClick(host.name, event)) {
449
+ event.preventDefault();
450
+ event.stopPropagation();
451
+ return;
452
+ }
453
+ const transferFocus = event.detail === 0 && (host.phase === 'ready' || host.phase === 'starting');
454
+ actions.openHost(host.name);
455
+ if (transferFocus) panes?.focusStatusWhenAvailable?.(host.name);
456
+ },
457
+ contextmenu: (e) => {
458
+ e.preventDefault();
459
+ openMenu(host.name, e.clientX, e.clientY);
460
+ },
461
+ // 鼠标右键/长按之外的第三条路:纯键盘也要开得出菜单
462
+ keydown: (e) => {
463
+ const wantsMenu = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') || e.key === 'ArrowDown';
464
+ if (!wantsMenu) return;
465
+ e.preventDefault();
466
+ const at = anchorOf(node);
467
+ openMenu(host.name, at.x, at.y);
468
+ },
469
+ pointerdown: (e) => {
470
+ if (e.pointerType === 'mouse') return; // 鼠标走 contextmenu
471
+ beginPress(host, e);
472
+ },
473
+ pointerup: finishPress,
474
+ pointercancel: cancelPress,
475
+ pointerleave: cancelPress,
476
+ },
477
+ }, [
478
+ el('span.status-dot', { dataset: { dot: meta.dot }, class: `dot-${meta.tone}` }),
479
+ el('span.tab-label', { text: host.name }),
480
+ host.local === true ? el('span.tag.tag-lock', { text: '本机' }) : null,
481
+ ]);
482
+ return node;
483
+ }
484
+
485
+ // 上次滚到哪个路由目标;用来只在切换时滚,不在每次重渲染时滚
486
+ let lastScrolledTo;
487
+
488
+ function render() {
489
+ const route = store.state.route;
490
+ const hosts = store.listHosts();
491
+ const tabs = visibleTabs(hosts);
492
+ const overflow = overflowHosts(hosts);
493
+
494
+ // 标签整片重建,旧节点带着焦点被移除,浏览器只能把焦点交回 body——方向键走到
495
+ // 一半来一帧 SSE 就丢位置。认「同一个标签」靠 data-host(issue #110)。
496
+ const held = document.activeElement;
497
+ const heldHost = held && hostTabs.contains?.(held) ? held.dataset?.host ?? null : null;
498
+ const heldOverflow = overflowFocusKey(held);
499
+ const heldOverflowButton = held === overflowBtn;
500
+ const overflowWasOpen = !overflowMenu.hidden;
501
+
502
+ clear(hostTabs);
503
+ for (const host of tabs) hostTabs.append(tabNode(host));
504
+
505
+ // roving tabindex:Tab 进标签栏落在当前选中的那个上,没选中就落第一个
506
+ const nodes = [...hostTabs.querySelectorAll('.tab')];
507
+ const stop = nodes.find((n) => n.dataset.host === route.host) ?? nodes[0];
508
+ for (const n of nodes) n.setAttribute('tabindex', n === stop ? '0' : '-1');
509
+ if (heldHost) focusTab(nodes.find((n) => n.dataset.host === heldHost) ?? stop);
510
+
511
+ overflowBtn.hidden = overflow.length === 0;
512
+ overflowBtn.textContent = `+${overflow.length} ▾`;
513
+ overflowBtn.setAttribute('aria-label', `${overflow.length} 台不可用或已禁用的主机`);
514
+ if (overflowWasOpen && overflow.length > 0) {
515
+ fillOverflowMenu(overflow);
516
+ positionOverflowMenu();
517
+ restoreOverflowFocus(heldOverflow);
518
+ } else if (overflow.length === 0) {
519
+ closeOverflowMenu();
520
+ if (heldOverflow || heldOverflowButton) {
521
+ const promoted = nodes.find((node) => node.dataset.host === heldOverflow?.host) ?? stop;
522
+ if (promoted) focusTab(promoted);
523
+ else manageBtn.focus();
524
+ }
525
+ }
526
+
527
+ const managing = route.kind === 'manage';
528
+ manageBtn.classList.toggle('is-active', managing);
529
+ manageBtn.setAttribute('aria-pressed', String(managing));
530
+ if (managing) manageBtn.setAttribute('aria-current', 'page');
531
+ else manageBtn.removeAttribute('aria-current');
532
+
533
+ // 当前停留的主机从状态里消失(例如被摘出配置)→ 回起始页,避免停在空白页。
534
+ // hostsLoaded 是必要门禁:首屏就带 host 路由(书签 / 刷新 / dshc open <host>)时
535
+ // 主机集合还没到,那时的「查不到」是尚未同步,不是消失——改写地址会让深链永远
536
+ // 落在管理台(issue #15)。等它到过一次再判,「不存在」的兜底文案由 app.js 给。
537
+ if (route.kind === 'host' && store.state.hostsLoaded
538
+ && !tabs.some((h) => h.name === route.host) && !store.getHost(route.host)) {
539
+ actions.navigate('#/');
540
+ }
541
+ if (!menu.hidden && !tabs.some((h) => h.name === menuHost)) closeMenu();
542
+
543
+ // 主机标签区能横向滚(.host-tabs overflow-x: auto),但从不自己跟到当前位置:主机多、
544
+ // 名字长时,切到靠后那台之后激活标签停在可视区外,看起来像一个都没选中(issue #25)。
545
+ // 只在激活项变化时滚——每次重渲染都滚会把用户自己拖的位置一直拽回去。
546
+ const activeTarget = route.kind === 'host' ? route.host : (managing ? '@manage' : null);
547
+ if (activeTarget !== lastScrolledTo) {
548
+ lastScrolledTo = activeTarget;
549
+ const node = activeTarget === '@manage' ? manageBtn : hostTabs.querySelector('.tab.is-active');
550
+ node?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
551
+ }
552
+ }
553
+
554
+ const offs = [
555
+ store.on('hosts:changed', render),
556
+ store.on('hosts:reset', render),
557
+ store.on('route:changed', render),
558
+ store.on('pending:changed', render),
559
+ store.on('connection:changed', render),
560
+ ];
561
+ render();
562
+
563
+ return {
564
+ root,
565
+ menu,
566
+ overflowMenu,
567
+ render,
568
+ destroy() {
569
+ clearPressCycle();
570
+ for (const off of offs) off();
571
+ document.removeEventListener('pointerdown', onDocPointerDown);
572
+ document.removeEventListener('keydown', onKeyDown);
573
+ window.removeEventListener('resize', closeMenus);
574
+ window.removeEventListener('scroll', closeMenus, true);
575
+ },
576
+ };
577
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 全局 toast(10 §3.9 / UI-07)。成功 5s 自动关闭,错误留到手动关闭;
3
+ * stderr 一律 textContent 渲染。
4
+ */
5
+
6
+ import { clear, copyText, el } from '../utils.js';
7
+
8
+ const AUTO_DISMISS_MS = { info: 5_000, success: 5_000, warn: 8_000, error: null };
9
+
10
+ export function createToastRegion({ store }) {
11
+ const root = el('div.toast-region', { 'aria-live': 'polite', role: 'status' });
12
+ const timers = new Map();
13
+ const savedTabindex = new WeakMap();
14
+ let modalBlocked = false;
15
+
16
+ const render = () => {
17
+ clear(root);
18
+ for (const toast of store.state.toasts) {
19
+ root.append(renderToast(toast));
20
+ scheduleDismiss(toast);
21
+ }
22
+ syncModalBlocked();
23
+ };
24
+
25
+ /**
26
+ * toast 留在 aria-live 树里播报抽屉内的保存错误,但 custom modal 打开时,它的
27
+ * summary/button 不能成为外部 Tab 落点。render 会重建整片内容,所以每次渲染都
28
+ * 重新同步;关闭抽屉时再还原各控件原本的 tabindex。
29
+ */
30
+ function syncModalBlocked() {
31
+ if (modalBlocked) root.setAttribute('data-modal-blocked', 'true');
32
+ else root.removeAttribute('data-modal-blocked');
33
+
34
+ const controls = [
35
+ ...root.querySelectorAll('summary'),
36
+ ...root.querySelectorAll('button'),
37
+ ];
38
+ for (const control of controls) {
39
+ if (modalBlocked) {
40
+ if (!savedTabindex.has(control)) savedTabindex.set(control, control.getAttribute('tabindex'));
41
+ control.setAttribute('tabindex', '-1');
42
+ continue;
43
+ }
44
+ if (!savedTabindex.has(control)) continue;
45
+ const previous = savedTabindex.get(control);
46
+ if (previous === null) control.removeAttribute('tabindex');
47
+ else control.setAttribute('tabindex', previous);
48
+ savedTabindex.delete(control);
49
+ }
50
+ }
51
+
52
+ function setModalBlocked(on) {
53
+ modalBlocked = Boolean(on);
54
+ syncModalBlocked();
55
+ }
56
+
57
+ function scheduleDismiss(toast) {
58
+ if (timers.has(toast.id)) return;
59
+ const ms = toast.timeoutMs ?? AUTO_DISMISS_MS[toast.level] ?? 5_000;
60
+ if (ms === null) return;
61
+ timers.set(toast.id, setTimeout(() => {
62
+ timers.delete(toast.id);
63
+ store.dismissToast(toast.id);
64
+ }, ms));
65
+ }
66
+
67
+ function renderToast(toast) {
68
+ const node = el(`article.toast.toast-${toast.level}`, {}, [
69
+ el('p.summary', { text: toast.count > 1 ? `${toast.summary}(×${toast.count})` : toast.summary }),
70
+ ]);
71
+ if (toast.detail) {
72
+ const pre = el('pre.detail', { text: toast.detail });
73
+ node.append(el('details', {}, [el('summary', { text: '详情' }), pre, el('div.detail-actions', {}, [
74
+ el('button.btn.btn-compact.btn-default', {
75
+ type: 'button', text: '复制', on: { click: () => copyText(toast.detail) },
76
+ }),
77
+ ])]));
78
+ }
79
+ node.append(el('button.toast-close', {
80
+ type: 'button',
81
+ 'aria-label': '关闭',
82
+ text: '×',
83
+ on: {
84
+ click: () => {
85
+ const timer = timers.get(toast.id);
86
+ if (timer) clearTimeout(timer);
87
+ timers.delete(toast.id);
88
+ store.dismissToast(toast.id);
89
+ },
90
+ },
91
+ }));
92
+ return node;
93
+ }
94
+
95
+ const off = store.on('toasts:changed', render);
96
+ render();
97
+
98
+ return {
99
+ root,
100
+ setModalBlocked,
101
+ destroy() {
102
+ off();
103
+ for (const timer of timers.values()) clearTimeout(timer);
104
+ timers.clear();
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="DSH Center">
2
+ <rect width="32" height="32" rx="7" fill="#12161d"/>
3
+ <circle cx="16" cy="9" r="3.2" fill="#4ea1ff"/>
4
+ <circle cx="8" cy="23" r="3.2" fill="#3ddc97"/>
5
+ <circle cx="24" cy="23" r="3.2" fill="#3ddc97"/>
6
+ <path d="M16 12.2 8 19.8M16 12.2 24 19.8" stroke="#5b6472" stroke-width="1.8" stroke-linecap="round"/>
7
+ </svg>