@shendeguize/remote-dsh-center 0.5.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shendeguize/remote-dsh-center",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Local manager for remote dsh web instances: ssh tunnels, single-entry iframe shell, CLI lifecycle control",
5
5
  "type": "module",
6
6
  "engines": {
@@ -3,22 +3,57 @@
3
3
  * stderr 一律 textContent 渲染。
4
4
  */
5
5
 
6
- import { clear, copyText, el } from '../utils.js';
6
+ import { copyText, el } from '../utils.js';
7
7
 
8
+ /** `null` = 这一档不自动关闭(错误留到手动关)。缺档才落到 FALLBACK_MS。 */
8
9
  const AUTO_DISMISS_MS = { info: 5_000, success: 5_000, warn: 8_000, error: null };
10
+ const FALLBACK_MS = 5_000;
9
11
 
10
12
  export function createToastRegion({ store }) {
11
13
  const root = el('div.toast-region', { 'aria-live': 'polite', role: 'status' });
12
14
  const timers = new Map();
13
15
  const savedTabindex = new WeakMap();
16
+ /** toast.id → 已经渲染好的节点,跨次渲染复用。 */
17
+ const rendered = new Map();
14
18
  let modalBlocked = false;
15
19
 
20
+ /**
21
+ * 按 id 复用节点,不整片重建。错误 toast 里的「详情」常是排障唯一的线索,人正读着
22
+ * 的时候又弹出一条(批量操作一次弹好几条很常见),整片重建会把 `<details>` 的展开
23
+ * 状态清零、焦点甩回 body(issue #114)。
24
+ */
16
25
  const render = () => {
17
- clear(root);
26
+ const live = new Set();
18
27
  for (const toast of store.state.toasts) {
19
- root.append(renderToast(toast));
28
+ live.add(toast.id);
29
+ let entry = rendered.get(toast.id);
30
+ if (entry === undefined) {
31
+ entry = renderToast(toast);
32
+ rendered.set(toast.id, entry);
33
+ }
34
+ entry.summary.textContent = summaryText(toast); // 会变的只有重复计数
20
35
  scheduleDismiss(toast);
21
36
  }
37
+
38
+ for (const [id, entry] of rendered) {
39
+ if (live.has(id)) continue;
40
+ entry.node.remove();
41
+ rendered.delete(id);
42
+ }
43
+
44
+ // 对齐顺序。toast 只在尾部追加、从头部淘汰,幸存者的相对次序不会变,实际只有新
45
+ // 节点会被插进来;但仍逐个核对,免得将来换了插入策略这里悄悄错位。只在真的不对
46
+ // 位时才动——挪一个已经在树上的节点等于摘下来再插回去,焦点会掉。
47
+ let cursor = root.firstChild;
48
+ for (const toast of store.state.toasts) {
49
+ const { node } = rendered.get(toast.id);
50
+ if (node === cursor) {
51
+ cursor = cursor.nextSibling;
52
+ continue;
53
+ }
54
+ root.insertBefore(node, cursor);
55
+ }
56
+
22
57
  syncModalBlocked();
23
58
  };
24
59
 
@@ -54,9 +89,21 @@ export function createToastRegion({ store }) {
54
89
  syncModalBlocked();
55
90
  }
56
91
 
92
+ /**
93
+ * `??` 分不清「没配这一档」和「明确配成不关」:`null ?? 5_000` 会把「不关」变回 5 秒,
94
+ * 让 `error: null` 形同虚设(issue #114)。所以逐级判断,让 null 真的传得下去。
95
+ * 注意 store 的 addToast 默认就给 `timeoutMs: null`,那是「这条没单独指定」的意思,
96
+ * 与档位表里的 null 含义不同——这里用 `!= null` 把它当未指定处理。
97
+ */
98
+ function dismissDelay(toast) {
99
+ if (toast.timeoutMs != null) return toast.timeoutMs;
100
+ if (Object.hasOwn(AUTO_DISMISS_MS, toast.level)) return AUTO_DISMISS_MS[toast.level];
101
+ return FALLBACK_MS;
102
+ }
103
+
57
104
  function scheduleDismiss(toast) {
58
105
  if (timers.has(toast.id)) return;
59
- const ms = toast.timeoutMs ?? AUTO_DISMISS_MS[toast.level] ?? 5_000;
106
+ const ms = dismissDelay(toast);
60
107
  if (ms === null) return;
61
108
  timers.set(toast.id, setTimeout(() => {
62
109
  timers.delete(toast.id);
@@ -64,10 +111,13 @@ export function createToastRegion({ store }) {
64
111
  }, ms));
65
112
  }
66
113
 
114
+ function summaryText(toast) {
115
+ return toast.count > 1 ? `${toast.summary}(×${toast.count})` : toast.summary;
116
+ }
117
+
67
118
  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
- ]);
119
+ const summary = el('p.summary', { text: summaryText(toast) });
120
+ const node = el(`article.toast.toast-${toast.level}`, {}, [summary]);
71
121
  if (toast.detail) {
72
122
  const pre = el('pre.detail', { text: toast.detail });
73
123
  node.append(el('details', {}, [el('summary', { text: '详情' }), pre, el('div.detail-actions', {}, [
@@ -89,7 +139,7 @@ export function createToastRegion({ store }) {
89
139
  },
90
140
  },
91
141
  }));
92
- return node;
142
+ return { node, summary };
93
143
  }
94
144
 
95
145
  const off = store.on('toasts:changed', render);