@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

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/client.js ADDED
@@ -0,0 +1,4413 @@
1
+ /**
2
+ * dsh-session-sync — browser half.
3
+ *
4
+ * Hand-written bundle: no build step, no JSX. The module system hands the
5
+ * factory a `require` bound to the frozen platform seeds, so `react` is the
6
+ * only dependency this file needs.
7
+ *
8
+ * Surfaces:
9
+ * - `settings.section` → the full "会话同步" page (devices, naming, seeds,
10
+ * session pull/push)
11
+ * - `sidebar.panellist` → the sidebar row next to the host's own pages
12
+ * - `main` → the keyed panel that row opens
13
+ *
14
+ * Every string goes through this file's own dictionary so the plugin can follow
15
+ * the DSH locale and still honour an explicit 中文 / English override.
16
+ *
17
+ * All data comes from the host half's same-origin route `/session-sync/api`.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-session-sync',
21
+ factory: (require) => {
22
+ const React = require('react')
23
+ const h = React.createElement
24
+
25
+ const NS = 'sessionSync'
26
+ const API_PATH = '/session-sync/api'
27
+ /** Keyed `main` panel id, also the `sidebar.panellist` row id. */
28
+ const PANEL_ID = 'session-sync'
29
+ // Bumped together with package.json (a test enforces the pair). The footer
30
+ // shows the *running* host version next to it, so a stale process is
31
+ // obvious instead of looking like a publishing mistake.
32
+ const CLIENT_VERSION = '0.8.3'
33
+ const POLL_MS = 4000
34
+
35
+ // ── styles ────────────────────────────────────────────────────────────
36
+ // Pre-tagged so the module loader's `claimStyles` owns the tag and removes
37
+ // it again when the plugin is unloaded.
38
+ // The look is built on DSH's own design tokens (`--dsw-*`): colours, radii,
39
+ // type ramp and motion all come from the host, so light/dark themes and any
40
+ // future theme switch follow automatically instead of being recoloured here.
41
+ const CSS = `
42
+ /* --------------------------------------------------------------------------
43
+ 2. 产品层 —— 语义变量(落地时替换为 --dsw-alias-*)
44
+ -------------------------------------------------------------------------- */
45
+
46
+ .ss-root {
47
+ --canvas: var(--dsw-alias-bg-base, #0d0d0f);
48
+ --s1: var(--dsw-alias-bg-layer-1, #17171a);
49
+ --s2: var(--dsw-alias-bg-layer-2, #1e1e22);
50
+ --s3: var(--dsw-alias-bg-layer-3, #26262b);
51
+ --line-1: var(--dsw-alias-border-l1, rgba(255, 255, 255, .06));
52
+ --line-2: var(--dsw-alias-border-l2, rgba(255, 255, 255, .10));
53
+ --line-3: var(--dsw-alias-border-l3, rgba(255, 255, 255, .17));
54
+ --t1: var(--dsw-alias-label-primary, #f4f4f5);
55
+ --t2: var(--dsw-alias-label-secondary, #adadb6);
56
+ --t3: var(--dsw-alias-label-tertiary, #8b8b96);
57
+ --brand: var(--dsw-alias-brand-primary, #7c93ff);
58
+ --brand-ink: var(--dsw-alias-label-primary-foreground, #0b0b12);
59
+ --brand-weak: color-mix(in srgb, var(--brand) 13%, transparent);
60
+ --brand-line: color-mix(in srgb, var(--brand) 42%, transparent);
61
+ --ok: var(--dsw-alias-state-success-primary, #4ade80);
62
+ --ok-weak: color-mix(in srgb, var(--ok) 12%, transparent);
63
+ --ok-line: color-mix(in srgb, var(--ok) 30%, transparent);
64
+ --bad: var(--dsw-alias-state-error-primary, #f87171);
65
+ --bad-weak: color-mix(in srgb, var(--bad) 11%, transparent);
66
+ --bad-line: color-mix(in srgb, var(--bad) 30%, transparent);
67
+ --warn: var(--dsw-alias-state-warning-primary, #fbbf24);
68
+ --warn-weak: color-mix(in srgb, var(--warn) 12%, transparent);
69
+ --warn-line: color-mix(in srgb, var(--warn) 30%, transparent);
70
+ --btn-bg: var(--dsw-alias-button-primary-fill, #ededf0);
71
+ --btn-fg: var(--dsw-alias-label-primary-foreground, #131316);
72
+ --btn-bg-hover: var(--dsw-alias-button-primary-hover, #ffffff);
73
+ --hover: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .045));
74
+ --hover-2: var(--dsw-alias-interactive-bg-active, rgba(255, 255, 255, .07));
75
+ --shadow-pop: var(--dsw-elevation-panel, 0 20px 48px rgba(0, 0, 0, .55));
76
+ --shadow-dialog: var(--dsw-elevation-panel, 0 32px 80px rgba(0, 0, 0, .6));
77
+ --r1: var(--dsw-radius-xs, 6px);
78
+ --r2: var(--dsw-radius-sm, 8px);
79
+ --r3: var(--dsw-radius-md, 10px);
80
+ --r4: var(--dsw-radius-lg, 12px);
81
+ --r5: var(--dsw-radius-xl, 16px);
82
+ --ease: var(--ds-ease-in-out, cubic-bezier(.32, .72, 0, 1));
83
+ --dur: var(--ds-transition-duration, 150ms);
84
+
85
+ box-sizing: border-box;
86
+ display: flex;
87
+ flex-direction: column;
88
+ width: 100%;
89
+ height: 100%;
90
+ min-height: 640px;
91
+ background: var(--canvas);
92
+ color: var(--t1);
93
+ font: 14px/22px var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", sans-serif);
94
+ -webkit-font-smoothing: antialiased;
95
+ overflow: hidden;
96
+ }
97
+
98
+
99
+ .ss-root ::selection { background: var(--brand-weak); }
100
+
101
+ /* 图标 */
102
+ .i {
103
+ width: 16px;
104
+ height: 16px;
105
+ flex: none;
106
+ fill: none;
107
+ stroke: currentColor;
108
+ stroke-width: 1.5;
109
+ stroke-linecap: round;
110
+ stroke-linejoin: round;
111
+ }
112
+ .i.sm { width: 14px; height: 14px; }
113
+ .i.xs { width: 12px; height: 12px; stroke-width: 1.7; }
114
+ .i.lg { width: 20px; height: 20px; }
115
+
116
+ /* 按钮 */
117
+ .ss-btn {
118
+ display: inline-flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ gap: 6px;
122
+ height: 32px;
123
+ padding: 0 12px;
124
+ border-radius: var(--r3);
125
+ border: .5px solid var(--line-2);
126
+ background: transparent;
127
+ color: var(--t1);
128
+ font: inherit;
129
+ font-size: 13px;
130
+ line-height: 20px;
131
+ white-space: nowrap;
132
+ cursor: pointer;
133
+ transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease),
134
+ color var(--dur) var(--ease), transform 90ms var(--ease);
135
+ }
136
+ .ss-btn:hover { background: var(--hover-2); }
137
+ .ss-btn:active { transform: scale(.985); }
138
+ .ss-btn.primary {
139
+ background: var(--btn-bg);
140
+ color: var(--btn-fg);
141
+ border-color: transparent;
142
+ font-weight: 550;
143
+ }
144
+ .ss-btn.primary:hover { background: var(--btn-bg-hover); }
145
+ .ss-btn.ghost { border-color: transparent; color: var(--t2); }
146
+ .ss-btn.ghost:hover { color: var(--t1); background: var(--hover-2); }
147
+ .ss-btn.danger { color: var(--bad); border-color: transparent; }
148
+ .ss-btn.danger:hover { background: var(--bad-weak); }
149
+ .ss-btn.sm { height: 28px; padding: 0 10px; font-size: 12.5px; border-radius: var(--r2); }
150
+ .ss-btn.xs { height: 24px; padding: 0 8px; font-size: 12px; border-radius: var(--r1); }
151
+ .ss-btn[disabled] { opacity: .42; pointer-events: none; }
152
+ .ss-btn:focus-visible,
153
+ .ss-icon:focus-visible,
154
+ .ss-tab:focus-visible,
155
+ .ss-filter:focus-visible,
156
+ .ss-cb:focus-visible,
157
+ .ss-dev:focus-visible,
158
+ .ss-row:focus-visible,
159
+ .ss-switch:focus-visible {
160
+ outline: 2px solid var(--brand);
161
+ outline-offset: 1px;
162
+ }
163
+
164
+ .ss-icon {
165
+ display: inline-grid;
166
+ place-items: center;
167
+ width: 30px;
168
+ height: 30px;
169
+ padding: 0;
170
+ border: 0;
171
+ border-radius: var(--r2);
172
+ background: transparent;
173
+ color: var(--t2);
174
+ cursor: pointer;
175
+ transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease);
176
+ }
177
+ .ss-icon:hover { background: var(--hover-2); color: var(--t1); }
178
+ .ss-icon.sm { width: 24px; height: 24px; border-radius: var(--r1); }
179
+ .ss-icon.xs { width: 20px; height: 20px; border-radius: 5px; }
180
+
181
+ /* 状态点 */
182
+ .ss-dot {
183
+ width: 7px; height: 7px;
184
+ border-radius: 50%;
185
+ flex: none;
186
+ background: var(--t3);
187
+ box-shadow: none;
188
+ }
189
+ .ss-dot.on { background: var(--ok); box-shadow: 0 0 0 2.5px var(--ok-weak); }
190
+ .ss-dot.off { background: transparent; box-shadow: inset 0 0 0 1.5px var(--t3); }
191
+
192
+ /* 徽章 */
193
+ .ss-chip {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ gap: 4px;
197
+ height: 20px;
198
+ padding: 0 7px;
199
+ border-radius: var(--r1);
200
+ border: .5px solid var(--line-2);
201
+ color: var(--t3);
202
+ font-size: 11.5px;
203
+ line-height: 1;
204
+ white-space: nowrap;
205
+ }
206
+ .ss-chip.ok { color: var(--ok); border-color: var(--ok-line); background: var(--ok-weak); }
207
+ .ss-chip.bad { color: var(--bad); border-color: var(--bad-line); background: var(--bad-weak); }
208
+ .ss-chip.brand { color: var(--brand); border-color: var(--brand-line); background: var(--brand-weak); }
209
+ .ss-chip.warn { color: var(--warn); border-color: var(--warn-line); background: var(--warn-weak); }
210
+ .ss-chip.plain { border-color: transparent; background: var(--hover); }
211
+ .ss-chip.mono { font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 10.5px; letter-spacing: -.01em; }
212
+ .ss-chip .i { width: 11px; height: 11px; stroke-width: 2; }
213
+
214
+ /* 页面骨架 */
215
+ .ss-page {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 14px;
219
+ flex: 1;
220
+ min-height: 0;
221
+ padding: 20px 24px 0;
222
+ }
223
+ .ss-top { display: flex; align-items: flex-start; gap: 16px; }
224
+ .ss-top-l { min-width: 0; flex: 1; }
225
+ .ss-h1 {
226
+ margin: 0;
227
+ font-size: 20px;
228
+ line-height: 28px;
229
+ font-weight: 600;
230
+ letter-spacing: -.015em;
231
+ }
232
+ .ss-sub {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 7px;
236
+ flex-wrap: wrap;
237
+ margin-top: 2px;
238
+ color: var(--t3);
239
+ font-size: 12.5px;
240
+ line-height: 18px;
241
+ }
242
+ .ss-sub .sep { color: var(--line-3); }
243
+ .ss-sub b { color: var(--t2); font-weight: 550; font-variant-numeric: tabular-nums; }
244
+ .ss-top-r { display: flex; align-items: center; gap: 4px; flex: none; padding-top: 2px; }
245
+
246
+ .ss-desk {
247
+ display: grid;
248
+ grid-template-columns: 258px minmax(0, 1fr);
249
+ gap: 12px;
250
+ flex: 1;
251
+ min-height: 0;
252
+ }
253
+
254
+ /* 左栏:本机 + 设备 */
255
+ .ss-rail {
256
+ display: flex;
257
+ flex-direction: column;
258
+ min-height: 0;
259
+ border: .5px solid var(--line-1);
260
+ border-radius: var(--r4);
261
+ background: var(--s1);
262
+ overflow: hidden;
263
+ }
264
+ .ss-self { padding: 12px 12px 11px; border-bottom: .5px solid var(--line-1); }
265
+ .ss-self-hd { display: flex; align-items: center; gap: 6px; color: var(--t3); font-size: 11.5px; line-height: 16px; }
266
+ .ss-self-hd .ss-icon { margin-left: auto; }
267
+ .ss-self-name {
268
+ margin-top: 2px;
269
+ font-size: 13.5px;
270
+ line-height: 20px;
271
+ font-weight: 550;
272
+ overflow: hidden;
273
+ text-overflow: ellipsis;
274
+ white-space: nowrap;
275
+ }
276
+ .ss-self-meta {
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 5px;
280
+ margin-top: 2px;
281
+ color: var(--t3);
282
+ font: 11px/16px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
283
+ white-space: nowrap;
284
+ overflow: hidden;
285
+ }
286
+ .ss-self-meta .ss-icon { color: var(--t3); }
287
+ .ss-self-meta .ss-icon:hover { color: var(--t1); }
288
+
289
+ .ss-rail-scroll { flex: 1; min-height: 0; overflow: auto; padding: 6px; scrollbar-width: thin; }
290
+ .ss-glabel {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 6px;
294
+ padding: 9px 7px 5px;
295
+ color: var(--t3);
296
+ font-size: 11px;
297
+ line-height: 16px;
298
+ letter-spacing: .02em;
299
+ }
300
+ .ss-glabel span { font-variant-numeric: tabular-nums; }
301
+ .ss-glabel::after { content: ""; flex: 1; height: .5px; background: var(--line-1); }
302
+
303
+ .ss-dev {
304
+ position: relative;
305
+ display: grid;
306
+ grid-template-columns: 7px minmax(0, 1fr) auto;
307
+ align-items: center;
308
+ gap: 8px;
309
+ width: 100%;
310
+ padding: 8px;
311
+ border: 0;
312
+ border-radius: var(--r2);
313
+ background: transparent;
314
+ color: inherit;
315
+ font: inherit;
316
+ text-align: left;
317
+ cursor: pointer;
318
+ transition: background-color var(--dur) var(--ease);
319
+ }
320
+ .ss-dev:hover { background: var(--hover); }
321
+ .ss-dev[aria-selected="true"] { background: var(--s2); }
322
+ .ss-dev[aria-selected="true"]::before {
323
+ content: "";
324
+ position: absolute;
325
+ left: 0; top: 9px; bottom: 9px;
326
+ width: 2px;
327
+ border-radius: 2px;
328
+ background: var(--brand);
329
+ }
330
+ .ss-dev-name {
331
+ display: block;
332
+ font-size: 13.5px;
333
+ line-height: 19px;
334
+ font-weight: 500;
335
+ overflow: hidden;
336
+ text-overflow: ellipsis;
337
+ white-space: nowrap;
338
+ }
339
+ .ss-dev-meta {
340
+ display: block;
341
+ margin-top: 1px;
342
+ color: var(--t3);
343
+ font-size: 11.5px;
344
+ line-height: 16px;
345
+ overflow: hidden;
346
+ text-overflow: ellipsis;
347
+ white-space: nowrap;
348
+ font-variant-numeric: tabular-nums;
349
+ }
350
+ .ss-dev-meta code { font: 11px/16px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); }
351
+ .ss-dev-count {
352
+ min-width: 20px;
353
+ text-align: right;
354
+ color: var(--t3);
355
+ font-size: 12px;
356
+ line-height: 18px;
357
+ font-variant-numeric: tabular-nums;
358
+ }
359
+ .ss-dev.offline .ss-dev-name { color: var(--t2); }
360
+ .ss-dev.offline .ss-dev-count { color: var(--t3); }
361
+ .ss-dev.over {
362
+ background: var(--brand-weak);
363
+ box-shadow: inset 0 0 0 1px var(--brand-line);
364
+ }
365
+
366
+ .ss-rail-foot {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 6px;
370
+ padding: 8px;
371
+ border-top: .5px solid var(--line-1);
372
+ }
373
+ .ss-rail-foot .ss-btn { flex: 1; justify-content: flex-start; }
374
+ .ss-rail-empty {
375
+ display: flex;
376
+ flex-direction: column;
377
+ align-items: center;
378
+ gap: 6px;
379
+ padding: 26px 12px;
380
+ color: var(--t3);
381
+ font-size: 11.5px;
382
+ line-height: 17px;
383
+ text-align: center;
384
+ }
385
+ .ss-rail-empty .i { width: 18px; height: 18px; opacity: .7; }
386
+
387
+ /* 右栏:会话台 */
388
+ .ss-stage {
389
+ position: relative;
390
+ display: flex;
391
+ flex-direction: column;
392
+ min-width: 0;
393
+ min-height: 0;
394
+ border: .5px solid var(--line-1);
395
+ border-radius: var(--r4);
396
+ background: var(--s1);
397
+ overflow: hidden;
398
+ }
399
+ .ss-stage-hd { display: flex; align-items: flex-start; gap: 10px; padding: 13px 16px 10px; }
400
+ .ss-stage-id { display: flex; gap: 9px; min-width: 0; flex: 1; }
401
+ .ss-stage-id .ss-dot { margin-top: 7px; }
402
+ .ss-stage-name {
403
+ font-size: 15px;
404
+ line-height: 22px;
405
+ font-weight: 600;
406
+ letter-spacing: -.01em;
407
+ overflow: hidden;
408
+ text-overflow: ellipsis;
409
+ white-space: nowrap;
410
+ }
411
+ .ss-stage-meta {
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 5px;
415
+ flex-wrap: wrap;
416
+ margin-top: 1px;
417
+ color: var(--t3);
418
+ font-size: 11.5px;
419
+ line-height: 17px;
420
+ font-variant-numeric: tabular-nums;
421
+ }
422
+ .ss-stage-meta code { font: 11px/17px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); }
423
+ .ss-stage-meta b { color: var(--t2); font-weight: 550; }
424
+ .ss-stage-actions { display: flex; align-items: center; gap: 4px; flex: none; }
425
+
426
+ /* 方向切换 */
427
+ .ss-tabs {
428
+ display: flex;
429
+ gap: 2px;
430
+ width: fit-content;
431
+ margin: 0 16px 10px;
432
+ padding: 2px;
433
+ border-radius: var(--r3);
434
+ background: var(--s2);
435
+ }
436
+ .ss-tab {
437
+ display: flex;
438
+ align-items: center;
439
+ gap: 6px;
440
+ height: 27px;
441
+ padding: 0 11px;
442
+ border: 0;
443
+ border-radius: var(--r2);
444
+ background: transparent;
445
+ color: var(--t2);
446
+ font: inherit;
447
+ font-size: 12.5px;
448
+ cursor: pointer;
449
+ transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease);
450
+ }
451
+ .ss-tab:hover { color: var(--t1); }
452
+ .ss-tab[aria-selected="true"] {
453
+ background: var(--s3);
454
+ color: var(--t1);
455
+ box-shadow: inset 0 0 0 .5px var(--line-2), 0 1px 2px rgba(0, 0, 0, .18);
456
+ }
457
+ .ss-tab b { font-weight: 500; color: var(--t3); font-variant-numeric: tabular-nums; }
458
+ .ss-tab[aria-selected="true"] b { color: var(--t2); }
459
+
460
+ /* 工具条 */
461
+ .ss-tools { display: flex; align-items: center; gap: 8px; padding: 0 16px 10px; flex-wrap: wrap; }
462
+ .ss-search { position: relative; flex: 1; min-width: 168px; max-width: 296px; }
463
+ .ss-search input {
464
+ width: 100%;
465
+ height: 30px;
466
+ padding: 0 42px 0 29px;
467
+ border: .5px solid var(--line-2);
468
+ border-radius: var(--r2);
469
+ background: transparent;
470
+ color: var(--t1);
471
+ font: inherit;
472
+ font-size: 12.5px;
473
+ outline: 0;
474
+ }
475
+ .ss-search input::placeholder { color: var(--t3); }
476
+ .ss-search input:focus { border-color: var(--brand-line); box-shadow: 0 0 0 3px var(--brand-weak); }
477
+ .ss-search .i { position: absolute; left: 9px; top: 8px; color: var(--t3); }
478
+ .ss-search kbd {
479
+ position: absolute;
480
+ right: 7px; top: 6px;
481
+ height: 18px;
482
+ min-width: 18px;
483
+ padding: 0 4px;
484
+ display: grid;
485
+ place-items: center;
486
+ border: .5px solid var(--line-2);
487
+ border-radius: 5px;
488
+ color: var(--t3);
489
+ font: 10.5px/1 var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
490
+ }
491
+
492
+ .ss-filters { display: flex; gap: 2px; padding: 2px; border-radius: var(--r2); background: var(--s2); }
493
+ .ss-filter {
494
+ display: inline-flex;
495
+ align-items: center;
496
+ gap: 5px;
497
+ height: 26px;
498
+ padding: 0 9px;
499
+ border: 0;
500
+ border-radius: var(--r1);
501
+ background: transparent;
502
+ color: var(--t2);
503
+ font: inherit;
504
+ font-size: 12px;
505
+ cursor: pointer;
506
+ transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease);
507
+ }
508
+ .ss-filter:hover { color: var(--t1); }
509
+ .ss-filter b { font-weight: 500; color: var(--t3); font-variant-numeric: tabular-nums; }
510
+ .ss-filter[aria-pressed="true"] {
511
+ background: var(--s3);
512
+ color: var(--t1);
513
+ box-shadow: inset 0 0 0 .5px var(--line-2);
514
+ }
515
+ .ss-filter[aria-pressed="true"] b { color: var(--t2); }
516
+ .ss-tools .ss-sort { margin-left: auto; }
517
+
518
+ /* 会话列表 */
519
+ .ss-tools > .ss-cb { margin-right: 2px; }
520
+ .ss-list {
521
+ flex: 1;
522
+ min-height: 0;
523
+ overflow: auto;
524
+ padding: 2px 8px 8px;
525
+ border-top: .5px solid var(--line-1);
526
+ scrollbar-width: thin;
527
+ scrollbar-color: var(--line-2) transparent;
528
+ }
529
+ .ss-list::-webkit-scrollbar { width: 9px; }
530
+ .ss-list::-webkit-scrollbar-thumb {
531
+ background: var(--line-2);
532
+ border-radius: 99px;
533
+ border: 2.5px solid transparent;
534
+ background-clip: content-box;
535
+ }
536
+ .ss-list-head {
537
+ display: grid;
538
+ grid-template-columns: 16px minmax(0, 1fr) auto 104px;
539
+ gap: 10px;
540
+ align-items: center;
541
+ padding: 8px 8px 6px;
542
+ color: var(--t3);
543
+ font-size: 11px;
544
+ line-height: 16px;
545
+ }
546
+ .ss-list-head .right { text-align: right; }
547
+
548
+ .ss-row {
549
+ position: relative;
550
+ display: grid;
551
+ grid-template-columns: 16px minmax(0, 1fr) auto 104px;
552
+ gap: 10px;
553
+ align-items: center;
554
+ height: 47px;
555
+ padding: 0 8px;
556
+ border-radius: var(--r2);
557
+ cursor: pointer;
558
+ transition: background-color var(--dur) var(--ease);
559
+ }
560
+ .ss-row.push { grid-template-columns: 16px minmax(0, 1fr) 104px; }
561
+ .ss-row:hover,
562
+ .ss-row.is-hover { background: var(--hover); }
563
+ .ss-row[aria-selected="true"] { background: var(--brand-weak); }
564
+ .ss-row-main { min-width: 0; }
565
+ .ss-row-title {
566
+ overflow: hidden;
567
+ text-overflow: ellipsis;
568
+ white-space: nowrap;
569
+ color: var(--t1);
570
+ font-size: 13.5px;
571
+ line-height: 19px;
572
+ }
573
+ .ss-row-meta {
574
+ display: flex;
575
+ align-items: center;
576
+ gap: 5px;
577
+ overflow: hidden;
578
+ color: var(--t3);
579
+ font-size: 11.5px;
580
+ line-height: 16px;
581
+ white-space: nowrap;
582
+ font-variant-numeric: tabular-nums;
583
+ }
584
+ .ss-row-meta code { font: 11px/16px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); }
585
+ .ss-row-meta .ss-path { overflow: hidden; text-overflow: ellipsis; }
586
+ .ss-row-meta .ss-sep { color: var(--line-3); }
587
+ .ss-row-trail { display: flex; justify-content: flex-end; }
588
+ .ss-row-act { display: flex; align-items: center; justify-content: flex-end; gap: 2px; min-width: 0; overflow: hidden; opacity: 0; transition: opacity var(--dur) var(--ease); }
589
+ .ss-row:hover .ss-row-act,
590
+ .ss-row.is-hover .ss-row-act,
591
+ .ss-row:focus-within .ss-row-act { opacity: 1; }
592
+ @media (hover: none) { .ss-row-act { opacity: 1; } }
593
+
594
+ .ss-cb {
595
+ display: grid;
596
+ place-items: center;
597
+ width: 16px; height: 16px;
598
+ padding: 0;
599
+ border: 1px solid var(--line-3);
600
+ border-radius: 5px;
601
+ background: transparent;
602
+ color: var(--brand-ink);
603
+ cursor: pointer;
604
+ transition: background-color var(--dur) var(--ease), border-color var(--dur) var(--ease);
605
+ }
606
+ .ss-cb:hover { border-color: var(--brand-line); }
607
+ .ss-cb .i { width: 11px; height: 11px; stroke-width: 2.6; opacity: 0; }
608
+ .ss-cb[aria-checked="true"] { border-color: var(--brand); background: var(--brand); color: var(--brand-ink); }
609
+ .ss-cb[aria-checked="true"] .i { opacity: 1; }
610
+ .ss-cb[aria-checked="mixed"] { border-color: var(--brand); background: var(--brand); }
611
+ .ss-cb[aria-checked="mixed"]::after {
612
+ content: "";
613
+ width: 7px; height: 1.5px;
614
+ border-radius: 1px;
615
+ background: var(--brand-ink);
616
+ }
617
+
618
+ /* 摘要行 */
619
+ .ss-digest {
620
+ display: flex;
621
+ align-items: center;
622
+ gap: 8px;
623
+ margin: 0 8px 2px 42px;
624
+ padding: 7px 9px;
625
+ border-radius: var(--r2);
626
+ background: var(--hover);
627
+ color: var(--t2);
628
+ font-size: 11.5px;
629
+ line-height: 16px;
630
+ }
631
+ .ss-digest.ok { background: var(--ok-weak); color: var(--ok); }
632
+ .ss-digest.bad { background: var(--bad-weak); color: var(--bad); }
633
+ .ss-digest code { font: 11px/16px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); color: inherit; overflow: hidden; text-overflow: ellipsis; }
634
+ .ss-digest .grow { flex: 1; min-width: 0; }
635
+ .ss-digest .ss-btn { color: var(--t2); }
636
+ .ss-digest .ss-btn:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
637
+ .ss-digest-pair { display: grid; gap: 2px; min-width: 0; }
638
+ .ss-digest-pair div { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
639
+
640
+ /* 选择条 */
641
+ .ss-selbar {
642
+ position: relative;
643
+ z-index: 6;
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 8px;
647
+ margin: 6px 10px;
648
+ padding: 6px 7px 6px 12px;
649
+ border: .5px solid var(--line-2);
650
+ border-radius: var(--r3);
651
+ background: var(--s3);
652
+ box-shadow: 0 10px 28px rgba(0, 0, 0, .28);
653
+ animation: ss-rise 160ms var(--ease) both;
654
+ }
655
+ @keyframes ss-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
656
+ .ss-selbar .count { font-size: 12.5px; font-weight: 550; white-space: nowrap; }
657
+ .ss-selbar .count b { font-variant-numeric: tabular-nums; }
658
+ .ss-selbar .ss-target {
659
+ display: flex;
660
+ align-items: center;
661
+ gap: 5px;
662
+ height: 28px;
663
+ padding: 0 7px 0 9px;
664
+ border: .5px solid var(--line-2);
665
+ border-radius: var(--r2);
666
+ background: var(--s1);
667
+ color: var(--t1);
668
+ font: inherit;
669
+ font-size: 12.5px;
670
+ cursor: pointer;
671
+ max-width: 260px;
672
+ }
673
+ .ss-selbar .ss-target span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
674
+ .ss-selbar .ss-target .i { color: var(--t3); }
675
+ .ss-selbar .ss-spacer { flex: 1; }
676
+ .ss-selbar .hint { color: var(--t3); font-size: 11px; margin-right: 2px; }
677
+
678
+ .ss-popover {
679
+ position: absolute;
680
+ bottom: calc(100% + 8px);
681
+ left: 0;
682
+ z-index: 30;
683
+ width: 322px;
684
+ padding: 6px;
685
+ border: .5px solid var(--line-2);
686
+ border-radius: var(--r4);
687
+ background: var(--s3);
688
+ box-shadow: var(--shadow-pop);
689
+ animation: ss-pop 140ms var(--ease) both;
690
+ }
691
+ @keyframes ss-pop { from { opacity: 0; transform: translateY(3px) scale(.99); } to { opacity: 1; transform: none; } }
692
+ .ss-pop-hd {
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 6px;
696
+ padding: 7px 8px 6px;
697
+ color: var(--t3);
698
+ font-size: 11px;
699
+ line-height: 16px;
700
+ }
701
+ .ss-opt {
702
+ display: flex;
703
+ align-items: center;
704
+ gap: 8px;
705
+ width: 100%;
706
+ padding: 7px 8px;
707
+ border: 0;
708
+ border-radius: var(--r2);
709
+ background: transparent;
710
+ color: inherit;
711
+ font: inherit;
712
+ text-align: left;
713
+ cursor: pointer;
714
+ }
715
+ .ss-opt:hover { background: var(--hover-2); }
716
+ .ss-opt-main { flex: 1; min-width: 0; }
717
+ .ss-opt-name { display: flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 18px; }
718
+ .ss-opt-path {
719
+ display: block;
720
+ margin-top: 1px;
721
+ color: var(--t3);
722
+ font: 10.5px/15px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
723
+ overflow: hidden;
724
+ text-overflow: ellipsis;
725
+ white-space: nowrap;
726
+ }
727
+ .ss-opt-count { color: var(--t3); font-size: 11px; white-space: nowrap; font-variant-numeric: tabular-nums; }
728
+ .ss-opt .i.check { color: var(--brand); }
729
+ .ss-pop-foot { margin-top: 4px; padding: 5px 2px 2px; border-top: .5px solid var(--line-1); }
730
+
731
+ /* 传输坞 */
732
+ .ss-dock {
733
+ display: flex;
734
+ align-items: center;
735
+ gap: 10px;
736
+ min-height: 42px;
737
+ padding: 6px 10px 6px 12px;
738
+ border-top: .5px solid var(--line-1);
739
+ background: color-mix(in srgb, var(--s1) 76%, var(--s2));
740
+ color: var(--t2);
741
+ font-size: 12px;
742
+ }
743
+ .ss-dock .ss-dock-main { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
744
+ .ss-dock .ss-dock-main b { color: var(--t1); font-weight: 550; font-variant-numeric: tabular-nums; }
745
+ .ss-dock .ss-dock-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--t3); }
746
+ .ss-dock .meter { flex: 1; min-width: 52px; height: 4px; border-radius: 99px; background: var(--line-2); overflow: hidden; }
747
+ .ss-dock .meter i { display: block; height: 100%; width: 43%; border-radius: inherit; background: var(--brand); transition: width 320ms var(--ease); }
748
+ .ss-dock .ss-dock-pct { font-variant-numeric: tabular-nums; color: var(--t2); }
749
+
750
+ .ss-spinner {
751
+ width: 13px; height: 13px;
752
+ flex: none;
753
+ border-radius: 50%;
754
+ border: 1.5px solid var(--line-2);
755
+ border-top-color: var(--brand);
756
+ animation: ss-spin .75s linear infinite;
757
+ }
758
+ @keyframes ss-spin { to { transform: rotate(360deg); } }
759
+
760
+ /* 队列展开 */
761
+ .ss-queue { border-top: .5px solid var(--line-1); background: var(--s1); }
762
+ .ss-queue-hd {
763
+ display: flex;
764
+ align-items: center;
765
+ gap: 8px;
766
+ padding: 9px 12px;
767
+ border-bottom: .5px solid var(--line-1);
768
+ }
769
+ .ss-queue-hd h4 { margin: 0; font-size: 12.5px; line-height: 18px; font-weight: 600; }
770
+ .ss-queue-stats { display: flex; align-items: center; gap: 8px; color: var(--t3); font-size: 11px; margin-left: 4px; }
771
+ .ss-queue-stats b { color: var(--t2); font-weight: 550; font-variant-numeric: tabular-nums; }
772
+ .ss-queue-hd .ss-spacer { flex: 1; }
773
+ .ss-queue-body { max-height: 268px; overflow: auto; padding: 4px 6px 6px; }
774
+ .ss-qrow {
775
+ display: grid;
776
+ grid-template-columns: 18px minmax(0, 1fr) auto;
777
+ gap: 9px;
778
+ padding: 8px 7px;
779
+ border-radius: var(--r2);
780
+ align-items: start;
781
+ }
782
+ .ss-qrow:hover { background: var(--hover); }
783
+ .ss-qrow + .ss-qrow { margin-top: 1px; }
784
+ .ss-qico { display: grid; place-items: center; width: 18px; height: 18px; margin-top: 1px; color: var(--t3); }
785
+ .ss-qico.done { color: var(--ok); }
786
+ .ss-qico.failed { color: var(--bad); }
787
+ .ss-qico.running { color: var(--brand); }
788
+ .ss-qmain { min-width: 0; }
789
+ .ss-qname { font-size: 12.5px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
790
+ .ss-qmeta { display: flex; align-items: center; gap: 6px; color: var(--t3); font-size: 11px; line-height: 16px; white-space: nowrap; overflow: hidden; }
791
+ .ss-qmeta code { font: 10.5px/16px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); overflow: hidden; text-overflow: ellipsis; }
792
+ .ss-qmsg { color: var(--bad); font-size: 11px; line-height: 16px; margin-top: 1px; }
793
+ .ss-qright { display: flex; align-items: center; gap: 6px; padding-top: 1px; }
794
+ .ss-qright .pct { color: var(--t2); font-size: 11px; font-variant-numeric: tabular-nums; }
795
+ .ss-qright .dur { color: var(--t3); font-size: 10.5px; font-variant-numeric: tabular-nums; }
796
+
797
+ /* 通用小工具 */
798
+ .ss-empty {
799
+ display: flex;
800
+ flex-direction: column;
801
+ align-items: center;
802
+ justify-content: center;
803
+ flex: 1;
804
+ min-height: 0;
805
+ gap: 6px;
806
+ padding: 28px;
807
+ text-align: center;
808
+ }
809
+ .ss-empty .glyph {
810
+ display: grid;
811
+ place-items: center;
812
+ width: 40px; height: 40px;
813
+ margin-bottom: 4px;
814
+ border-radius: 12px;
815
+ border: .5px solid var(--line-2);
816
+ background: var(--s2);
817
+ color: var(--t2);
818
+ }
819
+ .ss-empty h3 { margin: 0; font-size: 14.5px; line-height: 22px; font-weight: 600; }
820
+ .ss-empty p { margin: 0; max-width: 380px; color: var(--t3); font-size: 12.5px; line-height: 20px; }
821
+ .ss-empty .ss-addr-box { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
822
+ .ss-addr-box {
823
+ display: flex;
824
+ align-items: center;
825
+ gap: 6px;
826
+ height: 30px;
827
+ padding: 0 4px 0 9px;
828
+ border: .5px solid var(--line-2);
829
+ border-radius: var(--r2);
830
+ background: var(--s1);
831
+ }
832
+ .ss-addr-box code { color: var(--t2); font: 11.5px/18px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); }
833
+ .ss-input {
834
+ height: 30px;
835
+ padding: 0 9px;
836
+ border: .5px solid var(--line-2);
837
+ border-radius: var(--r2);
838
+ background: var(--s1);
839
+ color: var(--t1);
840
+ font: inherit;
841
+ font-size: 12.5px;
842
+ outline: 0;
843
+ min-width: 0;
844
+ }
845
+ .ss-input::placeholder { color: var(--t3); }
846
+ .ss-input:focus { border-color: var(--brand-line); box-shadow: 0 0 0 3px var(--brand-weak); }
847
+
848
+ /* 弹窗(计划确认) */
849
+ .ss-scrim {
850
+ position: fixed;
851
+ inset: 0;
852
+ z-index: 50;
853
+ display: grid;
854
+ place-items: center;
855
+ padding: 28px;
856
+ background: rgba(8, 8, 10, .58);
857
+ backdrop-filter: blur(2px);
858
+ }
859
+ .ss-dialog {
860
+ width: 540px;
861
+ max-height: 86%;
862
+ display: flex;
863
+ flex-direction: column;
864
+ border: .5px solid var(--line-2);
865
+ border-radius: var(--r5);
866
+ background: var(--s3);
867
+ box-shadow: var(--shadow-dialog);
868
+ animation: ss-dialog-in 180ms var(--ease) both;
869
+ }
870
+ @keyframes ss-dialog-in { from { opacity: 0; transform: translateY(6px) scale(.99); } to { opacity: 1; transform: none; } }
871
+ .ss-dialog-hd { padding: 18px 20px 0; }
872
+ .ss-dialog-hd h3 { margin: 0; font-size: 15.5px; line-height: 23px; font-weight: 600; }
873
+ .ss-dialog-hd p { margin: 3px 0 0; color: var(--t3); font-size: 12.5px; line-height: 19px; }
874
+ .ss-dialog-body { flex: 1; min-height: 0; padding: 14px 20px 4px; overflow: auto; }
875
+ .ss-plan-row {
876
+ display: grid;
877
+ grid-template-columns: minmax(0, 1fr) auto;
878
+ gap: 12px;
879
+ align-items: center;
880
+ padding: 9px 0;
881
+ border-top: .5px solid var(--line-1);
882
+ }
883
+ .ss-plan-row:first-child { border-top: 0; }
884
+ .ss-plan-title { font-size: 12.5px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
885
+ .ss-plan-meta { color: var(--t3); font-size: 11px; line-height: 16px; }
886
+ .ss-select {
887
+ height: 28px;
888
+ padding: 0 24px 0 8px;
889
+ border: .5px solid var(--line-2);
890
+ border-radius: var(--r2);
891
+ background-color: var(--s1);
892
+ color: var(--t1);
893
+ font: inherit;
894
+ font-size: 12px;
895
+ appearance: none;
896
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'><path d='m4.5 6.5 3.5 3.5 3.5-3.5'/></svg>");
897
+ background-repeat: no-repeat;
898
+ background-position: right 7px center;
899
+ }
900
+ .ss-dialog-foot {
901
+ display: flex;
902
+ align-items: center;
903
+ gap: 8px;
904
+ padding: 14px 20px 16px;
905
+ border-top: .5px solid var(--line-1);
906
+ margin-top: 8px;
907
+ }
908
+ .ss-dialog-foot .ss-spacer { flex: 1; }
909
+
910
+ /* 设置视图 */
911
+ .ss-settings { flex: 1; min-height: 0; overflow: auto; padding: 18px 24px 28px; }
912
+ .ss-settings-in { max-width: 720px; }
913
+ .ss-set-hd { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; }
914
+ .ss-set-hd h2 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; }
915
+ .ss-set-sub { margin: 0 0 18px; color: var(--t3); font-size: 12.5px; line-height: 20px; }
916
+ .ss-set-sec { margin: 0 0 20px; }
917
+ .ss-set-sec > h3 {
918
+ margin: 0 0 6px;
919
+ color: var(--t3);
920
+ font-size: 11px;
921
+ line-height: 16px;
922
+ font-weight: 600;
923
+ letter-spacing: .05em;
924
+ }
925
+ .ss-set-card { border: .5px solid var(--line-1); border-radius: var(--r3); background: var(--s1); overflow: hidden; }
926
+ .ss-field { display: flex; align-items: center; gap: 16px; padding: 11px 14px; }
927
+ .ss-field + .ss-field { border-top: .5px solid var(--line-1); }
928
+ .ss-field-text { flex: 1; min-width: 0; }
929
+ .ss-field-label { font-size: 13px; line-height: 20px; }
930
+ .ss-field-note { color: var(--t3); font-size: 11.5px; line-height: 17px; margin-top: 1px; }
931
+ .ss-field-body { display: flex; align-items: center; gap: 6px; flex: none; }
932
+ .ss-field.stack { display: block; }
933
+ .ss-field.stack .ss-field-body { margin-top: 8px; }
934
+ .ss-field.stack .ss-field-body .ss-input { flex: 1; }
935
+ .ss-switch {
936
+ position: relative;
937
+ width: 36px; height: 22px;
938
+ flex: none;
939
+ padding: 0;
940
+ border: 0;
941
+ border-radius: 99px;
942
+ background: var(--line-3);
943
+ cursor: pointer;
944
+ transition: background-color var(--dur) var(--ease);
945
+ }
946
+ .ss-switch::after {
947
+ content: "";
948
+ position: absolute;
949
+ top: 2px; left: 2px;
950
+ width: 18px; height: 18px;
951
+ border-radius: 50%;
952
+ background: #fff;
953
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
954
+ transition: transform 180ms var(--ease);
955
+ }
956
+ .ss-switch[aria-checked="true"] { background: var(--brand); }
957
+ .ss-switch[aria-checked="true"]::after { transform: translateX(14px); }
958
+
959
+ .ss-adv-toggle {
960
+ display: flex;
961
+ align-items: center;
962
+ gap: 6px;
963
+ width: 100%;
964
+ padding: 10px 14px;
965
+ border: 0;
966
+ border-top: .5px solid var(--line-1);
967
+ background: transparent;
968
+ color: var(--t2);
969
+ font: inherit;
970
+ font-size: 12.5px;
971
+ cursor: pointer;
972
+ text-align: left;
973
+ }
974
+ .ss-adv-toggle:hover { background: var(--hover); }
975
+ .ss-adv-toggle .i { transition: transform 160ms var(--ease); }
976
+ .ss-adv-toggle[aria-expanded="true"] .i { transform: rotate(90deg); }
977
+
978
+ .ss-token-list { display: flex; flex-wrap: wrap; gap: 5px; }
979
+ .ss-token {
980
+ display: inline-flex;
981
+ align-items: center;
982
+ gap: 5px;
983
+ height: 24px;
984
+ padding: 0 4px 0 8px;
985
+ border: .5px solid var(--line-2);
986
+ border-radius: var(--r1);
987
+ background: var(--s2);
988
+ color: var(--t2);
989
+ font: 11px/1 var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
990
+ }
991
+ .ss-token .ss-icon { width: 16px; height: 16px; }
992
+
993
+ /* 页脚 */
994
+ .ss-foot {
995
+ display: flex;
996
+ align-items: center;
997
+ gap: 7px;
998
+ flex-wrap: wrap;
999
+ padding: 8px 2px 10px;
1000
+ color: var(--t3);
1001
+ font-size: 11px;
1002
+ line-height: 16px;
1003
+ }
1004
+ .ss-foot code { font-size: 10.5px; }
1005
+ .ss-foot .sep { color: var(--line-3); }
1006
+ .ss-foot a { color: var(--t2); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; }
1007
+ .ss-foot a:hover { color: var(--t1); }
1008
+ .ss-foot .warn { color: var(--warn); }
1009
+ .ss-foot .ss-spacer { flex: 1; }
1010
+
1011
+ /* Toast */
1012
+ .ss-toast {
1013
+ position: absolute;
1014
+ left: 50%;
1015
+ bottom: 18px;
1016
+ z-index: 60;
1017
+ transform: translateX(-50%);
1018
+ display: flex;
1019
+ align-items: center;
1020
+ gap: 7px;
1021
+ height: 32px;
1022
+ padding: 0 12px;
1023
+ border: .5px solid var(--line-2);
1024
+ border-radius: 99px;
1025
+ background: var(--s3);
1026
+ box-shadow: var(--shadow-pop);
1027
+ color: var(--t1);
1028
+ font-size: 12px;
1029
+ white-space: nowrap;
1030
+ }
1031
+ .ss-toast .i { color: var(--ok); }
1032
+
1033
+ /* 拖拽层(逻辑沿用旧实现,只换 token) */
1034
+ .ss-row.ss-dragging { opacity: .45; }
1035
+ .ss-dragghost {
1036
+ position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none;
1037
+ display: flex; align-items: center; gap: 10px; max-width: 320px;
1038
+ padding: 8px 12px; z-index: 80;
1039
+ border: .5px solid var(--line-3); border-radius: var(--r3);
1040
+ background: var(--s1); color: var(--t1);
1041
+ font-size: 13px; line-height: 20px;
1042
+ box-shadow: var(--shadow-pop);
1043
+ will-change: transform;
1044
+ }
1045
+ .ss-dragghost[data-show] { opacity: .96; }
1046
+ .ss-dragghost-main { display: flex; flex-direction: column; min-width: 0; }
1047
+ .ss-dragghost-title { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; line-height: 18px; }
1048
+ .ss-dragghost-sub { color: var(--t3); font-size: 11px; line-height: 15px; }
1049
+ .ss-dragghost-badge { margin-left: auto; flex: none; padding: 1px 7px; border-radius: 999px; background: var(--hover-2); color: var(--t2); font-size: 11px; line-height: 16px; font-variant-numeric: tabular-nums; }
1050
+ .ss-dnd-tip {
1051
+ position: fixed; z-index: 80; pointer-events: none;
1052
+ display: inline-flex; align-items: center; gap: 8px;
1053
+ max-width: min(460px, 84vw); padding: 6px 10px 6px 8px;
1054
+ border: .5px solid var(--line-2); border-radius: 999px;
1055
+ background: color-mix(in srgb, var(--s1) 92%, transparent);
1056
+ color: var(--t1); font-size: 12px; line-height: 18px;
1057
+ box-shadow: var(--shadow-pop); backdrop-filter: blur(10px);
1058
+ opacity: 0; transform: translateY(-2px) scale(.98);
1059
+ transition: opacity .12s var(--ease), transform .12s var(--ease);
1060
+ }
1061
+ .ss-dnd-tip[data-show] { opacity: 1; transform: none; }
1062
+ .ss-dnd-tip.ws { border-color: var(--brand-line); }
1063
+ .ss-dnd-tip.blocked .ss-dnd-ico { color: var(--t3); }
1064
+ .ss-dnd-tip.done { background: var(--ok); border-color: transparent; color: var(--brand-ink); }
1065
+ .ss-dnd-ico { display: grid; place-items: center; width: 16px; height: 16px; flex: none; color: var(--brand); }
1066
+ .ss-dnd-tip.done .ss-dnd-ico, .ss-dnd-tip.done .ss-dnd-name { color: var(--brand-ink); }
1067
+ .ss-dnd-tip.done .ss-dnd-hint { color: color-mix(in srgb, var(--brand-ink) 72%, transparent); border-left-color: color-mix(in srgb, var(--brand-ink) 24%, transparent); }
1068
+ .ss-dnd-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1069
+ .ss-dnd-hint { padding-left: 8px; border-left: .5px solid var(--line-2); color: var(--t3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1070
+ .ss-insert {
1071
+ position: fixed; height: 2px; border-radius: 2px; z-index: 61;
1072
+ background: var(--brand); opacity: 0; pointer-events: none;
1073
+ transition: opacity 120ms var(--ease), top 90ms var(--ease);
1074
+ }
1075
+ .ss-insert[data-show="1"] { opacity: 1; box-shadow: 0 0 0 1px rgba(0, 0, 0, .2); }
1076
+ .ss-ws-over { background: var(--hover) !important; box-shadow: inset 0 0 0 1.5px var(--brand); border-radius: var(--r2); transition: background-color .12s ease; }
1077
+ .ss-ws-member { position: relative; background: color-mix(in srgb, var(--brand) 10%, transparent) !important; transition: background-color .12s ease; }
1078
+ .ss-ws-member::before { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 2px; border-radius: 2px; background: var(--brand); opacity: .75; pointer-events: none; }
1079
+
1080
+
1081
+ /* 诊断 */
1082
+ .ss-diag {
1083
+ margin: 10px 14px 14px;
1084
+ padding: 12px;
1085
+ border: .5px solid var(--line-1);
1086
+ border-radius: var(--r2);
1087
+ background: var(--canvas);
1088
+ color: var(--t2);
1089
+ font: 11px/18px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
1090
+ white-space: pre-wrap;
1091
+ word-break: break-all;
1092
+ }
1093
+
1094
+
1095
+ .ss-root .ss-page { flex: 1; min-height: 0; height: 100%; }
1096
+ .ss-root.settings-only { height: auto; min-height: 0; overflow: visible; }
1097
+ .ss-root.settings-only .ss-page { height: auto; min-height: 0; }
1098
+ .ss-root.settings-only .ss-settings { overflow: visible; }
1099
+ .ss-notice {
1100
+ display: flex; align-items: center; gap: 7px;
1101
+ margin: 0 2px; padding: 7px 10px;
1102
+ border-radius: var(--r2);
1103
+ border: .5px solid var(--line-1);
1104
+ background: var(--s2);
1105
+ color: var(--t2);
1106
+ font-size: 12px; line-height: 18px;
1107
+ }
1108
+ .ss-notice.error { color: var(--bad); border-color: var(--bad-line); background: var(--bad-weak); }
1109
+ .ss-notice.ok { color: var(--ok); border-color: var(--ok-line); background: var(--ok-weak); }
1110
+ .ss-dev.over { background: var(--brand-weak); box-shadow: inset 0 0 0 1px var(--brand-line); }
1111
+ .ss-hint { color: var(--t3); font-size: 11.5px; line-height: 17px; }
1112
+ .ss-host-page { height: 100%; min-height: 0; }
1113
+ @media (max-width: 860px) {
1114
+ .ss-desk { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
1115
+ .ss-rail { display: flex; max-height: 220px; }
1116
+ }
1117
+ @media (max-width: 980px) {
1118
+ .ss-settings.cols .ss-settings-in { grid-template-columns: minmax(0, 1fr); }
1119
+ }
1120
+
1121
+ /* 新壳补充 */
1122
+ .ss-menu {
1123
+ position: absolute; right: 0; top: calc(100% + 6px); z-index: 40;
1124
+ min-width: 190px; padding: 6px;
1125
+ border: .5px solid var(--line-2); border-radius: var(--r3);
1126
+ background: var(--s3); box-shadow: var(--shadow-pop);
1127
+ display: flex; flex-direction: column; gap: 2px;
1128
+ }
1129
+ .ss-menu-item {
1130
+ display: flex; align-items: center; gap: 8px;
1131
+ padding: 7px 9px; border: 0; border-radius: var(--r2);
1132
+ background: transparent; color: var(--t2);
1133
+ font: inherit; font-size: 12.5px; line-height: 18px;
1134
+ text-align: left; cursor: pointer; white-space: nowrap;
1135
+ }
1136
+ .ss-menu-item:hover { background: var(--hover-2); color: var(--t1); }
1137
+ .ss-menu-item.danger { color: var(--bad); }
1138
+ .ss-menu-item.danger:hover { background: var(--bad-weak); }
1139
+ .ss-menu-wrap { position: relative; display: flex; }
1140
+ .ss-inline { display: flex; align-items: center; gap: 8px; }
1141
+ .ss-target-wrap { position: relative; display: flex; }
1142
+ .ss-target-wrap .ss-popover { left: 0; bottom: calc(100% + 8px); }
1143
+ .ss-empty-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1144
+ .ss-steps { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; text-align: left; }
1145
+ .ss-step { display: flex; gap: 8px; align-items: flex-start; color: var(--t3); font-size: 12px; line-height: 18px; }
1146
+ .ss-step b {
1147
+ display: grid; place-items: center; width: 16px; height: 16px; flex: none;
1148
+ margin-top: 1px; border-radius: 50%; background: var(--hover-2);
1149
+ color: var(--t2); font: 10px/1 var(--ds-font-family-code, ui-monospace, monospace);
1150
+ }
1151
+ .ss-banner {
1152
+ display: flex; align-items: center; gap: 7px;
1153
+ margin: 0 16px 10px; padding: 7px 10px;
1154
+ border: .5px solid var(--warn-line); border-radius: var(--r2);
1155
+ background: var(--warn-weak); color: var(--warn);
1156
+ font-size: 11.5px; line-height: 17px;
1157
+ }
1158
+ .ss-error {
1159
+ display: flex; align-items: flex-start; gap: 8px;
1160
+ margin: 10px 16px; padding: 10px 12px;
1161
+ border: .5px solid var(--bad-line); border-radius: var(--r2);
1162
+ background: var(--bad-weak); color: var(--bad);
1163
+ font-size: 12px; line-height: 18px;
1164
+ }
1165
+ .ss-error .grow { flex: 1; min-width: 0; }
1166
+ .ss-error code { font-size: 11px; }
1167
+ .ss-sk { height: 8px; border-radius: 4px; background: var(--hover-2); animation: ss-pulse 1.5s ease-in-out infinite; }
1168
+ @keyframes ss-pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
1169
+ .ss-empty-inline {
1170
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1171
+ gap: 6px; flex: 1; padding: 28px 16px; text-align: center;
1172
+ color: var(--t3); font-size: 12px; line-height: 18px;
1173
+ }
1174
+ .ss-empty-inline b { color: var(--t1); font-weight: 550; font-size: 12.5px; }
1175
+ .ss-set-view-hd { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
1176
+ .ss-set-view-hd h2 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 600; }
1177
+ .ss-set-view-hd span { color: var(--t3); font-size: 11.5px; }
1178
+ .ss-settings.cols .ss-settings-in {
1179
+ max-width: none; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
1180
+ gap: 20px 24px; align-items: start;
1181
+ }
1182
+ .ss-set-col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
1183
+ .ss-settings.cols .ss-set-sec { margin: 0; }
1184
+ .ss-settings:not(.cols) .ss-settings-in { display: flex; flex-direction: column; gap: 20px; }
1185
+ .ss-dialog-note { display: flex; align-items: center; gap: 6px; padding: 10px 20px 0; color: var(--t3); font-size: 11px; line-height: 16px; }
1186
+ .ss-plan-fresh { color: var(--t3); font-size: 11px; white-space: nowrap; }
1187
+ .ss-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1188
+ .ss-note-row { display: flex; align-items: center; gap: 6px; color: var(--t3); font-size: 11.5px; line-height: 17px; padding: 0 16px 8px; }
1189
+
1190
+ `
1191
+
1192
+ const STYLE_ID = 'dsh-session-sync/client.css'
1193
+ if (typeof document !== 'undefined') {
1194
+ const existing = document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_ID)}]`)
1195
+ if (existing === null) {
1196
+ const tag = document.createElement('style')
1197
+ tag.dataset.plugin = 'dsh-session-sync'
1198
+ tag.dataset.pluginCss = STYLE_ID
1199
+ tag.textContent = CSS
1200
+ document.head.appendChild(tag)
1201
+ }
1202
+ }
1203
+
1204
+ const DICTIONARIES = {
1205
+ zh: {
1206
+ 'sessions.search': '搜索会话…',
1207
+ 'sessions.filterAll': '全部 {n}',
1208
+ 'sessions.filterNew': '未拉取 {n}',
1209
+ 'sessions.filterDone': '已拉取 {n}',
1210
+ 'sessions.sortTime': '按时间',
1211
+ 'sessions.sortTitle': '按标题',
1212
+ 'sessions.notImported': '未拉取',
1213
+ 'sessions.emptyFiltered': '没有匹配的会话。',
1214
+ 'sessions.focusHint': '↑↓ 移动 · 空格选择 · 回车拉取',
1215
+ 'device.menu': '更多操作',
1216
+ 'device.remove': '移除',
1217
+ 'device.copyAddress': '复制局域网地址',
1218
+ 'device.refresh': '刷新会话列表',
1219
+ 'device.pushHere': '推送本机会话…',
1220
+ 'device.sessionsCount': '{n} 个会话',
1221
+ 'notice.addressCopied': '局域网地址已复制',
1222
+ 'notice.addressCopyFailed': '复制失败,请手动选择地址',
1223
+ 'settings.advanced': '网络与高级',
1224
+ 'settings.repair': '修复历史会话',
1225
+ 'settings.repairHint': '早期版本拉取过的会话可能带上对方的继承标记,打开时会报「inherited end-seed marker」。这里会扫描并修复,原文件保留为 .bak。',
1226
+ 'settings.repairAction': '扫描并修复',
1227
+ 'notice.repairDone': '已扫描 {scanned} 个会话,修复 {repaired} 个',
1228
+ 'notice.repairNone': '没有需要修复的会话',
1229
+ 'settings.advancedHint': '端口 · 手动地址 · 跨网段扫描 · 种子地址',
1230
+ 'settings.ports': '端口',
1231
+ 'devices.emptyTitle': '让另一台设备也安装这个插件',
1232
+ 'devices.emptyBody': '同一局域网内会自动发现;跨网段或 VPN 用手动地址。',
1233
+ 'devices.copyAddress': '复制本机地址',
1234
+ 'devices.step1': '另一台设备安装并启用「会话同步」',
1235
+ 'devices.step2': '确认两台设备在同一网段',
1236
+ 'devices.step3': '仍未发现就填下面的手动地址',
1237
+ 'devices.manualAdd': '添加',
1238
+ 'devices.manualPlaceholder': '手动地址,如 10.0.0.8:47801',
1239
+ 'devices.copyFail': '复制失败,请手动选择地址',
1240
+ 'queue.title': '传输队列',
1241
+ 'queue.open': '展开',
1242
+ 'queue.close': '收起',
1243
+ 'queue.idle': '暂无传输',
1244
+ 'page.title': '会话同步',
1245
+ 'nav.sessionSync': '会话同步',
1246
+ 'sessions.verify': '校验',
1247
+ 'sessions.verifying': '正在校验…',
1248
+ 'sessions.verified': 'sha256 {hash} 校验通过',
1249
+ 'sessions.copyHash': '复制完整摘要',
1250
+ 'notice.digestCopied': '完整 sha256 已复制',
1251
+ 'notice.digestCopyFailed': '复制失败,请手动选择摘要',
1252
+ 'sessions.hashHint': '摘要只证明两端内容一致,不证明对方身份;需要带外核对时复制完整摘要比对。',
1253
+ 'sessions.verifyFailed': 'sha256 {hash} 不匹配',
1254
+ 'sessions.noDigest': 'sha256 {hash}(对端未提供摘要)',
1255
+ 'page.subtitle': '局域网内 {online}/{total} 台设备在线',
1256
+ 'page.onlineCount': '在线 {n} 台',
1257
+ 'page.seenCount': '已发现 {found} 台 · 曾连接 {recalled} 台',
1258
+ 'page.ports': '同步端口 {lan} · 发现端口 {discovery}',
1259
+ 'page.apiError': '读取状态失败:{message}',
1260
+ 'page.noPersistence': '当前会话存储不可用,拉取/推送会失败。',
1261
+ 'self.title': '本机',
1262
+ 'self.name': '设备名称',
1263
+ 'self.hint': '这个名称会随发现广播发给局域网内的其他设备,只影响别人看到的名字。',
1264
+ 'devices.tabPeer': '对端会话',
1265
+ 'devices.tabLocal': '本机会话',
1266
+ 'devices.selectHint': '选择左侧设备查看它的会话',
1267
+ 'devices.title': '设备',
1268
+ 'devices.count': '已发现 {found} 台 · 曾连接 {recalled} 台',
1269
+ 'diag.title': '界面出错了',
1270
+ 'diag.body': '页面渲染时抛了异常,已在此展示诊断信息,而不是留一个空白面板。',
1271
+ 'diag.hint': '把下面的内容发到 GitHub issue 即可;点「重新加载」可以先恢复界面。',
1272
+ 'diag.reload': '重新加载',
1273
+ 'diag.copy': '复制诊断',
1274
+ 'devices.empty': '暂未发现其他设备。请确认另一台机器的 DSH 也安装并启用了本插件,且处在同一个局域网。',
1275
+ 'devices.hint': '自定义命名只保存在本机,设备离线或重启后再次上线时仍会显示你起的名字。',
1276
+ 'devices.moreHint': '列表展示全部已发现或曾连接过的设备;其他网段可在「网络与高级」里加网段扫描。',
1277
+ 'device.rename': '命名',
1278
+ 'device.renamed': '自定义',
1279
+ 'device.pulled': '已拉取 {n}',
1280
+ 'device.forget': '移除',
1281
+ 'device.namePlaceholder': '给这台设备起个名字',
1282
+ 'device.lastSeen': '最近 {when}',
1283
+ 'device.offlineHint': '设备离线时只能查看,不能拉取会话',
1284
+ 'status.online': '在线',
1285
+ 'status.offline': '离线',
1286
+ 'sessions.show': '查看会话',
1287
+ 'sessions.hide': '收起会话',
1288
+ 'sessions.push': '推送会话…',
1289
+ 'sessions.pushShort': '推送',
1290
+ 'sessions.empty': '这台设备没有可同步的会话。',
1291
+ 'sessions.untitled': '未命名会话',
1292
+ 'sessions.events': '{n} 条事件',
1293
+ 'sessions.pull': '拉取到本机',
1294
+ 'sessions.pullShort': '拉取',
1295
+ 'sessions.imported': '已拉取',
1296
+ 'settings.title': '同步设置',
1297
+ 'settings.short': '设置',
1298
+ 'page.onlineShort': '台在线',
1299
+ 'page.seenShort': '台已发现',
1300
+ 'settings.syncDir': '自动同步目录',
1301
+ 'settings.syncDirHint': '自动拉取的会话存到哪个目录。默认「未分组」:不归任何工作区;也可以选一个已有工作区,或填一个文件夹路径(不存在会自动创建)。',
1302
+ 'settings.syncUnsorted': '未分组(不归档到工作区)',
1303
+ 'settings.syncCustom': '自定义目录',
1304
+ 'settings.syncCustomPlaceholder': '/Users/you/synced-sessions',
1305
+ 'settings.syncDirSave': '保存目录',
1306
+ 'settings.autoPull': '自动拉取',
1307
+ 'settings.concurrency': '同时传输',
1308
+ 'settings.concurrencyHint': '一次同时传输多少个会话。自动按 CPU 核数估算,封顶 5 个;手动 1–16 会覆盖自动值。',
1309
+ 'settings.concurrencyAuto': '自动(本机 {n} 个,封顶 5)',
1310
+ 'settings.concurrencyManual': '手动并发',
1311
+ 'notice.concurrencySaved': '已更新同时传输数量',
1312
+ 'settings.autoPullLabel': '自动拉取在线设备的新会话',
1313
+ 'settings.autoPullHint': '开启后每隔 20 秒检查一次在线设备,把还没拉过的会话自动同步到本机。',
1314
+ 'settings.seeds': '手动设备地址',
1315
+ 'settings.seedsHint': '广播不可用的网络(如跨网段、VPN)可以在这里填写对端 IP:UDP 端口,一行一个。',
1316
+ 'settings.language': '界面语言',
1317
+ 'settings.languageAuto': '跟随 DSH / 系统语言',
1318
+ 'settings.languageHint': '默认跟随 DSH 的语言设置;中文系统显示中文,其它系统显示 English。',
1319
+ 'notice.languageSaved': '界面语言已切换',
1320
+ 'notice.selfNameSaved': '本机名称已保存',
1321
+ 'notice.seedsSaved': '设备地址已保存',
1322
+ 'notice.syncDirSaved': '同步目录已保存',
1323
+ 'notice.settingsSaved': '设置已保存',
1324
+ 'notice.renamed': '命名已保存',
1325
+ 'notice.forgot': '已移除该设备记录',
1326
+ 'notice.pulled': '会话已拉取到本机',
1327
+ 'notice.importCleared': '已清除拉取记录',
1328
+ 'notice.pushed': '会话已推送到对端',
1329
+ 'notice.refreshed': '已刷新',
1330
+ 'common.save': '保存',
1331
+ 'common.cancel': '取消',
1332
+ 'common.refresh': '刷新',
1333
+ 'common.clear': '清除',
1334
+ 'common.loading': '加载中…',
1335
+ 'time.now': '刚刚',
1336
+ 'time.never': '从未',
1337
+ 'time.seconds': '{n} 秒前',
1338
+ 'time.minutes': '{n} 分钟前',
1339
+ 'time.hours': '{n} 小时前',
1340
+ 'time.days': '{n} 天前',
1341
+ 'sessions.selectAll': '全选',
1342
+ 'sessions.selected': '已选 {n} 个',
1343
+ 'sessions.syncSelected': '同步选中',
1344
+ 'sessions.pushSelected': '推送选中',
1345
+ 'sessions.clearSelection': '清除选择',
1346
+ 'dnd.dropInto': '放入「{name}」',
1347
+ 'dnd.intoGroup': '拉取到此分组',
1348
+ 'dnd.intoGroupArea': '整组可放 · 会话将出现在此分组',
1349
+ 'dnd.insertHere': '插入到此位置',
1350
+ 'dnd.intoUnsorted': '放入插件目录,不归档到工作区',
1351
+ 'dnd.blocked': '无法放入此处',
1352
+ 'dnd.blockedHint': '请拖到左侧工作区',
1353
+ 'dnd.selected': '已选定 {name}',
1354
+ 'dnd.preparing': '正在准备确认…',
1355
+ 'dnd.title': '拖到工作区',
1356
+ 'dnd.ghostPull': '拉取 · {count} 个会话',
1357
+ 'dnd.ghostPush': '推送 · {count} 个会话',
1358
+ 'dnd.hint': '松开后确认要同步的会话;目录不存在会自动创建。',
1359
+ 'sessions.dropHint': '拖到设备卡片上推送',
1360
+ 'device.pullAll': '拉取全部',
1361
+ 'device.pushAll': '一键推送',
1362
+ 'workspaces.title': '本机工作区',
1363
+ 'workspaces.hint': '把对端会话拖到某个目录上松手,确认后就会拉取到该目录;也可以把本机会话拖到设备卡片上推送。',
1364
+ 'workspaces.count': '{n} 个会话',
1365
+ 'workspaces.add': '添加路径',
1366
+ 'workspaces.placeholder': '/Users/you/project',
1367
+ 'plan.titlePull': '确认拉取到 {dir}',
1368
+ 'plan.titlePush': '确认推送到 {peer}',
1369
+ 'plan.dirAuto': '会话原目录',
1370
+ 'plan.summary': '共 {n} 个:{fresh} 个待传,{dup} 个已存在',
1371
+ 'plan.dupNote': '已存在的会话(按会话 id 判断)默认保留现有版本;选「覆盖」会新建一份副本,旧副本不动。',
1372
+ 'plan.keep': '保留现有',
1373
+ 'plan.skip': '跳过此条',
1374
+ 'plan.copy': '覆盖为新副本',
1375
+ 'plan.skipAll': '跳过全部已存在',
1376
+ 'plan.confirm': '开始传输',
1377
+ 'queue.title': '传输队列',
1378
+ 'queue.pending': '排队中',
1379
+ 'error.timeout': '请求超时',
1380
+ 'error.offline': '无法连接插件服务:{message}',
1381
+ 'error.badResponse': '服务返回了无法解析的数据({status})',
1382
+ 'error.status': '服务返回 {status}',
1383
+ 'plan.fresh': '待传输',
1384
+ 'queue.running': '传输中',
1385
+ 'queue.done': '已完成',
1386
+ 'queue.skipped': '已存在,跳过',
1387
+ 'queue.failed': '失败',
1388
+ 'queue.clear': '清空',
1389
+ 'queue.retry': '重试',
1390
+ 'queue.summary': '{done} 完成 · {skipped} 跳过 · {failed} 失败',
1391
+ 'queue.overall': '{done}/{total} 已完成(含跳过)',
1392
+ 'queue.duration': '耗时 {seconds}s',
1393
+ 'queue.empty': '还没有传输任务:选中会话后点「同步选中」,或直接拖到设备 / 侧栏工作区。',
1394
+ 'notice.queued': '已加入传输队列',
1395
+ 'notice.subnetsSaved': '跨网段扫描目标已保存',
1396
+ 'settings.subnets': '跨网段扫描',
1397
+ 'settings.subnetsHint': '广播穿不过路由器。填写对端所在网段(如 192.168.5.0/24)、单个 IP(如 192.168.5.20)或 IP:端口(如 192.168.5.20:47802),一行一个。网段走 UDP 发现包;单个 IP 额外用 HTTP 探测 47802–47817,UDP 被防火墙挡住也能连上。',
1398
+ 'settings.probeStatus': 'HTTP 探测:{at} 检查 {n} 个目标,答复 {found} 台(UDP 不通时兜底)',
1399
+ 'footer.version': 'v{version}',
1400
+ 'footer.hostVersion': '宿主 v{version}',
1401
+ 'footer.hostStale': '宿主仍在运行旧版本,重启 DSH 后生效',
1402
+ 'footer.license': 'MIT 协议 · 开源',
1403
+ 'footer.repo': 'GitHub 仓库',
1404
+ 'footer.star': '如果这个插件帮到了你,欢迎在 GitHub 点个 Star ⭐',
1405
+ 'common.add': '添加',
1406
+ 'devices.addAddress': '添加地址',
1407
+ 'empty.title': '还没有发现其他设备',
1408
+ 'empty.body': '在同一局域网的机器上装好「会话同步」,它会自己出现;跨网段或防火墙挡住广播时,用下面的地址手动连。',
1409
+ 'settings.unsortedShort': '未分组(默认)',
1410
+ 'sessions.verifyFailedShort': '校验失败',
1411
+ 'sessions.synced': '已同步',
1412
+ 'sessions.notSynced': '未同步',
1413
+ 'sessions.pulling': '拉取中',
1414
+ 'sessions.queued': '排队中',
1415
+ 'settings.current': '当前',
1416
+ 'device.sessionsUnit': '个会话',
1417
+ 'dir.pull': '拉取',
1418
+ 'dir.push': '推送',
1419
+ 'stage.pushHint': '把本机会话发送到「{peer}」',
1420
+ 'sessions.emptyBody': '这台设备还没有会话。',
1421
+ 'sessions.selectedUnit': '项已选',
1422
+ 'sessions.pullTo': '拉取到',
1423
+ 'target.title': '选择目标目录',
1424
+ 'target.count': '{n} 个位置',
1425
+ 'target.note': '也可以直接把会话拖到宿主侧栏的工作区。',
1426
+ 'settings.identity': '身份',
1427
+ 'common.copy': '复制',
1428
+ 'settings.address': '局域网地址',
1429
+ 'settings.version': '版本',
1430
+ 'settings.hostVersion': '宿主版本',
1431
+ 'settings.platform': '平台',
1432
+ 'settings.transfer': '传输',
1433
+ 'settings.about': '关于',
1434
+ 'common.open': '打开',
1435
+ 'settings.intro': '局域网内的设备与会话同步。',
1436
+ 'settings.backToDesk': '返回会话台',
1437
+ },
1438
+ en: {
1439
+ 'sessions.search': 'Search sessions…',
1440
+ 'sessions.filterAll': 'All {n}',
1441
+ 'sessions.filterNew': 'New {n}',
1442
+ 'sessions.filterDone': 'Pulled {n}',
1443
+ 'sessions.sortTime': 'By time',
1444
+ 'sessions.sortTitle': 'By title',
1445
+ 'sessions.notImported': 'Not pulled',
1446
+ 'sessions.emptyFiltered': 'No session matches this filter.',
1447
+ 'sessions.focusHint': '↑↓ move · space select · enter pull',
1448
+ 'device.menu': 'More actions',
1449
+ 'device.remove': 'Remove',
1450
+ 'device.copyAddress': 'Copy LAN address',
1451
+ 'device.refresh': 'Reload session list',
1452
+ 'device.pushHere': 'Push local sessions…',
1453
+ 'device.sessionsCount': '{n} sessions',
1454
+ 'notice.addressCopied': 'LAN address copied',
1455
+ 'notice.addressCopyFailed': 'Copy failed — select the address manually',
1456
+ 'settings.advanced': 'Network & advanced',
1457
+ 'settings.repair': 'Repair old sessions',
1458
+ 'settings.repairHint': "Sessions pulled by an early build can carry the peer's inherited marker and fail to open with \"inherited end-seed marker\". This scans and repairs them, keeping a .bak copy.",
1459
+ 'settings.repairAction': 'Scan and repair',
1460
+ 'notice.repairDone': 'Scanned {scanned} sessions, repaired {repaired}',
1461
+ 'notice.repairNone': 'Nothing needed repairing',
1462
+ 'settings.advancedHint': 'Ports · manual addresses · subnet scan · seeds',
1463
+ 'settings.ports': 'Ports',
1464
+ 'devices.emptyTitle': 'Install this plugin on another device',
1465
+ 'devices.emptyBody': 'Devices on the same LAN discover each other automatically; use a manual address across subnets or VPN.',
1466
+ 'devices.copyAddress': 'Copy local address',
1467
+ 'devices.step1': 'Install and enable Session Sync there',
1468
+ 'devices.step2': 'Make sure both devices share a subnet',
1469
+ 'devices.step3': 'Still nothing? Add a manual address below',
1470
+ 'devices.manualAdd': 'Add',
1471
+ 'devices.manualPlaceholder': 'Manual address, e.g. 10.0.0.8:47801',
1472
+ 'devices.copyFail': 'Copy failed — select the address manually',
1473
+ 'queue.title': 'Transfer queue',
1474
+ 'queue.open': 'Open',
1475
+ 'queue.close': 'Close',
1476
+ 'queue.idle': 'Idle',
1477
+ 'page.title': 'Session Sync',
1478
+ 'nav.sessionSync': 'Session Sync',
1479
+ 'sessions.verify': 'Verify',
1480
+ 'sessions.verifying': 'Verifying…',
1481
+ 'sessions.verified': 'sha256 {hash} verified',
1482
+ 'sessions.copyHash': 'Copy full digest',
1483
+ 'notice.digestCopied': 'Full sha256 copied',
1484
+ 'notice.digestCopyFailed': 'Copy failed — select the digest manually',
1485
+ 'sessions.hashHint': 'A digest proves the bytes match, not who sent them. Copy the full hash when you need an out-of-band check.',
1486
+ 'sessions.verifyFailed': 'sha256 {hash} mismatch',
1487
+ 'sessions.noDigest': 'sha256 {hash} (no digest from peer)',
1488
+ 'page.subtitle': '{online}/{total} devices online on the LAN',
1489
+ 'page.onlineCount': '{n} online',
1490
+ 'page.seenCount': '{found} discovered · {recalled} seen',
1491
+ 'page.ports': 'sync port {lan} · discovery port {discovery}',
1492
+ 'page.apiError': 'Failed to read state: {message}',
1493
+ 'page.noPersistence': 'The session store is unavailable; pull/push will fail.',
1494
+ 'self.title': 'This machine',
1495
+ 'self.name': 'Device name',
1496
+ 'self.hint': 'Advertised to other devices on the LAN — it only changes how they see you.',
1497
+ 'devices.tabPeer': 'Remote sessions',
1498
+ 'devices.tabLocal': 'This device',
1499
+ 'devices.selectHint': 'Pick a device on the left to see its sessions',
1500
+ 'devices.title': 'Devices',
1501
+ 'devices.count': '{found} discovered · {recalled} seen',
1502
+ 'diag.title': 'The page hit an error',
1503
+ 'diag.body': 'Rendering threw. The details are shown here instead of leaving a blank panel.',
1504
+ 'diag.hint': 'Paste this into a GitHub issue; “Reload” usually brings the UI back.',
1505
+ 'diag.reload': 'Reload',
1506
+ 'diag.copy': 'Copy diagnostics',
1507
+ 'devices.empty': 'No other device found yet. Make sure DSH on the other machine has this plugin enabled and is on the same LAN.',
1508
+ 'devices.hint': 'Custom names are stored locally and survive a device going offline and coming back.',
1509
+ 'devices.moreHint': 'Every discovered or previously seen device is listed; add other subnets under “Network & advanced” to scan them.',
1510
+ 'device.rename': 'Rename',
1511
+ 'device.renamed': 'custom',
1512
+ 'device.pulled': '{n} pulled',
1513
+ 'device.forget': 'Forget',
1514
+ 'device.namePlaceholder': 'Name this device',
1515
+ 'device.lastSeen': 'seen {when}',
1516
+ 'device.offlineHint': 'Offline — view only',
1517
+ 'status.online': 'online',
1518
+ 'status.offline': 'offline',
1519
+ 'sessions.show': 'Sessions',
1520
+ 'sessions.hide': 'Hide sessions',
1521
+ 'sessions.push': 'Push a session…',
1522
+ 'sessions.pushShort': 'Push',
1523
+ 'sessions.empty': 'No sessions to sync on this device.',
1524
+ 'sessions.untitled': 'Untitled session',
1525
+ 'sessions.events': '{n} events',
1526
+ 'sessions.pull': 'Pull here',
1527
+ 'sessions.pullShort': 'Pull',
1528
+ 'sessions.imported': 'pulled',
1529
+ 'settings.title': 'Sync settings',
1530
+ 'settings.short': 'Settings',
1531
+ 'page.onlineShort': 'online',
1532
+ 'page.seenShort': 'discovered',
1533
+ 'settings.syncDir': 'Auto-sync folder',
1534
+ 'settings.syncDirHint': 'Where auto-pulled sessions land. “Ungrouped” keeps them outside every workspace; pick an existing workspace, or type a folder path (created on save).',
1535
+ 'settings.syncUnsorted': 'Ungrouped (no workspace)',
1536
+ 'settings.syncCustom': 'Custom folder',
1537
+ 'settings.syncCustomPlaceholder': '/Users/you/synced-sessions',
1538
+ 'settings.syncDirSave': 'Save folder',
1539
+ 'dnd.title': 'Drop into a workspace',
1540
+ 'dnd.ghostPull': 'Pull · {count} sessions',
1541
+ 'dnd.ghostPush': 'Push · {count} sessions',
1542
+ 'dnd.intoGroup': 'Pull into this group',
1543
+ 'dnd.intoGroupArea': 'Whole group accepts the drop',
1544
+ 'dnd.intoUnsorted': 'Goes to the plugin folder, stays ungrouped',
1545
+ 'dnd.insertHere': 'Insert right here',
1546
+ 'dnd.blocked': 'Cannot drop here',
1547
+ 'dnd.blockedHint': 'Drag onto the sidebar',
1548
+ 'dnd.hint': 'Release, then confirm the sessions; missing folders are created.',
1549
+ 'dnd.selected': 'Target: {name}',
1550
+ 'dnd.preparing': 'Preparing the confirmation…',
1551
+ 'notice.syncDirSaved': 'Sync folder saved',
1552
+ 'settings.autoPull': 'Auto pull',
1553
+ 'settings.autoPullLabel': 'Auto-pull new sessions from online devices',
1554
+ 'settings.concurrency': 'Concurrent transfers',
1555
+ 'settings.concurrencyHint': 'How many sessions transfer at once. Auto estimates from the CPU count, capped at 5; a manual 1–16 value overrides it.',
1556
+ 'settings.concurrencyAuto': 'Auto ({n} here, max 5)',
1557
+ 'settings.concurrencyManual': 'Manual concurrency',
1558
+ 'notice.concurrencySaved': 'Concurrent transfers updated',
1559
+ 'settings.autoPullHint': 'Checks online devices every 20 seconds and pulls sessions you have not pulled yet.',
1560
+ 'settings.seeds': 'Manual peers',
1561
+ 'settings.seedsHint': 'For networks where broadcast fails (other subnets, VPN), list peer IP:UDP-port one per line.',
1562
+ 'settings.language': 'Language',
1563
+ 'settings.languageAuto': 'Follow DSH / system',
1564
+ 'settings.languageHint': 'Follows the DSH language setting by default; when DSH uses the system language, Chinese systems show Chinese and everything else shows English.',
1565
+ 'notice.languageSaved': 'Language switched',
1566
+ 'notice.selfNameSaved': 'Device name saved',
1567
+ 'notice.seedsSaved': 'Peer addresses saved',
1568
+ 'notice.settingsSaved': 'Settings saved',
1569
+ 'notice.renamed': 'Name saved',
1570
+ 'notice.forgot': 'Device record removed',
1571
+ 'notice.pulled': 'Session pulled',
1572
+ 'notice.importCleared': 'Pull record cleared',
1573
+ 'notice.pushed': 'Session pushed',
1574
+ 'notice.refreshed': 'Refreshed',
1575
+ 'common.save': 'Save',
1576
+ 'common.cancel': 'Cancel',
1577
+ 'common.refresh': 'Refresh',
1578
+ 'common.clear': 'Clear',
1579
+ 'common.loading': 'Loading…',
1580
+ 'time.now': 'just now',
1581
+ 'time.never': 'never',
1582
+ 'time.seconds': '{n}s ago',
1583
+ 'time.minutes': '{n}m ago',
1584
+ 'time.hours': '{n}h ago',
1585
+ 'time.days': '{n}d ago',
1586
+ 'sessions.selectAll': 'Select all',
1587
+ 'sessions.selected': '{n} selected',
1588
+ 'sessions.syncSelected': 'Pull selected',
1589
+ 'sessions.pushSelected': 'Push selected',
1590
+ 'sessions.clearSelection': 'Clear',
1591
+ 'dnd.dropInto': 'Drop into “{name}”',
1592
+ 'sessions.dropHint': 'Drag onto a device to push',
1593
+ 'device.pullAll': 'Pull all',
1594
+ 'device.pushAll': 'Push all',
1595
+ 'workspaces.title': 'Local workspaces',
1596
+ 'workspaces.hint': 'Drop peer sessions onto a folder to pull them there; drag local sessions onto a device card to push.',
1597
+ 'workspaces.count': '{n} sessions',
1598
+ 'workspaces.add': 'Add path',
1599
+ 'workspaces.placeholder': '/Users/you/project',
1600
+ 'plan.titlePull': 'Pull into {dir}',
1601
+ 'plan.titlePush': 'Push to {peer}',
1602
+ 'plan.dirAuto': 'the session\'s own folder',
1603
+ 'plan.summary': '{n} sessions: {fresh} to transfer, {dup} already present',
1604
+ 'plan.dupNote': 'Duplicates are matched by session id; that copy is kept by default; "Overwrite as copy" creates a new copy and leaves the old one alone.',
1605
+ 'plan.keep': 'Keep existing',
1606
+ 'plan.skip': 'Skip this one',
1607
+ 'plan.copy': 'Overwrite as copy',
1608
+ 'plan.skipAll': 'Skip all existing',
1609
+ 'plan.confirm': 'Start transfer',
1610
+ 'queue.title': 'Transfer queue',
1611
+ 'queue.pending': 'Queued',
1612
+ 'error.timeout': 'Request timed out',
1613
+ 'error.offline': 'Cannot reach the plugin service: {message}',
1614
+ 'error.badResponse': 'The service returned unreadable data ({status})',
1615
+ 'error.status': 'The service returned {status}',
1616
+ 'plan.fresh': 'To transfer',
1617
+ 'queue.running': 'Transferring',
1618
+ 'queue.done': 'Done',
1619
+ 'queue.skipped': 'Skipped (already present)',
1620
+ 'queue.failed': 'Failed',
1621
+ 'queue.clear': 'Clear',
1622
+ 'queue.retry': 'Retry',
1623
+ 'queue.summary': '{done} done · {skipped} skipped · {failed} failed',
1624
+ 'queue.overall': '{done}/{total} settled (skips included)',
1625
+ 'queue.duration': 'took {seconds}s',
1626
+ 'queue.empty': 'No transfers yet: select sessions and pull, or drag them onto a device or sidebar workspace.',
1627
+ 'notice.queued': 'Added to the transfer queue',
1628
+ 'notice.subnetsSaved': 'Scan targets saved',
1629
+ 'settings.subnets': 'Cross-subnet scan',
1630
+ 'settings.subnetsHint': 'Broadcast cannot cross a router. List peer subnets (e.g. 192.168.5.0/24), single IPs (e.g. 192.168.5.20) or IP:port (e.g. 192.168.5.20:47802), one per line. Subnets get UDP discovery packets; single IPs are also probed over HTTP on 47802–47817, so a UDP-blocking firewall is not fatal.',
1631
+ 'settings.probeStatus': 'HTTP probe: {at} checked {n} targets, {found} answered (fallback when UDP is blocked)',
1632
+ 'footer.version': 'v{version}',
1633
+ 'footer.hostVersion': 'host v{version}',
1634
+ 'footer.hostStale': 'The running host is still on an older build — restart DSH to apply it',
1635
+ 'footer.license': 'MIT licensed',
1636
+ 'footer.repo': 'GitHub repository',
1637
+ 'footer.star': 'If this plugin saves you a few clicks, a ⭐ on GitHub is appreciated',
1638
+ 'common.add': 'Add',
1639
+ 'devices.addAddress': 'Add address',
1640
+ 'empty.title': 'No other device yet',
1641
+ 'empty.body': 'Install “Session Sync” on another machine on the same LAN; if broadcast is blocked, add its address below.',
1642
+ 'settings.unsortedShort': 'Unsorted (default)',
1643
+ 'sessions.verifyFailedShort': 'Checksum failed',
1644
+ 'sessions.synced': 'Synced',
1645
+ 'sessions.notSynced': 'Not synced',
1646
+ 'sessions.pulling': 'Pulling',
1647
+ 'sessions.queued': 'Queued',
1648
+ 'settings.current': 'Current',
1649
+ 'device.sessionsUnit': 'sessions',
1650
+ 'dir.pull': 'Pull',
1651
+ 'dir.push': 'Push',
1652
+ 'stage.pushHint': 'Send local sessions to “{peer}”',
1653
+ 'sessions.emptyBody': 'This device has no sessions yet.',
1654
+ 'sessions.selectedUnit': 'selected',
1655
+ 'sessions.pullTo': 'Pull to',
1656
+ 'target.title': 'Choose destination',
1657
+ 'target.count': '{n} locations',
1658
+ 'target.note': 'You can also drag sessions onto a host workspace.',
1659
+ 'settings.identity': 'Identity',
1660
+ 'common.copy': 'Copy',
1661
+ 'settings.address': 'LAN address',
1662
+ 'settings.version': 'Version',
1663
+ 'settings.hostVersion': 'Host version',
1664
+ 'settings.platform': 'Platform',
1665
+ 'settings.transfer': 'Transfer',
1666
+ 'settings.about': 'About',
1667
+ 'common.open': 'Open',
1668
+ 'settings.intro': 'Devices and session sync on this LAN.',
1669
+ 'settings.backToDesk': 'Back to desk',
1670
+ },
1671
+ }
1672
+
1673
+ // ── i18n ──────────────────────────────────────────────────────────────
1674
+ // Two sources decide the copy: the DSH locale (which itself starts from the
1675
+ // browser/OS language) and this plugin's own `locale` setting. `auto` means
1676
+ // "follow DSH"; anything that is not Chinese reads as English, by design.
1677
+ const LANGUAGES = ['auto', 'zh', 'en']
1678
+
1679
+ /** A tag such as `zh-CN`, `en-US` or `ja` → one of the two shipped languages. */
1680
+ function tagLanguage(tag) {
1681
+ if (typeof tag !== 'string' || tag.length === 0) return null
1682
+ return tag.toLowerCase().startsWith('zh') ? 'zh' : 'en'
1683
+ }
1684
+
1685
+ /** Browser/OS language: Chinese systems read Chinese, everything else English. */
1686
+ function systemLanguage() {
1687
+ const nav = typeof navigator === 'undefined' ? null : navigator
1688
+ const list = Array.isArray(nav?.languages) && nav.languages.length > 0 ? nav.languages : [nav?.language]
1689
+ for (const tag of list) {
1690
+ const language = tagLanguage(tag)
1691
+ if (language !== null) return language
1692
+ }
1693
+ return 'en'
1694
+ }
1695
+
1696
+ let runtime = null
1697
+ const overrideStore = { value: 'auto', listeners: new Set() }
1698
+
1699
+ function readOverride() {
1700
+ return overrideStore.value
1701
+ }
1702
+
1703
+ function writeOverride(value) {
1704
+ const next = LANGUAGES.includes(value) ? value : 'auto'
1705
+ if (next === overrideStore.value) return
1706
+ overrideStore.value = next
1707
+ for (const listener of [...overrideStore.listeners]) listener()
1708
+ }
1709
+
1710
+ function subscribeOverride(listener) {
1711
+ overrideStore.listeners.add(listener)
1712
+ return () => overrideStore.listeners.delete(listener)
1713
+ }
1714
+
1715
+ function dshLanguage() {
1716
+ return runtime === null ? null : tagLanguage(runtime.getActive())
1717
+ }
1718
+
1719
+ /** `auto` → the DSH language, else the system language. */
1720
+ function resolveLanguage(override, dsh) {
1721
+ if (override === 'zh' || override === 'en') return override
1722
+ return tagLanguage(dsh) ?? systemLanguage()
1723
+ }
1724
+
1725
+ function makeT(language) {
1726
+ const dictionary = DICTIONARIES[language] ?? DICTIONARIES.en
1727
+ return (key, params) => {
1728
+ const template = dictionary[key] ?? DICTIONARIES.en[key] ?? key
1729
+ if (params === undefined) return template
1730
+ return template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
1731
+ }
1732
+ }
1733
+
1734
+ /** For slot labels, which are plain functions rather than components. */
1735
+ function translateNow(key, params) {
1736
+ return makeT(resolveLanguage(overrideStore.value, dshLanguage()))(key, params)
1737
+ }
1738
+
1739
+ function useOverride() {
1740
+ // Older hosts may ship React without useSyncExternalStore; a plain state
1741
+ // subscription behaves the same for our single mutable override object.
1742
+ const subscribeStore = typeof React.useSyncExternalStore === 'function'
1743
+ ? React.useSyncExternalStore
1744
+ : (subscribe, read) => {
1745
+ const [value, setValue] = React.useState(read)
1746
+ React.useEffect(() => subscribe(() => setValue(read())), [subscribe, read])
1747
+ return value
1748
+ }
1749
+ return subscribeStore(subscribeOverride, readOverride, readOverride)
1750
+ }
1751
+
1752
+ /** Track the DSH locale and this plugin's override, and return the translator. */
1753
+ function useT() {
1754
+ const override = useOverride()
1755
+ const [, bump] = React.useState(0)
1756
+ React.useEffect(() => {
1757
+ if (runtime === null) return undefined
1758
+ return runtime.subscribe(() => bump((count) => count + 1))
1759
+ }, [])
1760
+ const dsh = dshLanguage()
1761
+ return React.useMemo(
1762
+ () => makeT(resolveLanguage(override, dsh)),
1763
+ [override, dsh],
1764
+ )
1765
+ }
1766
+
1767
+ // ── host API ──────────────────────────────────────────────────────────
1768
+ class ApiError extends Error {}
1769
+
1770
+ async function api(action, payload = {}) {
1771
+ const controller = new AbortController()
1772
+ const timer = setTimeout(() => controller.abort(), 30_000)
1773
+ let response
1774
+ try {
1775
+ response = await fetch(new URL(API_PATH.slice(1), document.baseURI).toString(), {
1776
+ method: 'POST',
1777
+ headers: { 'content-type': 'application/json' },
1778
+ body: JSON.stringify({ action, ...payload }),
1779
+ credentials: 'same-origin',
1780
+ cache: 'no-store',
1781
+ signal: controller.signal,
1782
+ })
1783
+ } catch (error) {
1784
+ throw new ApiError(error?.name === 'AbortError'
1785
+ ? translateNow('error.timeout')
1786
+ : translateNow('error.offline', { message: error?.message ?? String(error) }))
1787
+ } finally {
1788
+ clearTimeout(timer)
1789
+ }
1790
+ let body
1791
+ try {
1792
+ body = await response.json()
1793
+ } catch {
1794
+ throw new ApiError(translateNow('error.badResponse', { status: String(response.status) }))
1795
+ }
1796
+ if (!response.ok || body?.ok === false) {
1797
+ throw new ApiError(body?.error?.message ?? translateNow('error.status', { status: String(response.status) }))
1798
+ }
1799
+ return body
1800
+ }
1801
+
1802
+ // ── small helpers ─────────────────────────────────────────────────────
1803
+ function relativeTime(t, ms) {
1804
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return t('time.never')
1805
+ const delta = Date.now() - ms
1806
+ if (delta < 5_000) return t('time.now')
1807
+ if (delta < 60_000) return t('time.seconds', { n: Math.floor(delta / 1000) })
1808
+ if (delta < 3_600_000) return t('time.minutes', { n: Math.floor(delta / 60_000) })
1809
+ if (delta < 86_400_000) return t('time.hours', { n: Math.floor(delta / 3_600_000) })
1810
+ return t('time.days', { n: Math.floor(delta / 86_400_000) })
1811
+ }
1812
+
1813
+ /**
1814
+ * Unnamed sessions all render as “未命名会话”, so two different conversations look alike.
1815
+ * Identity is the session id — show a short slice on every row and in the plan dialog so a
1816
+ * duplicate can be checked against the same id instead of the title.
1817
+ */
1818
+ function sessionTag(session) {
1819
+ const id = typeof session?.id === 'string' ? session.id : ''
1820
+ const tail = id.startsWith('session-') ? id.slice('session-'.length) : id
1821
+ return tail.slice(0, 8) || '—'
1822
+ }
1823
+
1824
+ function shortDate(ms) {
1825
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
1826
+ const date = new Date(ms)
1827
+ const pad = (value) => String(value).padStart(2, '0')
1828
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
1829
+ }
1830
+
1831
+ /** Wall-clock time (HH:MM:SS) for the "last scan" line. */
1832
+ function shortClock(at) {
1833
+ if (typeof at !== 'number' || !Number.isFinite(at)) return '—'
1834
+ const date = new Date(at)
1835
+ const pad = (value) => String(value).padStart(2, '0')
1836
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
1837
+ }
1838
+
1839
+ /** Clipboard write with a textarea fallback for browsers without the async API. */
1840
+ async function copyText(value) {
1841
+ try {
1842
+ if (navigator.clipboard?.writeText !== undefined) {
1843
+ await navigator.clipboard.writeText(value)
1844
+ return true
1845
+ }
1846
+ } catch { /* fall through to the legacy path */ }
1847
+ try {
1848
+ const area = document.createElement('textarea')
1849
+ area.value = value
1850
+ area.setAttribute('readonly', '')
1851
+ area.style.position = 'fixed'
1852
+ area.style.opacity = '0'
1853
+ document.body.appendChild(area)
1854
+ area.select()
1855
+ const ok = document.execCommand('copy')
1856
+ area.remove()
1857
+ return ok === true
1858
+ } catch {
1859
+ return false
1860
+ }
1861
+ }
1862
+
1863
+ /** Follow the host's peer/live state without letting a failed poll wipe the view. */
1864
+ function useSnapshot(active, pollMs = POLL_MS) {
1865
+ const [snapshot, setSnapshot] = React.useState({ status: 'loading', peers: [], imports: [], self: null })
1866
+ const [error, setError] = React.useState(null)
1867
+ const reload = React.useCallback(async () => {
1868
+ try {
1869
+ const body = await api('state')
1870
+ setSnapshot({ status: 'ready', ...body })
1871
+ setError(null)
1872
+ } catch (failure) {
1873
+ setError(String(failure?.message ?? failure))
1874
+ setSnapshot((current) => (current.status === 'ready' ? current : { ...current, status: 'error' }))
1875
+ }
1876
+ }, [])
1877
+ React.useEffect(() => {
1878
+ if (!active) return undefined
1879
+ let alive = true
1880
+ void reload()
1881
+ const timer = setInterval(() => {
1882
+ if (alive) void reload()
1883
+ }, pollMs)
1884
+ // Never keep a headless runtime (tests, SSR) alive just for the poll.
1885
+ timer?.unref?.()
1886
+ return () => {
1887
+ alive = false
1888
+ clearInterval(timer)
1889
+ }
1890
+ }, [active, reload, pollMs])
1891
+ return { ...snapshot, error, reload }
1892
+ }
1893
+
1894
+ // ── drag & drop ───────────────────────────────────────────────────────
1895
+ // Rows carry a small JSON payload; drop zones read it back from
1896
+ // `dataTransfer`, which is the only channel available to a page that has
1897
+ // no native file-drag support.
1898
+ const DND_MIME = 'application/x-dsh-session-sync'
1899
+
1900
+ /** Drag image: title, event count and a selection badge, painted by DSH tokens. */
1901
+ function dragGhost(payload) {
1902
+ if (typeof document === 'undefined' || document.body === null) return null
1903
+ const node = document.createElement('div')
1904
+ node.className = 'ss-dragghost'
1905
+ const ico = document.createElement('span')
1906
+ ico.className = 'ss-dnd-ico'
1907
+ ico.textContent = '▤'
1908
+ const main = document.createElement('div')
1909
+ main.className = 'ss-dragghost-main'
1910
+ const title = document.createElement('span')
1911
+ title.className = 'ss-dragghost-title'
1912
+ const sub = document.createElement('span')
1913
+ sub.className = 'ss-dragghost-sub'
1914
+ main.append(title, sub)
1915
+ node.append(ico, main)
1916
+ const count = payload.ids?.length ?? 1
1917
+ title.textContent = payload.titles?.[0] || translateNow('sessions.untitled')
1918
+ sub.textContent = payload.direction === 'pull'
1919
+ ? translateNow('dnd.ghostPull', { count: String(count) })
1920
+ : translateNow('dnd.ghostPush', { count: String(count) })
1921
+ if (count > 1) {
1922
+ const badge = document.createElement('span')
1923
+ badge.className = 'ss-dragghost-badge'
1924
+ badge.textContent = `+${count - 1}`
1925
+ node.append(badge)
1926
+ }
1927
+ return node
1928
+ }
1929
+
1930
+ let ghostNode = null
1931
+ let ghostPayload = null
1932
+ let ghostTimer = null
1933
+
1934
+ const GHOST_IDLE_MS = 20_000
1935
+
1936
+ /** Arm (or re-arm) the watchdog that removes a ghost whose dragend never arrived. */
1937
+ function armGhostWatchdog() {
1938
+ if (ghostTimer !== null) clearTimeout(ghostTimer)
1939
+ ghostTimer = setTimeout(() => {
1940
+ clearGhost()
1941
+ hideDropTip(0)
1942
+ clearRowMark()
1943
+ dragStore.clear()
1944
+ }, GHOST_IDLE_MS)
1945
+ ghostTimer?.unref?.()
1946
+ }
1947
+
1948
+ /** Native drag images are painted by the compositor and collide with our pill; hide it. */
1949
+ const BLANK_DRAG_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
1950
+
1951
+ function blankDragImage() {
1952
+ const img = document.createElement('img')
1953
+ img.src = BLANK_DRAG_IMAGE
1954
+ img.width = 1
1955
+ img.height = 1
1956
+ img.style.cssText = 'position:fixed;top:-10px;left:-10px;width:1px;height:1px'
1957
+ return img
1958
+ }
1959
+
1960
+ function placeGhost(event) {
1961
+ if (ghostNode === null) {
1962
+ if (ghostPayload === null) return
1963
+ ghostNode = dragGhost(ghostPayload)
1964
+ if (ghostNode === null) return
1965
+ document.body.append(ghostNode)
1966
+ }
1967
+ const x = Math.min(event.clientX + 16, (window.innerWidth ?? 1440) - 340)
1968
+ const y = Math.min(event.clientY + 12, (window.innerHeight ?? 900) - 120)
1969
+ ghostNode.style.transform = `translate(${x}px, ${y}px)`
1970
+ ghostNode.dataset.show = '1'
1971
+ armGhostWatchdog()
1972
+ }
1973
+
1974
+ function clearGhost() {
1975
+ if (ghostTimer !== null) {
1976
+ clearTimeout(ghostTimer)
1977
+ ghostTimer = null
1978
+ }
1979
+ ghostNode?.remove()
1980
+ ghostNode = null
1981
+ ghostPayload = null
1982
+ }
1983
+
1984
+ function beginDrag(event, payload) {
1985
+ dragStore.set(payload)
1986
+ const source = event.currentTarget
1987
+ if (source !== null && source.classList !== undefined) {
1988
+ source.classList.add('ss-dragging')
1989
+ const clear = () => {
1990
+ source.classList.remove('ss-dragging')
1991
+ source.removeEventListener('dragend', clear)
1992
+ }
1993
+ source.addEventListener('dragend', clear)
1994
+ }
1995
+ try {
1996
+ event.dataTransfer.setData(DND_MIME, JSON.stringify(payload))
1997
+ event.dataTransfer.setData('text/plain', (payload.titles ?? []).join('\n'))
1998
+ event.dataTransfer.effectAllowed = 'copy'
1999
+ clearGhost()
2000
+ ghostPayload = payload
2001
+ const blank = blankDragImage()
2002
+ document.body.append(blank)
2003
+ event.dataTransfer.setDragImage(blank, 0, 0)
2004
+ setTimeout(() => blank.remove(), 0)
2005
+ placeGhost(event)
2006
+ } catch {
2007
+ /* some browsers refuse custom MIME types; the drop just does nothing */
2008
+ }
2009
+ }
2010
+
2011
+ const dragListeners = new Set()
2012
+ let dragState = null
2013
+ let dragHandler = null
2014
+ const dragStore = {
2015
+ subscribe(listener) {
2016
+ dragListeners.add(listener)
2017
+ return () => dragListeners.delete(listener)
2018
+ },
2019
+ get() {
2020
+ return dragState
2021
+ },
2022
+ set(payload) {
2023
+ dragState = payload
2024
+ for (const listener of dragListeners) listener()
2025
+ },
2026
+ clear() {
2027
+ if (dragState === null) return
2028
+ dragState = null
2029
+ for (const listener of dragListeners) listener()
2030
+ },
2031
+ setHandler(handler) {
2032
+ dragHandler = handler
2033
+ // Returning an owner-checked disposer keeps a page that unmounts later
2034
+ // from clearing the handler a still-mounted page registered.
2035
+ return () => {
2036
+ if (dragHandler === handler) dragHandler = null
2037
+ }
2038
+ },
2039
+ drop(target, payload = dragState) {
2040
+ // `dragend` fires before the drop animation finishes, so the caller
2041
+ // hands over the payload it captured at drop time; reading `dragState`
2042
+ // here would already be null and the transfer would silently vanish.
2043
+ if (payload === null) return
2044
+ if (dragState !== null) {
2045
+ dragState = null
2046
+ for (const listener of dragListeners) listener()
2047
+ }
2048
+ dragHandler?.(payload, target)
2049
+ },
2050
+ }
2051
+
2052
+ /**
2053
+ * Patch the real sidebar instead of painting our own column: DSH already
2054
+ * renders `[data-row-key="workspace:<key>"]` rows, so a capture-phase
2055
+ * listener can light up the row under the pointer and take the drop. The
2056
+ * official markup, classes and React tree stay exactly as they are.
2057
+ */
2058
+ /** Set by the visible session list so `/` can focus its search box. */
2059
+ const focusSearch = { current: null }
2060
+
2061
+ const sidebarDrop = {
2062
+ resolver: null,
2063
+ row: null,
2064
+ setResolver(resolver) {
2065
+ this.resolver = resolver
2066
+ return () => {
2067
+ if (this.resolver === resolver) this.resolver = null
2068
+ }
2069
+ },
2070
+ }
2071
+
2072
+ /** Every workspace header, in document order. Nested lists stay ordered the same way they read. */
2073
+ function workspaceHeaders() {
2074
+ return [...document.querySelectorAll('[data-row-key^="workspace:"]')]
2075
+ }
2076
+
2077
+ /**
2078
+ * Smallest box that holds this header (and, when given, the row the pointer
2079
+ * is over). DSH nests a group as <section><header/><span><row/></span></section>,
2080
+ * so the container is a real ancestor rather than a sibling.
2081
+ */
2082
+ function groupContainerOf(header, row = header) {
2083
+ let node = row.parentElement
2084
+ while (node !== null) {
2085
+ if (node.contains(header) && node.querySelectorAll('[data-row-key^="workspace:"]').length <= 1) return node
2086
+ node = node.parentElement
2087
+ }
2088
+ return header.parentElement
2089
+ }
2090
+
2091
+ /** Walk back to the workspace header that owns this row (session/overflow rows count). */
2092
+ function ownerWorkspaceRow(row) {
2093
+ if (row === null) return null
2094
+ const key = row.getAttribute?.('data-row-key') ?? ''
2095
+ if (key.startsWith('workspace:')) return row
2096
+ let owner = null
2097
+ for (const header of workspaceHeaders()) {
2098
+ if (header === row) break
2099
+ if ((header.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) break
2100
+ owner = header
2101
+ }
2102
+ if (owner === null) return null
2103
+ const container = groupContainerOf(owner, row)
2104
+ if (container !== null && !container.contains(row)) return null
2105
+ // A row in a later group's gutter must not inherit the previous header.
2106
+ const newer = [...container.querySelectorAll('[data-row-key^="workspace:"]')]
2107
+ .some((header) => header !== owner && (header.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0)
2108
+ return newer ? null : owner
2109
+ }
2110
+
2111
+ /** Tint the header, its container and every expanded row underneath. */
2112
+ function markGroup(headerRow, on) {
2113
+ if (headerRow === null) return
2114
+ headerRow.classList.toggle('ss-ws-over', on)
2115
+ groupContainerOf(headerRow)?.classList.toggle('ss-ws-group', on)
2116
+ const rows = [...document.querySelectorAll('[data-row-key]')]
2117
+ const start = rows.indexOf(headerRow)
2118
+ if (start < 0) return
2119
+ for (let index = start + 1; index < rows.length; index += 1) {
2120
+ const row = rows[index]
2121
+ const key = row.getAttribute('data-row-key') ?? ''
2122
+ if (key.startsWith('workspace:')) break
2123
+ row.classList.toggle('ss-ws-member', on)
2124
+ }
2125
+ }
2126
+
2127
+ /** The session row right after `row` inside the same group, if any. */
2128
+ function nextSessionId(row) {
2129
+ const rows = [...document.querySelectorAll('[data-row-key]')]
2130
+ let seen = false
2131
+ for (const node of rows) {
2132
+ if (node === row) {
2133
+ seen = true
2134
+ continue
2135
+ }
2136
+ if (!seen) continue
2137
+ const key = node.getAttribute('data-row-key') ?? ''
2138
+ if (key.startsWith('workspace:')) return null
2139
+ if (key.startsWith('session:')) return key.slice('session:'.length)
2140
+ }
2141
+ return null
2142
+ }
2143
+
2144
+ /** Where a native session row would re-insert: before its own id or the next row's. */
2145
+ function insertAnchor(row, event) {
2146
+ const key = row?.getAttribute?.('data-row-key') ?? ''
2147
+ if (!key.startsWith('session:')) return null
2148
+ const rect = row.getBoundingClientRect()
2149
+ const below = typeof event?.clientY === 'number' && event.clientY > rect.top + rect.height / 2
2150
+ const beforeId = below ? nextSessionId(row) : key.slice('session:'.length)
2151
+ showInsertLine(row, below)
2152
+ return { beforeId }
2153
+ }
2154
+
2155
+ let insertLine = null
2156
+ function showInsertLine(row, below) {
2157
+ const rect = row.getBoundingClientRect()
2158
+ if (insertLine === null) {
2159
+ insertLine = document.createElement('div')
2160
+ insertLine.className = 'ss-insert'
2161
+ document.body.appendChild(insertLine)
2162
+ }
2163
+ insertLine.style.left = `${rect.left}px`
2164
+ insertLine.style.width = `${rect.width}px`
2165
+ insertLine.style.top = `${(below ? rect.bottom : rect.top) - 1}px`
2166
+ insertLine.dataset.show = '1'
2167
+ }
2168
+ function clearInsertLine() {
2169
+ if (insertLine !== null) insertLine.dataset.show = '0'
2170
+ }
2171
+
2172
+ function workspaceRowTarget(row, event) {
2173
+ const anchor = insertAnchor(row, event)
2174
+ const owner = ownerWorkspaceRow(row)
2175
+ if (owner === null) return null
2176
+ const raw = owner.getAttribute('data-row-key') ?? ''
2177
+ if (!raw.startsWith('workspace:')) return null
2178
+ const key = raw.slice('workspace:'.length)
2179
+ const view = sidebarDrop.resolver?.() ?? {}
2180
+ const onHeader = row === owner
2181
+ if (key === '') {
2182
+ return typeof view.unsortedDir === 'string' && view.unsortedDir.length > 0
2183
+ ? {
2184
+ path: view.unsortedDir,
2185
+ label: translateNow('settings.syncUnsorted'),
2186
+ mode: 'plain',
2187
+ hint: translateNow('dnd.intoUnsorted'),
2188
+ workspaceId: null,
2189
+ beforeId: null,
2190
+ }
2191
+ : null
2192
+ }
2193
+ const match = (view.workspaces ?? []).find((workspace) => workspace.id === key || workspace.path === key)
2194
+ if (match !== undefined && typeof match.path === 'string' && match.path.length > 0) {
2195
+ return {
2196
+ path: match.path,
2197
+ label: match.title || match.path,
2198
+ mode: 'ws',
2199
+ hint: translateNow(anchor?.beforeId !== null && anchor?.beforeId !== undefined
2200
+ ? 'dnd.insertHere'
2201
+ : onHeader ? 'dnd.intoGroup' : 'dnd.intoGroupArea'),
2202
+ workspaceId: match.id ?? null,
2203
+ beforeId: anchor?.beforeId ?? null,
2204
+ }
2205
+ }
2206
+ return /^(?:\/|[A-Za-z]:[\\/])/.test(key) ? { path: key, label: key, workspaceId: null, beforeId: null } : null
2207
+ }
2208
+
2209
+ /** Pointer pill: icon + target name + what will happen. */
2210
+ const dropTip = { node: null, ico: null, name: null, hint: null, mode: null, hideAt: 0 }
2211
+
2212
+ function ensureDropTip() {
2213
+ if (dropTip.node !== null) return dropTip.node
2214
+ const node = document.createElement('div')
2215
+ node.className = 'ss-dnd-tip'
2216
+ const ico = document.createElement('span')
2217
+ ico.className = 'ss-dnd-ico'
2218
+ ico.textContent = '▣'
2219
+ const name = document.createElement('span')
2220
+ name.className = 'ss-dnd-name'
2221
+ const hint = document.createElement('span')
2222
+ hint.className = 'ss-dnd-hint'
2223
+ node.append(ico, name, hint)
2224
+ document.body.append(node)
2225
+ dropTip.node = node
2226
+ dropTip.ico = ico
2227
+ dropTip.name = name
2228
+ dropTip.hint = hint
2229
+ return node
2230
+ }
2231
+
2232
+ function showDropTip(event, target) {
2233
+ if (typeof document === 'undefined' || document.body === null) return
2234
+ const node = ensureDropTip()
2235
+ const mode = target.mode ?? 'ws'
2236
+ if (dropTip.mode !== mode) {
2237
+ node.classList.toggle('ws', mode === 'ws')
2238
+ node.classList.toggle('blocked', mode === 'blocked')
2239
+ node.classList.toggle('done', mode === 'done')
2240
+ dropTip.ico.textContent = mode === 'blocked' ? '⊘' : mode === 'done' ? '✓' : '▣'
2241
+ dropTip.mode = mode
2242
+ }
2243
+ dropTip.name.textContent = target.label ?? ''
2244
+ dropTip.hint.textContent = target.hint ?? ''
2245
+ dropTip.node.style.left = `${Math.min(event.clientX + 18, (window.innerWidth ?? 1440) - 240)}px`
2246
+ dropTip.node.style.top = `${event.clientY + 60}px`
2247
+ clearTimeout(dropTip.hideAt)
2248
+ dropTip.node.dataset.show = '1'
2249
+ }
2250
+
2251
+ function hideDropTip(delay = 0) {
2252
+ if (dropTip.node === null) return
2253
+ clearTimeout(dropTip.hideAt)
2254
+ if (delay === 0) {
2255
+ delete dropTip.node.dataset.show
2256
+ return
2257
+ }
2258
+ dropTip.hideAt = setTimeout(() => {
2259
+ if (dropTip.node !== null) delete dropTip.node.dataset.show
2260
+ }, delay)
2261
+ }
2262
+
2263
+ function clearRowMark() {
2264
+ if (sidebarDrop.row !== null) {
2265
+ markGroup(sidebarDrop.row, false)
2266
+ sidebarDrop.row.classList.remove('ss-ws-over')
2267
+ }
2268
+ sidebarDrop.row = null
2269
+ }
2270
+
2271
+ function installSidebarDrop() {
2272
+ if (typeof document?.addEventListener !== 'function') return () => {}
2273
+ const pulledDrag = () => {
2274
+ const drag = dragStore.get()
2275
+ return drag !== null && drag.direction === 'pull' ? drag : null
2276
+ }
2277
+ const rowOf = (event) => event.target?.closest?.('[data-row-key]') ?? null
2278
+ const onOver = (event) => {
2279
+ placeGhost(event)
2280
+ if (pulledDrag() === null) {
2281
+ clearRowMark()
2282
+ return
2283
+ }
2284
+ const row = rowOf(event)
2285
+ const target = row === null ? null : workspaceRowTarget(row)
2286
+ if (target === null) {
2287
+ clearRowMark()
2288
+ showDropTip(event, { mode: 'blocked', label: translateNow('dnd.blocked'), hint: translateNow('dnd.blockedHint') })
2289
+ return
2290
+ }
2291
+ event.preventDefault()
2292
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
2293
+ const owner = ownerWorkspaceRow(row)
2294
+ if (sidebarDrop.row !== owner) {
2295
+ clearRowMark()
2296
+ sidebarDrop.row = owner
2297
+ }
2298
+ markGroup(owner, true)
2299
+ showDropTip(event, target)
2300
+ }
2301
+ const onDrop = (event) => {
2302
+ const payload = pulledDrag()
2303
+ const row = rowOf(event) ?? sidebarDrop.row
2304
+ const target = row === null || payload === null ? null : workspaceRowTarget(row, event)
2305
+ clearRowMark()
2306
+ clearGhost()
2307
+ clearInsertLine()
2308
+ if (target === null) {
2309
+ hideDropTip()
2310
+ return
2311
+ }
2312
+ event.preventDefault()
2313
+ event.stopPropagation()
2314
+ showDropTip(event, {
2315
+ mode: 'done',
2316
+ label: translateNow('dnd.selected', { name: target.label }),
2317
+ hint: translateNow('dnd.preparing'),
2318
+ })
2319
+ setTimeout(() => {
2320
+ hideDropTip()
2321
+ dragStore.drop(target, payload)
2322
+ }, 90)
2323
+ }
2324
+ const onEnd = () => {
2325
+ clearRowMark()
2326
+ clearInsertLine()
2327
+ hideDropTip()
2328
+ clearGhost()
2329
+ dragStore.clear()
2330
+ }
2331
+ // A drag that ends outside the document (window switch, OS cancel, an
2332
+ // iframe swallowing the drop) never fires dragend, so the ghost and the
2333
+ // pill would stay on screen forever. Treat every "we lost the pointer"
2334
+ // signal as the end of the drag.
2335
+ const onVisibility = () => {
2336
+ if (document.visibilityState === 'hidden') onEnd()
2337
+ }
2338
+ document.addEventListener('dragover', onOver, true)
2339
+ document.addEventListener('drop', onDrop, true)
2340
+ document.addEventListener('dragend', onEnd, true)
2341
+ document.addEventListener('visibilitychange', onVisibility)
2342
+ // `window` inside the module factory is the host's real window; fall back
2343
+ // to `document.defaultView` so the guard also works in a bare DOM harness.
2344
+ const topWindow = (typeof window !== 'undefined' && typeof window?.addEventListener === 'function')
2345
+ ? window
2346
+ : (document.defaultView ?? null)
2347
+ topWindow?.addEventListener('blur', onEnd)
2348
+ const onLeave = (event) => {
2349
+ if (event.relatedTarget === null) onEnd()
2350
+ }
2351
+ document.addEventListener('dragleave', onLeave, true)
2352
+ return () => {
2353
+ document.removeEventListener('dragover', onOver, true)
2354
+ document.removeEventListener('drop', onDrop, true)
2355
+ document.removeEventListener('dragend', onEnd, true)
2356
+ document.removeEventListener('visibilitychange', onVisibility)
2357
+ topWindow?.removeEventListener('blur', onEnd)
2358
+ document.removeEventListener('dragleave', onLeave, true)
2359
+ clearRowMark()
2360
+ hideDropTip()
2361
+ clearGhost()
2362
+ }
2363
+ }
2364
+
2365
+ function dropPayload(event) {
2366
+ try {
2367
+ const raw = event.dataTransfer.getData(DND_MIME)
2368
+ if (raw.length === 0) return null
2369
+ const parsed = JSON.parse(raw)
2370
+ return Array.isArray(parsed?.ids) && parsed.ids.length > 0 ? parsed : null
2371
+ } catch {
2372
+ return null
2373
+ }
2374
+ }
2375
+
2376
+ // ── shared pieces ─────────────────────────────────────────────────────
2377
+ /** Second confirmation before anything leaves the machine. */
2378
+ function PlanDialog({ t, plan, onCancel, onConfirm }) {
2379
+ const [choices, setChoices] = React.useState({})
2380
+ React.useEffect(() => {
2381
+ const initial = {}
2382
+ for (const item of plan.items) initial[item.sessionId] = item.dup ? 'keep' : 'copy'
2383
+ setChoices(initial)
2384
+ }, [plan])
2385
+ const dupCount = plan.items.filter((item) => item.dup).length
2386
+ const title = plan.direction === 'pull'
2387
+ ? t('plan.titlePull', { dir: plan.targetLabel ?? plan.cwd ?? t('plan.dirAuto') })
2388
+ : t('plan.titlePush', { peer: plan.peerName })
2389
+ return h('div', {
2390
+ className: 'ss-scrim',
2391
+ role: 'presentation',
2392
+ onClick: (event) => { if (event.target === event.currentTarget) onCancel() },
2393
+ },
2394
+ h('div', { className: 'ss-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': title },
2395
+ h('div', { className: 'ss-dialog-hd' },
2396
+ h('h3', null, title),
2397
+ h('p', null, t('plan.summary', {
2398
+ n: plan.items.length,
2399
+ fresh: plan.items.filter((item) => !item.dup).length,
2400
+ dup: dupCount,
2401
+ })),
2402
+ dupCount > 0 ? h('div', { className: 'ss-dialog-note' },
2403
+ h(Icon, { name: 'verify', size: 12 }),
2404
+ h('span', null, t('plan.dupNote')),
2405
+ ) : null,
2406
+ ),
2407
+ h('div', { className: 'ss-dialog-body' },
2408
+ plan.items.map((item) => h('div', { className: 'ss-plan-row', key: item.sessionId },
2409
+ h('div', null,
2410
+ h('div', { className: 'ss-plan-title' }, item.title || t('sessions.untitled')),
2411
+ h('div', { className: 'ss-plan-meta' },
2412
+ h('code', null, `#${sessionTag({ id: item.sessionId })}`),
2413
+ item.dup ? ` · ${t('sessions.imported')}` : '',
2414
+ ),
2415
+ ),
2416
+ item.dup
2417
+ ? h('select', {
2418
+ className: 'ss-select',
2419
+ value: choices[item.sessionId] ?? 'keep',
2420
+ 'aria-label': item.title || t('sessions.untitled'),
2421
+ onChange: (event) => setChoices((current) => ({ ...current, [item.sessionId]: event.target.value })),
2422
+ },
2423
+ h('option', { value: 'keep' }, t('plan.keep')),
2424
+ h('option', { value: 'copy' }, t('plan.copy')),
2425
+ h('option', { value: 'skip' }, t('plan.skip')),
2426
+ )
2427
+ : h('span', { className: 'ss-plan-fresh' }, t('plan.fresh')),
2428
+ )),
2429
+ ),
2430
+ h('div', { className: 'ss-dialog-foot' },
2431
+ dupCount > 0
2432
+ ? h('button', {
2433
+ type: 'button',
2434
+ className: 'ss-btn ghost sm',
2435
+ onClick: () => {
2436
+ const next = { ...choices }
2437
+ for (const item of plan.items) if (item.dup) next[item.sessionId] = 'keep'
2438
+ onConfirm(next)
2439
+ },
2440
+ }, t('plan.skipAll'))
2441
+ : null,
2442
+ h('span', { className: 'ss-spacer' }),
2443
+ h('button', { type: 'button', className: 'ss-btn sm', onClick: onCancel }, t('common.cancel')),
2444
+ h('button', { type: 'button', className: 'ss-btn primary sm', onClick: () => onConfirm(choices) }, t('plan.confirm')),
2445
+ ),
2446
+ ))
2447
+ }
2448
+
2449
+ function queueIcon(status) {
2450
+ if (status === 'done') return h(Icon, { name: 'check', size: 14 })
2451
+ if (status === 'failed') return h(Icon, { name: 'alert', size: 14 })
2452
+ if (status === 'running') return h('span', { className: 'ss-spinner', style: { width: 13, height: 13 } })
2453
+ if (status === 'skipped') return h(Icon, { name: 'check', size: 14 })
2454
+ return h('span', { style: { color: 'var(--t3)', font: '11px/1 var(--ds-font-family-code, monospace)' } }, '…')
2455
+ }
2456
+
2457
+ /**
2458
+ * Queue block: one row per transfer with its own status motion. Rendered
2459
+ * inside the stage above the dock, not as a drawer over it.
2460
+ */
2461
+ function QueuePanel({ t, queue, progress = {}, onClear, onRetry }) {
2462
+ if (!Array.isArray(queue) || queue.length === 0) return null
2463
+ const count = (status) => queue.filter((item) => item.status === status).length
2464
+ const done = count('done')
2465
+ const skipped = count('skipped')
2466
+ const failed = count('failed')
2467
+ const pending = count('pending')
2468
+ const running = count('running')
2469
+ const stat = (value, label, tone) => h('span', { className: tone ? `ss-qstat ${tone}` : 'ss-qstat' },
2470
+ h('b', null, String(value)),
2471
+ h('span', null, label),
2472
+ )
2473
+ return h('div', { className: 'ss-queue' },
2474
+ h('div', { className: 'ss-queue-hd' },
2475
+ h('h4', null, t('queue.title')),
2476
+ h('div', { className: 'ss-queue-stats' },
2477
+ stat(done, t('queue.done'), 'done'),
2478
+ h('span', { className: 'ss-sep' }, '·'),
2479
+ running > 0 ? stat(running, t('queue.running'), 'running') : stat(pending, t('queue.pending')),
2480
+ h('span', { className: 'ss-sep' }, '·'),
2481
+ stat(skipped, t('queue.skipped')),
2482
+ h('span', { className: 'ss-sep' }, '·'),
2483
+ stat(failed, t('queue.failed'), failed > 0 ? 'failed' : undefined),
2484
+ ),
2485
+ h('span', { className: 'ss-spacer' }),
2486
+ h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: onClear }, t('queue.clear')),
2487
+ ),
2488
+ h('div', { className: 'ss-queue-body' },
2489
+ queue.map((item) => {
2490
+ const seconds = item.startedAt && item.endedAt ? ((item.endedAt - item.startedAt) / 1000).toFixed(1) : null
2491
+ const live = progress[`${item.peerId}::${item.sessionId}`] ?? null
2492
+ const percent = live !== null && Number.isFinite(live.total) && live.total > 0
2493
+ ? Math.min(100, Math.round((live.received / live.total) * 100))
2494
+ : null
2495
+ return h('div', { key: item.key, className: `ss-qrow ${item.status}` },
2496
+ h('span', { className: `ss-qico ${item.status}`, 'aria-hidden': 'true' }, queueIcon(item.status)),
2497
+ h('div', { className: 'ss-qmain' },
2498
+ h('div', { className: 'ss-qname', title: item.title || t('sessions.untitled') }, item.title || t('sessions.untitled')),
2499
+ h('div', { className: 'ss-qmeta' },
2500
+ h('span', null, `${item.direction === 'pull' ? '←' : '→'} ${item.peerName}`),
2501
+ item.cwd ? h('code', { title: item.cwd }, item.cwd) : null,
2502
+ seconds !== null ? h('span', null, t('queue.duration', { seconds })) : null,
2503
+ ),
2504
+ item.message ? h('div', { className: 'ss-qmsg' }, item.message) : null,
2505
+ ),
2506
+ h('div', { className: 'ss-qright' },
2507
+ item.status === 'running' && percent !== null ? h('span', { className: 'pct' }, `${percent}%`) : null,
2508
+ item.status === 'failed'
2509
+ ? h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: () => onRetry(item) }, t('queue.retry'))
2510
+ : h('span', { className: 'dur' }, t(`queue.${item.status}`)),
2511
+ ),
2512
+ )
2513
+ }),
2514
+ ),
2515
+ )
2516
+ }
2517
+
2518
+ function TokenEditor({ t, value, placeholder, onChange, onSave, busy }) {
2519
+ const [draft, setDraft] = React.useState('')
2520
+ const items = String(value ?? '').split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
2521
+ const add = () => {
2522
+ const next = draft.trim()
2523
+ if (next.length === 0) return
2524
+ if (!items.includes(next)) onChange([...items, next].join('\n'))
2525
+ setDraft('')
2526
+ }
2527
+ return h('div', null,
2528
+ items.length === 0 ? null : h('div', { className: 'ss-token-list', style: { marginBottom: 6 } },
2529
+ items.map((item) => h('span', { className: 'ss-token', key: item },
2530
+ item,
2531
+ h('button', {
2532
+ type: 'button',
2533
+ className: 'ss-icon xs',
2534
+ 'aria-label': t('common.clear'),
2535
+ onClick: () => onChange(items.filter((entry) => entry !== item).join('\n')),
2536
+ }, h(Icon, { name: 'x', size: 11 })),
2537
+ )),
2538
+ ),
2539
+ h('div', { className: 'ss-inline' },
2540
+ h('input', {
2541
+ className: 'ss-input',
2542
+ value: draft,
2543
+ spellCheck: false,
2544
+ placeholder,
2545
+ 'aria-label': placeholder,
2546
+ onChange: (event) => setDraft(event.target.value),
2547
+ onKeyDown: (event) => { if (event.key === 'Enter') add() },
2548
+ }),
2549
+ h('button', { type: 'button', className: 'ss-btn sm', onClick: add, disabled: draft.trim().length === 0 }, t('common.add')),
2550
+ h('button', { type: 'button', className: 'ss-btn sm', onClick: onSave, disabled: busy }, t('common.save')),
2551
+ ),
2552
+ )
2553
+ }
2554
+
2555
+ class Boundary extends React.Component {
2556
+ constructor(props) {
2557
+ super(props)
2558
+ this.state = { error: null }
2559
+ }
2560
+
2561
+ static getDerivedStateFromError(error) {
2562
+ return { error }
2563
+ }
2564
+
2565
+ componentDidCatch(error, info) {
2566
+ console.error('[dsh-session-sync] render failed', error, info)
2567
+ }
2568
+
2569
+ render() {
2570
+ if (this.state.error !== null) return h(Diagnostics, { error: this.state.error, t: this.props.t })
2571
+ return this.props.children
2572
+ }
2573
+ }
2574
+
2575
+ function Diagnostics({ error, t }) {
2576
+ const [diag, setDiag] = React.useState(null)
2577
+ React.useEffect(() => {
2578
+ let alive = true
2579
+ api('diag').then((body) => { if (alive) setDiag(body) }, () => undefined)
2580
+ return () => { alive = false }
2581
+ }, [])
2582
+ const lines = [
2583
+ `dsh-session-sync ${CLIENT_VERSION}`,
2584
+ `error: ${error?.message ?? String(error)}`,
2585
+ `stack: ${String(error?.stack ?? '').split('\n').slice(0, 4).join(' | ')}`,
2586
+ `ua: ${navigator.userAgent}`,
2587
+ `diag: ${diag === null ? 'unavailable' : `${diag.platform}/${diag.arch} node ${diag.node} host ${diag.version} lan ${diag.ports?.lan} udp ${diag.ports?.discovery}`}`,
2588
+ ]
2589
+ return h('div', { className: 'ss-root settings-only' },
2590
+ h('div', { className: 'ss-page' },
2591
+ h('div', { className: 'ss-settings' },
2592
+ h('div', { className: 'ss-settings-in' },
2593
+ h('div', { className: 'ss-set-hd' },
2594
+ h(Icon, { name: 'alert', size: 16 }),
2595
+ h('h2', null, t('diag.title')),
2596
+ ),
2597
+ h('p', { className: 'ss-set-sub' }, t('diag.body')),
2598
+ h('div', { className: 'ss-set-card' },
2599
+ h('div', { className: 'ss-field' },
2600
+ h('div', { className: 'ss-field-text' },
2601
+ h('div', { className: 'ss-field-label' }, t('diag.hint')),
2602
+ ),
2603
+ ),
2604
+ h('pre', { className: 'ss-diag' }, lines.join('\n')),
2605
+ h('div', { className: 'ss-field' },
2606
+ h('span', { style: { flex: 1 } }),
2607
+ h('button', { type: 'button', className: 'ss-btn primary sm', onClick: () => location.reload() }, t('diag.reload')),
2608
+ h('button', {
2609
+ type: 'button',
2610
+ className: 'ss-btn ghost sm',
2611
+ onClick: () => { void navigator.clipboard?.writeText(lines.join('\n')).catch(() => undefined) },
2612
+ }, t('diag.copy')),
2613
+ ),
2614
+ ),
2615
+ ),
2616
+ ),
2617
+ ),
2618
+ )
2619
+ }
2620
+
2621
+ // ── redesigned shell ─────────────────────────────────────────────────
2622
+ // Icons are drawn inline so the plugin keeps its "no assets, currentColor
2623
+ // only" rule; the paths match the design draft.
2624
+ const ICONS = {
2625
+ refresh: [['path', { d: 'M13.2 7.2A5.3 5.3 0 1 0 13 10.2' }], ['path', { d: 'M13.5 3.2v4h-4' }]],
2626
+ sliders: [['path', { d: 'M2.6 5.2h10.8M2.6 10.8h10.8' }], ['circle', { cx: 6, cy: 5.2, r: 1.7 }], ['circle', { cx: 10, cy: 10.8, r: 1.7 }]],
2627
+ search: [['circle', { cx: 7.1, cy: 7.1, r: 4.3 }], ['path', { d: 'm10.4 10.4 3 3' }]],
2628
+ chevronDown: [['path', { d: 'm4.2 6.4 3.8 3.8 3.8-3.8' }]],
2629
+ chevronRight: [['path', { d: 'm6.2 3.8 4.2 4.2-4.2 4.2' }]],
2630
+ check: [['path', { d: 'm3.2 8.4 3.1 3 6.5-6.8' }]],
2631
+ x: [['path', { d: 'm4 4 8 8M12 4l-8 8' }]],
2632
+ copy: [['rect', { x: 5.4, y: 5.4, width: 7.6, height: 7.6, rx: 1.7 }], ['path', { d: 'M10.6 5.4V4.1a1.1 1.1 0 0 0-1.1-1.1H4.1A1.1 1.1 0 0 0 3 4.1v5.4a1.1 1.1 0 0 0 1.1 1.1h1.3' }]],
2633
+ pull: [['path', { d: 'M8 2.8v6.6' }], ['path', { d: 'm5.2 6.8 2.8 2.6 2.8-2.6' }], ['path', { d: 'M3 11.4v1.4h10v-1.4' }]],
2634
+ push: [['path', { d: 'M8 9.4V2.8' }], ['path', { d: 'm5.2 5.4 2.8-2.6 2.8 2.6' }], ['path', { d: 'M3 11.4v1.4h10v-1.4' }]],
2635
+ verify: [['path', { d: 'M8 2.2 12.7 4v4.1c0 2.6-1.9 4.6-4.7 5.7C5.3 12.7 3.4 10.7 3.4 8.1V4L8 2.2Z' }], ['path', { d: 'm5.9 7.9 1.5 1.5 2.9-3.1' }]],
2636
+ alert: [['path', { d: 'M8 2.7 14 13H2L8 2.7Z' }], ['path', { d: 'M8 6.6v2.9M8 11.4v.1' }]],
2637
+ folder: [['path', { d: 'M2.6 4.6a1.2 1.2 0 0 1 1.2-1.2h2.4l1.3 1.6h4.7a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H3.8a1.2 1.2 0 0 1-1.2-1.2V4.6Z' }]],
2638
+ more: [['circle', { cx: 3.4, cy: 8, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 8, cy: 8, r: 1.1, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 12.6, cy: 8, r: 1.1, fill: 'currentColor', stroke: 'none' }]],
2639
+ plus: [['path', { d: 'M8 3.2v9.6M3.2 8h9.6' }]],
2640
+ devices: [['rect', { x: 2, y: 3, width: 8.6, height: 6.6, rx: 1.2 }], ['path', { d: 'M10.6 6.1h2.3a1 1 0 0 1 1 1v4.7a1 1 0 0 1-1 1H6.6' }], ['path', { d: 'M4.8 13.2h2.6' }]],
2641
+ clock: [['circle', { cx: 8, cy: 8, r: 5.3 }], ['path', { d: 'M8 5v3.1l2.1 1.3' }]],
2642
+ external: [['path', { d: 'M6.4 3.6H3.6v8.8h8.8V9.6' }], ['path', { d: 'M9.6 2.8h3.6v3.6M13.2 2.8 7.4 8.6' }]],
2643
+ pencil: [['path', { d: 'M11.4 2.8 13.2 4.6 5.9 11.9 3 12.9l1-2.9Z' }], ['path', { d: 'm10.2 4 1.8 1.8' }]],
2644
+ radar: [['circle', { cx: 8, cy: 8, r: 1.5 }], ['path', { d: 'M8 2.4a5.6 5.6 0 1 1-5.6 5.6' }], ['path', { d: 'M8 5.2a2.8 2.8 0 1 0 2.8 2.8' }]],
2645
+ arrowRight: [['path', { d: 'M3 8h9.2' }], ['path', { d: 'm9.2 5 3 3-3 3' }]],
2646
+ arrowLeft: [['path', { d: 'M13 8H3.8' }], ['path', { d: 'm6.8 5-3 3 3 3' }]],
2647
+ pause: [['path', { d: 'M6 3.5v9M10 3.5v9' }]],
2648
+ sort: [['path', { d: 'M5 3.2v9.6M5 12.8 2.8 10.6M5 12.8l2.2-2.2' }], ['path', { d: 'M11 12.8V3.2M11 3.2 8.8 5.4M11 3.2l2.2 2.2' }]],
2649
+ }
2650
+
2651
+ function Icon({ name, size = 16, className = '' }) {
2652
+ const spec = ICONS[name]
2653
+ if (spec === undefined) return null
2654
+ return h('svg', {
2655
+ className: `i ${className}`.trim(),
2656
+ viewBox: '0 0 16 16',
2657
+ width: size,
2658
+ height: size,
2659
+ 'aria-hidden': 'true',
2660
+ fill: 'none',
2661
+ stroke: 'currentColor',
2662
+ strokeWidth: 1.5,
2663
+ strokeLinecap: 'round',
2664
+ strokeLinejoin: 'round',
2665
+ }, spec.map(([tag, attrs], index) => h(tag, { key: index, ...attrs })))
2666
+ }
2667
+
2668
+ function RowCheck({ checked, label, onChange }) {
2669
+ return h('button', {
2670
+ type: 'button',
2671
+ className: 'ss-cb',
2672
+ role: 'checkbox',
2673
+ 'aria-checked': checked ? 'true' : 'false',
2674
+ 'aria-label': label,
2675
+ onClick: (event) => {
2676
+ event.stopPropagation()
2677
+ onChange()
2678
+ },
2679
+ }, h(Icon, { name: 'check' }))
2680
+ }
2681
+
2682
+ function DeviceRail({ t, self, selfAddress, peers, selectedId, onSelect, onOpenSettings, onCopySelf, onDrop, dropPeer }) {
2683
+ const online = peers.filter((peer) => peer.online === true)
2684
+ const offline = peers.filter((peer) => peer.online !== true)
2685
+ const row = (peer) => {
2686
+ const meta = [peer.address === undefined ? null : `${peer.address}:${peer.port ?? '—'}`]
2687
+ if (peer.online !== true) meta.push(relativeTime(t, peer.lastSeen))
2688
+ return h('button', {
2689
+ key: peer.id,
2690
+ type: 'button',
2691
+ className: `ss-dev${peer.online === true ? '' : ' offline'}`,
2692
+ 'data-peer': peer.id,
2693
+ 'aria-selected': selectedId === peer.id ? 'true' : 'false',
2694
+ title: t('device.sessionsCount', { n: peer.sessions ?? 0 }),
2695
+ onClick: () => onSelect(peer),
2696
+ onDragOver: (event) => {
2697
+ event.preventDefault()
2698
+ onDrop(peer, 'over')
2699
+ },
2700
+ onDragLeave: () => onDrop(peer, 'leave'),
2701
+ onDrop: (event) => {
2702
+ event.preventDefault()
2703
+ onDrop(peer, 'drop', event)
2704
+ },
2705
+ },
2706
+ h('span', { className: `ss-dot${peer.online === true ? ' on' : ' off'}` }),
2707
+ h('span', { style: { minWidth: 0 } },
2708
+ h('span', { className: 'ss-dev-name' }, peer.name ?? peer.remoteName ?? peer.address ?? peer.id),
2709
+ h('span', { className: 'ss-dev-meta' }, meta.filter((part) => part !== null).join(' · ')),
2710
+ ),
2711
+ h('span', { className: 'ss-dev-count' }, peer.sessions === undefined ? '' : String(peer.sessions)),
2712
+ )
2713
+ }
2714
+ return h('aside', { className: 'ss-rail' },
2715
+ h('div', { className: 'ss-self' },
2716
+ h('div', { className: 'ss-self-hd' },
2717
+ h('span', { className: 'ss-dot on' }),
2718
+ h('span', null, t('self.title')),
2719
+ h('button', {
2720
+ type: 'button',
2721
+ className: 'ss-icon xs',
2722
+ title: t('self.name'),
2723
+ 'aria-label': t('self.name'),
2724
+ onClick: onOpenSettings,
2725
+ }, h(Icon, { name: 'pencil', size: 12 })),
2726
+ ),
2727
+ h('div', { className: 'ss-self-name' }, self?.name ?? '—'),
2728
+ h('div', { className: 'ss-self-meta' },
2729
+ h('code', null, selfAddress || '—'),
2730
+ h('button', {
2731
+ type: 'button',
2732
+ className: 'ss-icon xs',
2733
+ title: t('devices.copyAddress'),
2734
+ 'aria-label': t('devices.copyAddress'),
2735
+ onClick: onCopySelf,
2736
+ }, h(Icon, { name: 'copy', size: 12 })),
2737
+ ),
2738
+ ),
2739
+ h('div', { className: 'ss-rail-scroll' },
2740
+ online.length === 0 ? null : h('div', { className: 'ss-glabel' }, h('span', null, t('status.online')), h('span', null, String(online.length))),
2741
+ online.map(row),
2742
+ offline.length === 0 ? null : h('div', { className: 'ss-glabel' }, h('span', null, t('status.offline')), h('span', null, String(offline.length))),
2743
+ offline.map(row),
2744
+ peers.length === 0
2745
+ ? h('div', { className: 'ss-rail-empty' },
2746
+ h(Icon, { name: 'devices', size: 18 }),
2747
+ h('span', null, t('devices.emptyTitle')),
2748
+ )
2749
+ : null,
2750
+ dropPeer === null || dropPeer === undefined ? null : h('div', { className: 'ss-hint', style: { padding: '8px 7px', color: 'var(--brand)' } }, t('sessions.dropHint')),
2751
+ ),
2752
+ h('div', { className: 'ss-rail-foot' },
2753
+ h('button', { type: 'button', className: 'ss-btn ghost sm', onClick: onOpenSettings },
2754
+ h(Icon, { name: 'plus', size: 14 }),
2755
+ h('span', null, t('devices.addAddress')),
2756
+ ),
2757
+ h('button', {
2758
+ type: 'button',
2759
+ className: 'ss-icon sm',
2760
+ title: t('settings.subnets'),
2761
+ 'aria-label': t('settings.subnets'),
2762
+ onClick: onOpenSettings,
2763
+ }, h(Icon, { name: 'radar', size: 14 })),
2764
+ ),
2765
+ )
2766
+ }
2767
+
2768
+ function EmptyStage({ t, selfAddress, manualAddress, setManualAddress, onAddManual, onCopySelf, onOpenSettings, ports, busy }) {
2769
+ return h('section', { className: 'ss-stage' },
2770
+ h('div', { className: 'ss-empty' },
2771
+ h('span', { className: 'glyph' }, h(Icon, { name: 'devices', size: 20 })),
2772
+ h('h3', null, t('empty.title')),
2773
+ h('p', null, t('empty.body')),
2774
+ h('div', { className: 'ss-addr-box' },
2775
+ h('code', null, selfAddress || '—'),
2776
+ h('button', {
2777
+ type: 'button',
2778
+ className: 'ss-icon xs',
2779
+ title: t('devices.copyAddress'),
2780
+ 'aria-label': t('devices.copyAddress'),
2781
+ onClick: onCopySelf,
2782
+ }, h(Icon, { name: 'copy', size: 12 })),
2783
+ ),
2784
+ h('div', { className: 'ss-empty-actions' },
2785
+ h('input', {
2786
+ className: 'ss-input',
2787
+ value: manualAddress,
2788
+ spellCheck: false,
2789
+ placeholder: t('devices.manualPlaceholder'),
2790
+ 'aria-label': t('devices.manualPlaceholder'),
2791
+ onChange: (event) => setManualAddress(event.target.value),
2792
+ onKeyDown: (event) => { if (event.key === 'Enter') onAddManual() },
2793
+ }),
2794
+ h('button', { type: 'button', className: 'ss-btn primary sm', disabled: busy || manualAddress.trim().length === 0, onClick: onAddManual }, t('common.add')),
2795
+ h('button', { type: 'button', className: 'ss-btn ghost sm', onClick: onOpenSettings },
2796
+ h('span', null, t('settings.subnets')),
2797
+ h(Icon, { name: 'chevronRight', size: 14 }),
2798
+ ),
2799
+ ),
2800
+ h('div', { className: 'ss-steps' },
2801
+ h('div', { className: 'ss-step' }, h('b', null, '1'), h('span', null, t('devices.step1'))),
2802
+ h('div', { className: 'ss-step' }, h('b', null, '2'), h('span', null, t('devices.step2'))),
2803
+ h('div', { className: 'ss-step' }, h('b', null, '3'), h('span', null, t('devices.step3'))),
2804
+ ),
2805
+ h('div', { className: 'ss-foot', style: { padding: '6px 0 0' } },
2806
+ h('span', null, t('page.ports', { lan: ports?.lan ?? '—', discovery: ports?.discovery ?? '—' })),
2807
+ ),
2808
+ ),
2809
+ )
2810
+ }
2811
+
2812
+ function baseName(value) {
2813
+ const parts = String(value ?? '').split(/[\\/]/).filter((part) => part.length > 0)
2814
+ return parts[parts.length - 1] ?? ''
2815
+ }
2816
+
2817
+ function SessionStage({
2818
+ t, peer, view, onView, sessions, localSessions, busy, selected, onToggle, onToggleAll,
2819
+ onClearSelection, digests, onVerify, onCopyDigest, onPull, onPush, onForgetImport, onPullAll,
2820
+ target, onTarget, workspaces, syncDir, extraPaths, pathDraft, onPathDraft, onAddPath,
2821
+ queue, progress, queueOpen, onQueueOpen, queueDone, barPercent, queueRunning, activeTitle,
2822
+ onClearQueue, onRetry, dragStart, onAction, onRename,
2823
+ }) {
2824
+ const [query, setQuery] = React.useState('')
2825
+ const [filter, setFilter] = React.useState('all')
2826
+ const [sort, setSort] = React.useState('time')
2827
+ const [cursor, setCursor] = React.useState(-1)
2828
+ const [menu, setMenu] = React.useState(false)
2829
+ const [renaming, setRenaming] = React.useState(false)
2830
+ const [draft, setDraft] = React.useState(peer?.name ?? '')
2831
+ const [targetOpen, setTargetOpen] = React.useState(false)
2832
+ const searchRef = React.useRef(null)
2833
+ const menuRef = React.useRef(null)
2834
+ const targetRef = React.useRef(null)
2835
+
2836
+ React.useEffect(() => {
2837
+ focusSearch.current = () => searchRef.current?.focus()
2838
+ return () => { if (focusSearch.current !== null) focusSearch.current = null }
2839
+ }, [])
2840
+
2841
+ React.useEffect(() => {
2842
+ setMenu(false)
2843
+ setTargetOpen(false)
2844
+ setRenaming(false)
2845
+ setCursor(-1)
2846
+ }, [peer?.id, view])
2847
+
2848
+ React.useEffect(() => {
2849
+ if (!renaming) setDraft(peer?.name ?? '')
2850
+ }, [renaming, peer?.id, peer?.name])
2851
+
2852
+ React.useEffect(() => {
2853
+ if (!menu && !targetOpen) return undefined
2854
+ const close = (event) => {
2855
+ if (menu && menuRef.current !== null && !menuRef.current.contains(event.target)) setMenu(false)
2856
+ if (targetOpen && targetRef.current !== null && !targetRef.current.contains(event.target)) setTargetOpen(false)
2857
+ }
2858
+ document.addEventListener('mousedown', close)
2859
+ return () => document.removeEventListener('mousedown', close)
2860
+ }, [menu, targetOpen])
2861
+
2862
+ const list = view === 'peer' ? (Array.isArray(sessions) ? sessions : []) : (Array.isArray(localSessions) ? localSessions : [])
2863
+ const loading = busy || (view === 'peer' ? sessions === null || sessions === undefined : localSessions === null || localSessions === undefined)
2864
+ const importedCount = list.filter((session) => session.imported !== null && session.imported !== undefined).length
2865
+ const freshCount = list.length - importedCount
2866
+ const needle = query.trim().toLowerCase()
2867
+ const visible = list
2868
+ .filter((session) => {
2869
+ if (view === 'peer') {
2870
+ if (filter === 'new' && session.imported !== null && session.imported !== undefined) return false
2871
+ if (filter === 'done' && (session.imported === null || session.imported === undefined)) return false
2872
+ }
2873
+ if (needle === '') return true
2874
+ return `${session.title ?? ''} ${sessionTag(session)} ${session.cwd ?? ''}`.toLowerCase().includes(needle)
2875
+ })
2876
+ .sort((a, b) => {
2877
+ if (sort === 'title') return (a.title ?? '').localeCompare(b.title ?? '')
2878
+ return (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)
2879
+ })
2880
+ const runningBySession = new Map(queue.map((item) => [item.sessionId, item.status]))
2881
+ const allSelected = visible.length > 0 && visible.every((session) => selected.includes(session.id))
2882
+ const count = selected.length
2883
+ const countLabel = count === 0
2884
+ ? ''
2885
+ : t('sessions.selected', { n: count })
2886
+ const targetLabel = target?.label
2887
+ ?? (typeof syncDir === 'string' && syncDir.length > 0 ? baseName(syncDir) : t('settings.unsortedShort'))
2888
+ const currentPath = target?.path ?? syncDir ?? ''
2889
+
2890
+ const commitRename = () => {
2891
+ const name = draft.trim()
2892
+ if (name.length > 0 && peer !== null && peer !== undefined) onRename(peer, name)
2893
+ setRenaming(false)
2894
+ }
2895
+
2896
+ const pullOne = (session) => onPull(session)
2897
+ const pushOne = (session) => onPush(session)
2898
+ const startSelected = () => {
2899
+ if (view === 'peer') onPull(selected)
2900
+ else onPush(selected)
2901
+ }
2902
+
2903
+ const statusChip = (session) => {
2904
+ if (view === 'local') return null
2905
+ const queueStatus = runningBySession.get(session.id)
2906
+ if (queueStatus === 'running' || queueStatus === 'pending') {
2907
+ return h('span', { className: `ss-chip${queueStatus === 'running' ? ' brand' : ''}` },
2908
+ queueStatus === 'running' ? h('span', { className: 'ss-spinner', style: { width: 10, height: 10 } }) : null,
2909
+ t(queueStatus === 'running' ? 'sessions.pulling' : 'sessions.queued'),
2910
+ )
2911
+ }
2912
+ const digest = digests?.[session.id] ?? null
2913
+ if (digest?.busy === true) {
2914
+ return h('span', { className: 'ss-chip brand' }, h('span', { className: 'ss-spinner', style: { width: 10, height: 10 } }), t('sessions.verifying'))
2915
+ }
2916
+ if (digest?.verified === false) {
2917
+ return h('span', { className: 'ss-chip bad' }, h(Icon, { name: 'alert', size: 11 }), t('sessions.verifyFailedShort'))
2918
+ }
2919
+ if (session.imported !== null && session.imported !== undefined) {
2920
+ return h('span', { className: 'ss-chip ok' }, h(Icon, { name: 'check', size: 11 }), t('sessions.synced'))
2921
+ }
2922
+ return h('span', { className: 'ss-chip' }, t('sessions.notSynced'))
2923
+ }
2924
+
2925
+ const digestStrip = (session) => {
2926
+ const digest = digests?.[session.id]
2927
+ if (digest === undefined || digest === null) return null
2928
+ if (digest.busy === true) {
2929
+ return h('div', { className: 'ss-digest' },
2930
+ h('span', { className: 'ss-spinner', style: { width: 12, height: 12 } }),
2931
+ h('span', null, t('sessions.verifying')),
2932
+ )
2933
+ }
2934
+ if (typeof digest.sha256 !== 'string' || digest.sha256.length === 0) return null
2935
+ const bad = digest.verified === false
2936
+ const ok = digest.verified === true
2937
+ return h('div', { className: `ss-digest${ok ? ' ok' : ''}${bad ? ' bad' : ''}` },
2938
+ h(Icon, { name: bad ? 'alert' : 'check', size: 14 }),
2939
+ h('div', { className: 'grow' },
2940
+ h('code', { title: digest.sha256 }, t(
2941
+ digest.verified === null ? 'sessions.noDigest' : bad ? 'sessions.verifyFailed' : 'sessions.verified',
2942
+ { hash: bad ? digest.sha256 : `${digest.sha256.slice(0, 12)}…` },
2943
+ )),
2944
+ ),
2945
+ h('button', {
2946
+ type: 'button',
2947
+ className: 'ss-btn ghost xs',
2948
+ title: t('sessions.hashHint'),
2949
+ onClick: (event) => { event.stopPropagation(); onCopyDigest(session) },
2950
+ }, t('sessions.copyHash')),
2951
+ bad ? h('button', {
2952
+ type: 'button',
2953
+ className: 'ss-btn ghost xs',
2954
+ onClick: (event) => { event.stopPropagation(); onVerify(session) },
2955
+ }, t('sessions.verify')) : null,
2956
+ )
2957
+ }
2958
+
2959
+ const actions = (session) => {
2960
+ const digest = digests?.[session.id] ?? null
2961
+ if (view === 'local') {
2962
+ return h('div', { className: 'ss-row-act' },
2963
+ h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: (event) => { event.stopPropagation(); pushOne(session) } }, t('sessions.pushShort')),
2964
+ )
2965
+ }
2966
+ const imported = session.imported !== null && session.imported !== undefined
2967
+ return h('div', { className: 'ss-row-act' },
2968
+ imported
2969
+ ? h('button', { type: 'button', className: 'ss-btn ghost xs', disabled: digest?.busy === true, onClick: (event) => { event.stopPropagation(); onVerify(session) } }, t('sessions.verify'))
2970
+ : h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: (event) => { event.stopPropagation(); pullOne(session) } }, t('sessions.pullShort')),
2971
+ imported
2972
+ ? h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: (event) => { event.stopPropagation(); onForgetImport(session) } }, t('common.clear'))
2973
+ : h('button', { type: 'button', className: 'ss-btn ghost xs', disabled: digest?.busy === true, onClick: (event) => { event.stopPropagation(); onVerify(session) } }, t('sessions.verify')),
2974
+ )
2975
+ }
2976
+
2977
+ const onListKeyDown = (event) => {
2978
+ const tag = event.target?.tagName
2979
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
2980
+ if (visible.length === 0) return
2981
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2982
+ event.preventDefault()
2983
+ setCursor((current) => (event.key === 'ArrowDown'
2984
+ ? Math.min(visible.length - 1, current + 1)
2985
+ : Math.max(0, current - 1)))
2986
+ return
2987
+ }
2988
+ const session = visible[Math.max(0, cursor)]
2989
+ if (session === undefined) return
2990
+ if (event.key === ' ') {
2991
+ event.preventDefault()
2992
+ onToggle(session.id, event)
2993
+ return
2994
+ }
2995
+ if (event.key === 'Enter') {
2996
+ event.preventDefault()
2997
+ if (view === 'peer') pullOne(session)
2998
+ else pushOne(session)
2999
+ }
3000
+ }
3001
+
3002
+ const targetOption = (label, path, value, note) => h('button', {
3003
+ key: `${label}:${path}`,
3004
+ type: 'button',
3005
+ className: 'ss-opt',
3006
+ role: 'menuitemradio',
3007
+ 'aria-checked': currentPath === path ? 'true' : 'false',
3008
+ title: path,
3009
+ onClick: () => { onTarget(value); setTargetOpen(false) },
3010
+ },
3011
+ h('span', { className: 'ss-opt-main' },
3012
+ h('span', { className: 'ss-opt-name' }, label, currentPath === path ? h('span', { className: 'ss-chip ok' }, t('settings.current')) : null),
3013
+ h('span', { className: 'ss-opt-path' }, path || note),
3014
+ ),
3015
+ currentPath === path ? h(Icon, { name: 'check', size: 14, className: 'check' }) : null,
3016
+ )
3017
+
3018
+ const menuButton = (label, action, danger) => h('button', {
3019
+ key: action,
3020
+ type: 'button',
3021
+ className: `ss-menu-item${danger ? ' danger' : ''}`,
3022
+ onClick: () => { setMenu(false); onAction(action, peer) },
3023
+ }, label)
3024
+
3025
+ return h('section', { className: 'ss-stage' },
3026
+ h('header', { className: 'ss-stage-hd' },
3027
+ h('div', { className: 'ss-stage-id' },
3028
+ h('span', { className: `ss-dot${peer.online === true ? ' on' : ' off'}` }),
3029
+ h('div', { style: { minWidth: 0 } },
3030
+ renaming
3031
+ ? h('div', { className: 'ss-inline' },
3032
+ h('input', {
3033
+ className: 'ss-input',
3034
+ value: draft,
3035
+ autoFocus: true,
3036
+ placeholder: peer.remoteName ?? t('device.namePlaceholder'),
3037
+ 'aria-label': t('device.rename'),
3038
+ onChange: (event) => setDraft(event.target.value),
3039
+ onKeyDown: (event) => {
3040
+ if (event.key === 'Enter') commitRename()
3041
+ if (event.key === 'Escape') setRenaming(false)
3042
+ },
3043
+ }),
3044
+ h('button', { type: 'button', className: 'ss-btn primary sm', onClick: commitRename }, t('common.save')),
3045
+ h('button', { type: 'button', className: 'ss-btn ghost sm', onClick: () => setRenaming(false) }, t('common.cancel')),
3046
+ )
3047
+ : h('div', { className: 'ss-stage-name', title: peer.name }, peer.name ?? peer.address ?? peer.id),
3048
+ h('div', { className: 'ss-stage-meta' },
3049
+ h('code', null, `${peer.address ?? '—'}:${peer.port ?? '—'}`),
3050
+ h('span', { className: 'ss-sep' }, '·'),
3051
+ h('span', null, h('b', null, String(peer.sessions ?? list.length)), ' ', t('device.sessionsUnit')),
3052
+ peer.platform === undefined ? null : h('span', { className: 'ss-sep' }, '·'),
3053
+ peer.platform === undefined ? null : h('span', null, peer.platform),
3054
+ peer.version === undefined ? null : h('span', { className: 'ss-sep' }, '·'),
3055
+ peer.version === undefined ? null : h('span', null, `v${peer.version}`),
3056
+ ),
3057
+ ),
3058
+ ),
3059
+ h('div', { className: 'ss-stage-actions' },
3060
+ view === 'peer'
3061
+ ? h('button', {
3062
+ type: 'button',
3063
+ className: 'ss-btn primary sm',
3064
+ disabled: busy || peer.online !== true,
3065
+ onClick: onPullAll,
3066
+ }, h(Icon, { name: 'pull', size: 14 }), t('device.pullAll'))
3067
+ : null,
3068
+ h('div', { className: 'ss-menu-wrap', ref: menuRef },
3069
+ h('button', {
3070
+ type: 'button',
3071
+ className: 'ss-icon sm',
3072
+ title: t('device.menu'),
3073
+ 'aria-label': t('device.menu'),
3074
+ 'aria-expanded': menu ? 'true' : 'false',
3075
+ onClick: () => setMenu((value) => !value),
3076
+ }, h(Icon, { name: 'more', size: 14 })),
3077
+ menu ? h('div', { className: 'ss-menu', role: 'menu' },
3078
+ menuButton(t('device.pushHere'), 'push'),
3079
+ h('button', {
3080
+ type: 'button',
3081
+ className: 'ss-menu-item',
3082
+ onClick: () => { setMenu(false); setRenaming(true); setDraft(peer.name ?? '') },
3083
+ }, t('device.rename')),
3084
+ menuButton(t('device.copyAddress'), 'copy'),
3085
+ menuButton(t('device.refresh'), 'refresh'),
3086
+ menuButton(t('device.forget'), 'forget', true),
3087
+ ) : null,
3088
+ ),
3089
+ ),
3090
+ ),
3091
+
3092
+ h('div', { className: 'ss-tabs', role: 'tablist' },
3093
+ h('button', {
3094
+ type: 'button', role: 'tab', className: 'ss-tab', 'aria-selected': view === 'peer' ? 'true' : 'false',
3095
+ onClick: () => onView('peer'),
3096
+ }, h(Icon, { name: 'pull', size: 14 }), t('dir.pull'), h('b', null, String(peer.sessions ?? sessions?.length ?? 0))),
3097
+ h('button', {
3098
+ type: 'button', role: 'tab', className: 'ss-tab', 'aria-selected': view === 'local' ? 'true' : 'false',
3099
+ onClick: () => onView('local'),
3100
+ }, h(Icon, { name: 'push', size: 14 }), t('dir.push'), h('b', null, String(localSessions?.length ?? 0))),
3101
+ ),
3102
+
3103
+ view === 'local'
3104
+ ? h('div', { className: 'ss-note-row' },
3105
+ h(Icon, { name: 'arrowRight', size: 12 }),
3106
+ h('span', null, t('stage.pushHint', { peer: peer.name })),
3107
+ )
3108
+ : null,
3109
+
3110
+ h('div', { className: 'ss-tools' },
3111
+ h(RowCheck, {
3112
+ checked: allSelected,
3113
+ label: t('sessions.selectAll'),
3114
+ onChange: () => onToggleAll(visible.map((session) => session.id)),
3115
+ }),
3116
+ h('div', { className: 'ss-search' },
3117
+ h(Icon, { name: 'search', size: 14 }),
3118
+ h('input', {
3119
+ ref: searchRef,
3120
+ type: 'text',
3121
+ value: query,
3122
+ spellCheck: false,
3123
+ placeholder: t('sessions.search'),
3124
+ 'aria-label': t('sessions.search'),
3125
+ onChange: (event) => { setQuery(event.target.value); setCursor(-1) },
3126
+ }),
3127
+ h('kbd', null, '/'),
3128
+ ),
3129
+ view === 'peer'
3130
+ ? h('div', { className: 'ss-filters', role: 'group' },
3131
+ h('button', { type: 'button', className: 'ss-filter', 'aria-pressed': filter === 'all' ? 'true' : 'false', onClick: () => { setFilter('all'); setCursor(-1) } }, t('sessions.filterAll', { n: list.length })),
3132
+ h('button', { type: 'button', className: 'ss-filter', 'aria-pressed': filter === 'new' ? 'true' : 'false', onClick: () => { setFilter('new'); setCursor(-1) } }, t('sessions.filterNew', { n: freshCount })),
3133
+ h('button', { type: 'button', className: 'ss-filter', 'aria-pressed': filter === 'done' ? 'true' : 'false', onClick: () => { setFilter('done'); setCursor(-1) } }, t('sessions.filterDone', { n: importedCount })),
3134
+ )
3135
+ : null,
3136
+ h('button', {
3137
+ type: 'button',
3138
+ className: 'ss-btn ghost sm ss-sort',
3139
+ title: t('sessions.focusHint'),
3140
+ onClick: () => setSort((value) => (value === 'time' ? 'title' : 'time')),
3141
+ }, t(sort === 'time' ? 'sessions.sortTime' : 'sessions.sortTitle'), h(Icon, { name: 'chevronDown', size: 14 })),
3142
+ ),
3143
+
3144
+ h('div', { className: 'ss-list', role: 'listbox', 'aria-multiselectable': 'true', onKeyDown: onListKeyDown },
3145
+ loading
3146
+ ? h('div', { className: 'ss-empty' }, t('common.loading'))
3147
+ : visible.length === 0
3148
+ ? h('div', { className: 'ss-empty' },
3149
+ h('span', { className: 'glyph' }, h(Icon, { name: 'search', size: 18 })),
3150
+ h('h3', null, t('sessions.empty')),
3151
+ h('p', null, needle.length > 0 || filter !== 'all' ? t('sessions.emptyFiltered') : t('sessions.emptyBody')),
3152
+ )
3153
+ : visible.map((session, index) => {
3154
+ const digest = digests?.[session.id] ?? null
3155
+ const isSelected = selected.includes(session.id)
3156
+ const imported = session.imported !== null && session.imported !== undefined
3157
+ return h(React.Fragment, { key: session.id },
3158
+ h('div', {
3159
+ className: `ss-row${view === 'local' ? ' push' : ''}${isSelected ? ' selected' : ''}${cursor === index ? ' cursor' : ''}`,
3160
+ role: 'option',
3161
+ 'aria-selected': isSelected ? 'true' : 'false',
3162
+ tabIndex: 0,
3163
+ draggable: true,
3164
+ onDragStart: (event) => dragStart?.(event, session),
3165
+ onDragEnd: () => dragStore.clear(),
3166
+ onFocus: () => setCursor(index),
3167
+ onClick: (event) => {
3168
+ if (event.target.closest('button, input, a, select, label, .ss-row-act')) return
3169
+ if (String(window.getSelection?.() ?? '').length > 0) return
3170
+ if (isSelected && selected.length === 1 && event.metaKey !== true && event.ctrlKey !== true && event.shiftKey !== true) {
3171
+ onClearSelection()
3172
+ return
3173
+ }
3174
+ onToggle(session.id, event)
3175
+ },
3176
+ },
3177
+ h(RowCheck, {
3178
+ checked: isSelected,
3179
+ label: `${session.title || t('sessions.untitled')} #${sessionTag(session)}`,
3180
+ onChange: () => onToggle(session.id),
3181
+ }),
3182
+ h('div', { className: 'ss-row-main' },
3183
+ h('div', { className: 'ss-row-title' }, session.title || t('sessions.untitled')),
3184
+ h('div', { className: 'ss-row-meta' },
3185
+ h('code', null, `#${sessionTag(session)}`),
3186
+ h('span', { className: 'ss-sep' }, '·'),
3187
+ h('span', null, shortDate(session.updatedAt ?? session.createdAt)),
3188
+ session.cwd ? h('span', { className: 'ss-sep' }, '·') : null,
3189
+ session.cwd ? h('span', { className: 'ss-path', title: session.cwd }, session.cwd) : null,
3190
+ typeof session.events === 'number' ? h('span', { className: 'ss-sep' }, '·') : null,
3191
+ typeof session.events === 'number' ? h('span', null, t('sessions.events', { n: session.events })) : null,
3192
+ ),
3193
+ ),
3194
+ h('div', { className: 'ss-row-trail' }, statusChip(session)),
3195
+ actions(session),
3196
+ ),
3197
+ digestStrip(session),
3198
+ )
3199
+ }),
3200
+ ),
3201
+
3202
+ count > 0
3203
+ ? h('div', { className: 'ss-selbar' },
3204
+ h('span', { className: 'count' }, t('sessions.selected', { n: count })),
3205
+ view === 'peer'
3206
+ ? h('div', { className: 'ss-target-wrap', ref: targetRef },
3207
+ h('button', {
3208
+ type: 'button',
3209
+ className: 'ss-target',
3210
+ 'aria-expanded': targetOpen ? 'true' : 'false',
3211
+ 'aria-haspopup': 'menu',
3212
+ onClick: () => setTargetOpen((value) => !value),
3213
+ },
3214
+ h('span', null, t('sessions.pullTo')),
3215
+ h('span', null, targetLabel),
3216
+ h(Icon, { name: 'chevronDown', size: 14 }),
3217
+ ),
3218
+ targetOpen ? h('div', { className: 'ss-popover', role: 'menu' },
3219
+ h('div', { className: 'ss-pop-hd' }, h('span', null, t('target.title')), h('span', { style: { flex: 1 } }), h('span', null, t('target.count', { n: 1 + workspaces.length + extraPaths.length }))),
3220
+ targetOption(t('settings.unsortedShort'), syncDir || t('settings.syncUnsorted'), null, t('settings.syncUnsorted')),
3221
+ workspaces.map((workspace) => targetOption(workspace.title, workspace.path, { path: workspace.path, workspaceId: workspace.id, label: workspace.title }, workspace.path)),
3222
+ extraPaths.map((path) => targetOption(baseName(path), path, { path, label: baseName(path) }, path)),
3223
+ h('div', { className: 'ss-pop-foot' },
3224
+ h('div', { className: 'ss-inline' },
3225
+ h('input', {
3226
+ className: 'ss-input',
3227
+ value: pathDraft,
3228
+ spellCheck: false,
3229
+ placeholder: t('workspaces.placeholder'),
3230
+ 'aria-label': t('workspaces.add'),
3231
+ onChange: (event) => onPathDraft(event.target.value),
3232
+ onKeyDown: (event) => { if (event.key === 'Enter') onAddPath(pathDraft) },
3233
+ }),
3234
+ h('button', { type: 'button', className: 'ss-btn sm', disabled: pathDraft.trim().length === 0, onClick: () => onAddPath(pathDraft) }, t('common.add')),
3235
+ ),
3236
+ ),
3237
+ h('div', { className: 'ss-pop-note' }, t('target.note')),
3238
+ ) : null,
3239
+ )
3240
+ : h('span', { className: 'ss-chip brand' }, h(Icon, { name: 'push', size: 11 }), peer.name),
3241
+ h('span', { className: 'ss-spacer' }),
3242
+ h('button', { type: 'button', className: 'ss-btn ghost xs', onClick: onClearSelection }, t('sessions.clearSelection')),
3243
+ h('button', { type: 'button', className: 'ss-btn primary sm', onClick: startSelected },
3244
+ h(Icon, { name: view === 'peer' ? 'pull' : 'push', size: 14 }),
3245
+ t(view === 'peer' ? 'sessions.syncSelected' : 'sessions.pushSelected'),
3246
+ ),
3247
+ )
3248
+ : null,
3249
+
3250
+ queue.length > 0 && queueOpen
3251
+ ? h(QueuePanel, { t, queue, progress, onClear: onClearQueue, onRetry })
3252
+ : null,
3253
+
3254
+ queue.length > 0
3255
+ ? h('div', {
3256
+ className: `ss-dock${queueRunning ? ' running' : ''}`,
3257
+ role: 'button',
3258
+ tabIndex: 0,
3259
+ onClick: () => onQueueOpen(!queueOpen),
3260
+ onKeyDown: (event) => {
3261
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onQueueOpen(!queueOpen) }
3262
+ },
3263
+ },
3264
+ queueRunning ? h('span', { className: 'ss-spinner' }) : h(Icon, { name: 'check', size: 14 }),
3265
+ h('div', { className: 'ss-dock-main' },
3266
+ h('b', null, queueRunning ? `${t('queue.running')} ${queueDone}/${queue.length}` : t('queue.title')),
3267
+ activeTitle === null ? null : h('span', { className: 'ss-dock-title' }, activeTitle),
3268
+ h('span', { className: 'ss-dock-pct' }, `${Math.round(barPercent)}%`),
3269
+ ),
3270
+ h('div', { className: 'meter', 'aria-hidden': 'true' }, h('i', { style: { width: `${barPercent}%` } })),
3271
+ h('button', {
3272
+ type: 'button',
3273
+ className: 'ss-btn ghost xs',
3274
+ onClick: (event) => { event.stopPropagation(); onQueueOpen(!queueOpen) },
3275
+ }, queueOpen ? t('queue.close') : t('queue.open')),
3276
+ )
3277
+ : null,
3278
+ )
3279
+ }
3280
+
3281
+ function SettingsView({
3282
+ t, surface, busy, self, platform, workspaces, selfAddress, selfName, onSelfNameChange, onSaveSelfName, onCopySelf,
3283
+ override, changeLanguage, syncDir, syncDraft, onSyncDraft, onSaveSyncDir,
3284
+ autoPull, onToggleAutoPull, concurrency, autoConcurrency, onSaveConcurrency,
3285
+ seeds, onSeeds, onSaveSeeds, subnets, onSubnets, onSaveSubnets,
3286
+ advancedOpen, onAdvancedOpen, ports, probe, onRepair,
3287
+ version, hostVersion, repo, onBack,
3288
+ }) {
3289
+ const isPanel = surface === 'panel'
3290
+ const row = (label, note, ...body) => h('div', { className: 'ss-field' },
3291
+ h('div', { className: 'ss-field-text' },
3292
+ h('div', { className: 'ss-field-label' }, label),
3293
+ note === null || note === undefined ? null : h('div', { className: 'ss-field-note' }, note),
3294
+ ),
3295
+ h('div', { className: 'ss-field-body' }, ...body),
3296
+ )
3297
+ const section = (title, ...content) => h('section', { className: 'ss-set-sec' },
3298
+ h('h3', null, title),
3299
+ h('div', { className: 'ss-set-card' }, ...content),
3300
+ )
3301
+ const identity = section(t('settings.identity'),
3302
+ row(t('self.name'), t('self.hint'),
3303
+ h('input', {
3304
+ className: 'ss-input',
3305
+ style: { width: 214 },
3306
+ value: selfName,
3307
+ spellCheck: false,
3308
+ 'aria-label': t('self.name'),
3309
+ onChange: (event) => onSelfNameChange(event.target.value),
3310
+ onKeyDown: (event) => { if (event.key === 'Enter') onSaveSelfName() },
3311
+ }),
3312
+ h('button', { type: 'button', className: 'ss-btn primary sm', disabled: busy || selfName.trim().length === 0, onClick: onSaveSelfName }, t('common.save')),
3313
+ ),
3314
+ row(t('settings.address'), selfAddress || '—',
3315
+ h('button', { type: 'button', className: 'ss-btn sm', onClick: onCopySelf }, t('common.copy')),
3316
+ ),
3317
+ row(t('settings.platform'), null,
3318
+ h('span', { className: 'ss-chip mono' }, platform ?? '—'),
3319
+ h('span', { className: 'ss-chip mono' }, `v${version}`),
3320
+ ),
3321
+ )
3322
+ const transfer = section(t('settings.transfer'),
3323
+ row(t('settings.language'), t('settings.languageHint'),
3324
+ h('select', {
3325
+ className: 'ss-select',
3326
+ value: override,
3327
+ disabled: busy,
3328
+ 'aria-label': t('settings.language'),
3329
+ onChange: (event) => changeLanguage(event.target.value),
3330
+ },
3331
+ h('option', { value: 'auto' }, t('settings.languageAuto')),
3332
+ h('option', { value: 'zh' }, '中文'),
3333
+ h('option', { value: 'en' }, 'English'),
3334
+ ),
3335
+ ),
3336
+ row(t('settings.syncDir'), t('settings.syncDirHint'),
3337
+ h('select', {
3338
+ className: 'ss-select',
3339
+ value: syncDir,
3340
+ disabled: busy,
3341
+ 'aria-label': t('settings.syncDir'),
3342
+ onChange: (event) => { if (event.target.value !== '__custom__') onSaveSyncDir(event.target.value) },
3343
+ },
3344
+ h('option', { value: '' }, t('settings.syncUnsorted')),
3345
+ (workspaces ?? []).map((workspace) => h('option', { key: workspace.id, value: workspace.path }, workspace.title)),
3346
+ syncDir !== '' && !(workspaces ?? []).some((workspace) => workspace.path === syncDir)
3347
+ ? h('option', { value: syncDir }, baseName(syncDir))
3348
+ : null,
3349
+ h('option', { value: '__custom__' }, t('settings.syncCustom')),
3350
+ ),
3351
+ ),
3352
+ row(t('settings.syncCustom'), t('settings.syncCustomPlaceholder'),
3353
+ h('input', {
3354
+ className: 'ss-input',
3355
+ style: { width: 214 },
3356
+ value: syncDraft,
3357
+ spellCheck: false,
3358
+ placeholder: t('settings.syncCustomPlaceholder'),
3359
+ 'aria-label': t('settings.syncCustom'),
3360
+ onChange: (event) => onSyncDraft(event.target.value),
3361
+ onKeyDown: (event) => { if (event.key === 'Enter') onSaveSyncDir(syncDraft.trim()) },
3362
+ }),
3363
+ h('button', { type: 'button', className: 'ss-btn sm', disabled: busy || syncDraft.trim().length === 0, onClick: () => onSaveSyncDir(syncDraft.trim()) }, t('settings.syncDirSave')),
3364
+ ),
3365
+ row(t('settings.autoPull'), t('settings.autoPullHint'),
3366
+ h('button', {
3367
+ type: 'button',
3368
+ className: 'ss-switch',
3369
+ role: 'switch',
3370
+ 'aria-checked': autoPull ? 'true' : 'false',
3371
+ 'aria-label': t('settings.autoPullLabel'),
3372
+ disabled: busy,
3373
+ onClick: () => onToggleAutoPull(!autoPull),
3374
+ }),
3375
+ ),
3376
+ row(t('settings.concurrency'), t('settings.concurrencyHint'),
3377
+ h('select', {
3378
+ className: 'ss-select',
3379
+ value: String(concurrency ?? 0),
3380
+ disabled: busy,
3381
+ 'aria-label': t('settings.concurrency'),
3382
+ onChange: (event) => onSaveConcurrency(Number(event.target.value)),
3383
+ },
3384
+ h('option', { value: '0' }, t('settings.concurrencyAuto', { n: autoConcurrency ?? 5 })),
3385
+ h('optgroup', { label: t('settings.concurrencyManual') },
3386
+ Array.from({ length: 16 }, (_, index) => index + 1).map((value) => h('option', { key: value, value: String(value) }, String(value))),
3387
+ )),
3388
+ ),
3389
+ )
3390
+ const advanced = section(t('settings.advanced'),
3391
+ h('button', {
3392
+ type: 'button',
3393
+ className: 'ss-adv-toggle',
3394
+ 'aria-expanded': advancedOpen ? 'true' : 'false',
3395
+ onClick: () => onAdvancedOpen(!advancedOpen),
3396
+ },
3397
+ h(Icon, { name: 'chevronRight', size: 14 }),
3398
+ h('span', null, t('settings.advancedHint')),
3399
+ ),
3400
+ advancedOpen ? h(React.Fragment, null,
3401
+ row(t('settings.ports'), t('page.ports', { lan: ports?.lan ?? '—', discovery: ports?.discovery ?? '—' }), null),
3402
+ h('div', { className: 'ss-field stack' },
3403
+ h('div', { className: 'ss-field-text' },
3404
+ h('div', { className: 'ss-field-label' }, t('settings.seeds')),
3405
+ h('div', { className: 'ss-field-note' }, t('settings.seedsHint')),
3406
+ ),
3407
+ h(TokenEditor, {
3408
+ t,
3409
+ value: seeds,
3410
+ placeholder: '192.168.1.20:47801',
3411
+ onChange: onSeeds,
3412
+ onSave: onSaveSeeds,
3413
+ busy,
3414
+ }),
3415
+ ),
3416
+ h('div', { className: 'ss-field stack' },
3417
+ h('div', { className: 'ss-field-text' },
3418
+ h('div', { className: 'ss-field-label' }, t('settings.subnets')),
3419
+ h('div', { className: 'ss-field-note' }, t('settings.subnetsHint')),
3420
+ ),
3421
+ h(TokenEditor, {
3422
+ t,
3423
+ value: subnets,
3424
+ placeholder: '192.168.5.0/24',
3425
+ onChange: onSubnets,
3426
+ onSave: onSaveSubnets,
3427
+ busy,
3428
+ }),
3429
+ probe !== undefined && probe.targets > 0 && probe.at > 0
3430
+ ? h('div', { className: 'ss-field-note', style: { marginTop: 8 } }, t('settings.probeStatus', { at: shortClock(probe.at), n: probe.targets, found: probe.found }))
3431
+ : null,
3432
+ ),
3433
+ row(t('settings.repair'), t('settings.repairHint'),
3434
+ h('button', { type: 'button', className: 'ss-btn sm', disabled: busy, onClick: onRepair }, t('settings.repairAction')),
3435
+ ),
3436
+ ) : null,
3437
+ )
3438
+ const about = section(t('settings.about'),
3439
+ row(t('settings.version'), null, h('span', { className: 'ss-chip mono' }, version)),
3440
+ hostVersion === undefined ? null : row(t('settings.hostVersion'), null, h('span', { className: 'ss-chip mono' }, hostVersion)),
3441
+ row(t('footer.repo'), null, h('a', {
3442
+ className: 'ss-btn ghost sm',
3443
+ href: repo ?? 'https://github.com/XXGxiaoyu/dsh-session-sync',
3444
+ target: '_blank',
3445
+ rel: 'noreferrer noopener',
3446
+ style: { textDecoration: 'none' },
3447
+ }, t('common.open'), h(Icon, { name: 'external', size: 14 }))),
3448
+ row(t('footer.license'), null, h('span', { className: 'ss-chip mono' }, 'MIT')),
3449
+ )
3450
+ return h('div', { className: `ss-settings${isPanel ? ' cols' : ''}` },
3451
+ isPanel
3452
+ ? h('div', { className: 'ss-set-view-hd' },
3453
+ h('h2', null, t('settings.title')),
3454
+ h('span', null, t('settings.intro')),
3455
+ h('span', { className: 'ss-spacer' }),
3456
+ h('button', { type: 'button', className: 'ss-btn sm', onClick: onBack },
3457
+ h(Icon, { name: 'arrowLeft', size: 14 }),
3458
+ h('span', null, t('settings.backToDesk')),
3459
+ ),
3460
+ )
3461
+ : h('div', { className: 'ss-set-hd' },
3462
+ h('h2', { style: { fontSize: 20, lineHeight: '28px' } }, t('page.title')),
3463
+ ),
3464
+ isPanel ? null : h('p', { className: 'ss-set-sub' }, t('settings.intro')),
3465
+ h('div', { className: 'ss-settings-in' },
3466
+ h('div', { className: 'ss-set-col' }, identity, transfer),
3467
+ h('div', { className: 'ss-set-col' }, advanced, about),
3468
+ ),
3469
+ )
3470
+ }
3471
+
3472
+ function SettingsPage({ t, surface = 'panel' }) {
3473
+ const snapshot = useSnapshot(true)
3474
+ const snapshotRef = React.useRef(snapshot)
3475
+ snapshotRef.current = snapshot
3476
+ const [busy, setBusy] = React.useState(false)
3477
+ const [notice, setNotice] = React.useState(null)
3478
+ const [selfName, setSelfName] = React.useState('')
3479
+ const selfDirty = React.useRef(false)
3480
+ const [seeds, setSeeds] = React.useState('')
3481
+ const seedsDirty = React.useRef(false)
3482
+ const [subnets, setSubnets] = React.useState('')
3483
+ const subnetsDirty = React.useRef(false)
3484
+ const [syncDraft, setSyncDraft] = React.useState('')
3485
+ const syncDraftDirty = React.useRef(false)
3486
+ const [openPeer, setOpenPeer] = React.useState(null)
3487
+ const [view, setView] = React.useState('peer')
3488
+ const [peerSessions, setPeerSessions] = React.useState({})
3489
+ const [localSessions, setLocalSessions] = React.useState(null)
3490
+ const [pushTarget, setPushTarget] = React.useState(null)
3491
+ const [digests, setDigests] = React.useState({})
3492
+ const [selected, setSelected] = React.useState([])
3493
+ const [plan, setPlan] = React.useState(null)
3494
+ const [queue, setQueue] = React.useState([])
3495
+ const [queueOpen, setQueueOpen] = React.useState(false)
3496
+ const [settingsOpen, setSettingsOpen] = React.useState(surface !== 'panel')
3497
+ const [target, setTarget] = React.useState(null)
3498
+ const [advancedOpen, setAdvancedOpen] = React.useState(surface === 'panel')
3499
+ const [manualAddress, setManualAddress] = React.useState('')
3500
+ const [dropPeer, setDropPeer] = React.useState(null)
3501
+ const [dropPath, setDropPath] = React.useState(null)
3502
+ const [extraPaths, setExtraPaths] = React.useState([])
3503
+ const [pathDraft, setPathDraft] = React.useState('')
3504
+ const anchor = React.useRef(null)
3505
+ const queueRef = React.useRef([])
3506
+ const running = React.useRef(false)
3507
+ const override = useOverride()
3508
+
3509
+ React.useEffect(() => {
3510
+ const onKey = (event) => {
3511
+ const tag = typeof event.target?.tagName === 'string' ? event.target.tagName : ''
3512
+ const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
3513
+ if (event.key === '/' && !typing) {
3514
+ if (focusSearch.current !== null) {
3515
+ event.preventDefault()
3516
+ focusSearch.current()
3517
+ }
3518
+ return
3519
+ }
3520
+ if (event.key === 'Escape') {
3521
+ setSelected([])
3522
+ setQueueOpen(false)
3523
+ return
3524
+ }
3525
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a' && !typing && openPeer !== null) {
3526
+ const sessions = peerSessions[openPeer]
3527
+ if (Array.isArray(sessions)) {
3528
+ event.preventDefault()
3529
+ setSelected(sessions.map((session) => session.id))
3530
+ }
3531
+ }
3532
+ }
3533
+ document.addEventListener('keydown', onKey)
3534
+ return () => document.removeEventListener('keydown', onKey)
3535
+ }, [openPeer, peerSessions])
3536
+
3537
+ const queueWrite = React.useCallback((updater) => {
3538
+ const next = typeof updater === 'function' ? updater(queueRef.current) : updater
3539
+ queueRef.current = next
3540
+ setQueue(next)
3541
+ }, [])
3542
+
3543
+ const self = snapshot.self
3544
+ // Background polling must never overwrite what the user is typing: the
3545
+ // text fields only adopt the host value until they are touched.
3546
+ React.useEffect(() => {
3547
+ if (!selfDirty.current && self !== null && self !== undefined) setSelfName(self.name)
3548
+ }, [self?.id, self?.name])
3549
+ const seedsText = (snapshot.settings?.seeds ?? []).join('\n')
3550
+ React.useEffect(() => {
3551
+ if (!seedsDirty.current) setSeeds(seedsText)
3552
+ }, [seedsText])
3553
+ const subnetsText = (snapshot.settings?.subnets ?? []).join('\n')
3554
+ React.useEffect(() => {
3555
+ if (!subnetsDirty.current) setSubnets(subnetsText)
3556
+ }, [subnetsText])
3557
+ const syncDir = typeof snapshot.settings?.syncDir === 'string' ? snapshot.settings.syncDir : ''
3558
+ React.useEffect(() => {
3559
+ if (!syncDraftDirty.current) setSyncDraft(syncDir)
3560
+ }, [syncDir])
3561
+ React.useEffect(() => {
3562
+ const stored = snapshot.settings?.locale
3563
+ if (typeof stored === 'string' && stored !== override) writeOverride(stored)
3564
+ }, [snapshot.settings?.locale, override])
3565
+
3566
+ // Local sessions give both the push source and the workspace folders that
3567
+ // accept a pull (a dropped session lands under that folder).
3568
+ React.useEffect(() => {
3569
+ let alive = true
3570
+ api('localSessions', { limit: 80 })
3571
+ .then((body) => { if (alive) setLocalSessions(body.sessions ?? []) })
3572
+ .catch(() => undefined)
3573
+ return () => { alive = false }
3574
+ }, [])
3575
+
3576
+ React.useEffect(() => { setSelected([]) }, [openPeer])
3577
+
3578
+ const selectPeer = (peer) => {
3579
+ setOpenPeer(peer.id)
3580
+ setView('peer')
3581
+ void loadPeerSessions(peer)
3582
+ }
3583
+
3584
+
3585
+ const run = React.useCallback(async (label, fn) => {
3586
+ setBusy(true)
3587
+ setNotice(null)
3588
+ try {
3589
+ const result = await fn()
3590
+ await snapshot.reload()
3591
+ setNotice({ kind: 'ok', key: label })
3592
+ return result
3593
+ } catch (error) {
3594
+ setNotice({ kind: 'error', text: String(error?.message ?? error) })
3595
+ return undefined
3596
+ } finally {
3597
+ setBusy(false)
3598
+ }
3599
+ }, [snapshot])
3600
+
3601
+ const saveSelfName = () => run('notice.selfNameSaved', async () => {
3602
+ const body = await api('selfName', { name: selfName.trim() })
3603
+ selfDirty.current = false
3604
+ return body
3605
+ })
3606
+ const saveSyncDir = (dir) => run('notice.syncDirSaved', async () => {
3607
+ const body = await api('settings', { patch: { syncDir: dir } })
3608
+ syncDraftDirty.current = false
3609
+ return body
3610
+ })
3611
+ const saveSeeds = () => run('notice.seedsSaved', async () => {
3612
+ const body = await api('settings', {
3613
+ patch: { seeds: seeds.split('\n').map((line) => line.trim()).filter((line) => line.length > 0) },
3614
+ })
3615
+ seedsDirty.current = false
3616
+ return body
3617
+ })
3618
+ const saveSubnets = () => run('notice.subnetsSaved', async () => {
3619
+ const body = await api('settings', {
3620
+ patch: { subnets: subnets.split('\n').map((line) => line.trim()).filter((line) => line.length > 0) },
3621
+ })
3622
+ subnetsDirty.current = false
3623
+ return body
3624
+ })
3625
+ const toggleAutoPull = (enabled) => run('notice.settingsSaved', () => api('settings', { patch: { autoPull: enabled } }))
3626
+ const repairLegacyLogs = async () => {
3627
+ setBusy(true)
3628
+ setNotice(null)
3629
+ try {
3630
+ const body = await api('repairLogs', {})
3631
+ setNotice(body.repaired > 0
3632
+ ? { kind: 'ok', key: 'notice.repairDone', params: { repaired: body.repaired, scanned: body.scanned } }
3633
+ : { kind: 'ok', key: 'notice.repairNone' })
3634
+ await snapshot.reload()
3635
+ } catch (error) {
3636
+ setNotice({ kind: 'error', text: String(error?.message ?? error) })
3637
+ } finally {
3638
+ setBusy(false)
3639
+ }
3640
+ }
3641
+
3642
+ const changeLanguage = (value) => run('notice.languageSaved', async () => {
3643
+ const body = await api('settings', { patch: { locale: value } })
3644
+ writeOverride(body.settings?.locale ?? value)
3645
+ return body
3646
+ })
3647
+
3648
+ const renamePeer = (peer, name) => run('notice.renamed', () => api('rename', { peerId: peer.id, name }))
3649
+ const forgetPeer = (peer) => run('notice.forgot', () => api('forget', { peerId: peer.id }))
3650
+
3651
+ const reloadPeer = React.useCallback(async (peerId) => {
3652
+ const body = await api('peerSessions', { peerId })
3653
+ setPeerSessions((current) => ({ ...current, [peerId]: body.sessions }))
3654
+ return body.sessions
3655
+ }, [])
3656
+
3657
+ const loadPeerSessions = async (peer) => {
3658
+ // Selecting a device never collapses the pane: the list is always the
3659
+ // detail view for whichever device is selected on the left.
3660
+ setOpenPeer(peer.id)
3661
+ if (peerSessions[peer.id] !== undefined) return
3662
+ setBusy(true)
3663
+ try {
3664
+ await reloadPeer(peer.id)
3665
+ } catch (error) {
3666
+ setNotice({ kind: 'error', text: String(error?.message ?? error) })
3667
+ } finally {
3668
+ setBusy(false)
3669
+ }
3670
+ }
3671
+
3672
+ const peerById = (peerId) => (snapshot.peers ?? []).find((peer) => peer.id === peerId) ?? null
3673
+
3674
+ const forgetImport = (peer, session) => run('notice.importCleared', async () => {
3675
+ await api('forgetImport', { peerId: peer.id, sessionId: session.id })
3676
+ setPeerSessions((current) => ({
3677
+ ...current,
3678
+ [peer.id]: (current[peer.id] ?? []).map((entry) => (
3679
+ entry.id === session.id ? { ...entry, imported: null } : entry
3680
+ )),
3681
+ }))
3682
+ })
3683
+
3684
+ const openPush = async (peer) => {
3685
+ setPushTarget(peer.id)
3686
+ if (localSessions !== null) return
3687
+ setBusy(true)
3688
+ try {
3689
+ const body = await api('localSessions', { limit: 80 })
3690
+ setLocalSessions(body.sessions ?? [])
3691
+ } catch (error) {
3692
+ setNotice({ kind: 'error', text: String(error?.message ?? error) })
3693
+ } finally {
3694
+ setBusy(false)
3695
+ }
3696
+ }
3697
+
3698
+ const verifySession = async (peer, session) => {
3699
+ setDigests((current) => ({ ...current, [session.id]: { ...(current[session.id] ?? {}), busy: true } }))
3700
+ try {
3701
+ const body = await api('verify', { peerId: peer.id, sessionId: session.id })
3702
+ setDigests((current) => ({
3703
+ ...current,
3704
+ [session.id]: { sha256: body.sha256, verified: body.verified, localMatch: body.localMatch, busy: false },
3705
+ }))
3706
+ } catch (error) {
3707
+ setDigests((current) => ({ ...current, [session.id]: { sha256: '', verified: false, busy: false, error: String(error?.message ?? error) } }))
3708
+ }
3709
+ }
3710
+
3711
+ const copyDigest = async (session) => {
3712
+ const digest = digests[session.id]?.sha256
3713
+ if (typeof digest !== 'string' || digest.length === 0) return
3714
+ const ok = await copyText(digest)
3715
+ setNotice({ kind: ok ? 'ok' : 'error', key: ok ? 'notice.digestCopied' : 'notice.digestCopyFailed' })
3716
+ }
3717
+
3718
+ // ── selection ───────────────────────────────────────────────────────
3719
+ const toggleSelected = (id, event) => {
3720
+ const list = (peerSessions[openPeer] ?? []).map((session) => session.id)
3721
+ const additive = event === undefined || event.metaKey === true || event.ctrlKey === true || event.shiftKey === true
3722
+ if (event?.shiftKey === true && anchor.current !== null) {
3723
+ const from = list.indexOf(anchor.current)
3724
+ const to = list.indexOf(id)
3725
+ if (from >= 0 && to >= 0) {
3726
+ const range = list.slice(Math.min(from, to), Math.max(from, to) + 1)
3727
+ setSelected((current) => [...new Set([...current, ...range])])
3728
+ return
3729
+ }
3730
+ }
3731
+ anchor.current = id
3732
+ if (!additive) {
3733
+ setSelected([id])
3734
+ return
3735
+ }
3736
+ setSelected((current) => (current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id]))
3737
+ }
3738
+
3739
+ const toggleAll = (ids) => {
3740
+ setSelected((current) => (ids.every((id) => current.includes(id)) ? [] : ids))
3741
+ }
3742
+
3743
+ // ── plans & queue ───────────────────────────────────────────────────
3744
+ const openPullPlan = (peer, ids, target) => {
3745
+ const cwd = typeof target === 'string' ? target : (target?.path ?? null)
3746
+ const workspaceId = typeof target === 'object' && target !== null ? (target.workspaceId ?? null) : null
3747
+ const beforeId = typeof target === 'object' && target !== null ? (target.beforeId ?? null) : null
3748
+ const targetLabel = typeof target === 'object' && target !== null ? (target.label ?? null) : null
3749
+ const sessions = peerSessions[peer.id] ?? []
3750
+ const items = ids
3751
+ .map((id) => sessions.find((session) => session.id === id))
3752
+ .filter((session) => session !== undefined)
3753
+ .map((session) => ({
3754
+ sessionId: session.id,
3755
+ title: session.title || '',
3756
+ dup: (session.imported ?? null) !== null,
3757
+ }))
3758
+ if (items.length === 0) return
3759
+ setPlan({
3760
+ direction: 'pull',
3761
+ peerId: peer.id,
3762
+ peerName: peer.name,
3763
+ cwd: cwd ?? null,
3764
+ workspaceId,
3765
+ beforeId,
3766
+ targetLabel,
3767
+ items,
3768
+ })
3769
+ }
3770
+
3771
+ // The drop layer lives in a different slot; route its drops back into the
3772
+ // page that owns peer sessions and the confirmation dialog.
3773
+ React.useEffect(() => {
3774
+ const releaseHandler = dragStore.setHandler((payload, target) => {
3775
+ const peer = peerById(payload.peerId)
3776
+ if (peer === null) return
3777
+ openPullPlan(peer, payload.ids, target)
3778
+ })
3779
+ // The sidebar rows live in another plugin's slot, so expose the folder
3780
+ // table the document-level drop handler needs to resolve a row.
3781
+ const releaseResolver = sidebarDrop.setResolver(() => ({
3782
+ workspaces: snapshotRef.current.workspaces ?? [],
3783
+ unsortedDir: snapshotRef.current.unsortedDir ?? '',
3784
+ }))
3785
+ return () => {
3786
+ releaseHandler()
3787
+ releaseResolver()
3788
+ }
3789
+ })
3790
+
3791
+ const openPushPlan = (peer, ids) => {
3792
+ const sessions = localSessions ?? []
3793
+ const items = ids
3794
+ .map((id) => sessions.find((session) => session.id === id))
3795
+ .filter((session) => session !== undefined)
3796
+ .map((session) => ({ sessionId: session.id, title: session.title || '', dup: false }))
3797
+ if (items.length === 0) return
3798
+ setPlan({ direction: 'push', peerId: peer.id, peerName: peer.name, cwd: null, items })
3799
+ }
3800
+
3801
+ const updateItem = (key, patch) => queueWrite((current) => current.map((item) => (item.key === key ? { ...item, ...patch } : item)))
3802
+
3803
+ // Claim one pending item atomically, so N workers never pick the same row.
3804
+ const claimNext = () => {
3805
+ let picked = null
3806
+ queueWrite((current) => current.map((item) => {
3807
+ if (picked !== null || item.status !== 'pending') return item
3808
+ picked = { ...item, status: 'running', message: '', startedAt: Date.now() }
3809
+ return picked
3810
+ }))
3811
+ return picked
3812
+ }
3813
+
3814
+ const drain = async () => {
3815
+ if (running.current) return
3816
+ running.current = true
3817
+ const limit = Math.max(1, Number(snapshot.concurrency) || 5)
3818
+ const worker = async () => {
3819
+ for (;;) {
3820
+ const next = claimNext()
3821
+ if (next === null) return
3822
+ try {
3823
+ const body = next.direction === 'pull'
3824
+ ? await api('pull', {
3825
+ peerId: next.peerId,
3826
+ sessionId: next.sessionId,
3827
+ cwd: next.cwd ?? undefined,
3828
+ force: next.force === true,
3829
+ workspace: next.workspaceId ?? undefined,
3830
+ before: next.beforeId ?? undefined,
3831
+ })
3832
+ : await api('push', { peerId: next.peerId, sessionId: next.sessionId, force: next.force === true })
3833
+ const skipped = body?.skipped === true
3834
+ updateItem(next.key, { status: skipped ? 'skipped' : 'done', endedAt: Date.now() })
3835
+ } catch (error) {
3836
+ updateItem(next.key, { status: 'failed', message: String(error?.message ?? error), endedAt: Date.now() })
3837
+ }
3838
+ }
3839
+ }
3840
+ try {
3841
+ await Promise.all(Array.from({ length: limit }, () => worker()))
3842
+ } finally {
3843
+ running.current = false
3844
+ }
3845
+ await snapshot.reload()
3846
+ const currentPeer = openPeer
3847
+ if (currentPeer !== null) {
3848
+ try { await reloadPeer(currentPeer) } catch { /* list refreshes on next open */ }
3849
+ }
3850
+ }
3851
+
3852
+ const saveConcurrency = async (value) => {
3853
+ await run('notice.concurrencySaved', async () => {
3854
+ await api('settings', { patch: { concurrency: value } })
3855
+ return null
3856
+ })
3857
+ }
3858
+
3859
+ const confirmPlan = (choices) => {
3860
+ if (plan === null) return
3861
+ const created = plan.items.map((item, index) => {
3862
+ const choice = item.dup ? (choices[item.sessionId] ?? 'keep') : 'copy'
3863
+ return {
3864
+ key: `${Date.now()}-${index}-${item.sessionId}`,
3865
+ direction: plan.direction,
3866
+ peerId: plan.peerId,
3867
+ peerName: plan.peerName,
3868
+ sessionId: item.sessionId,
3869
+ title: item.title,
3870
+ cwd: plan.cwd,
3871
+ workspaceId: plan.workspaceId ?? null,
3872
+ beforeId: plan.beforeId ?? null,
3873
+ force: choice === 'copy',
3874
+ status: item.dup && choice !== 'copy' ? 'skipped' : 'pending',
3875
+ message: '',
3876
+ }
3877
+ })
3878
+ setPlan(null)
3879
+ queueWrite((current) => [...current, ...created])
3880
+ setNotice({ kind: 'ok', key: 'notice.queued' })
3881
+ void drain()
3882
+ }
3883
+
3884
+ const retryItem = (item) => {
3885
+ updateItem(item.key, { status: 'pending', message: '' })
3886
+ void drain()
3887
+ }
3888
+
3889
+ const clearQueue = () => queueWrite((current) => current.filter((item) => item.status === 'running'))
3890
+
3891
+ // ── drag sources ────────────────────────────────────────────────────
3892
+ const dragPeerSessions = (peer) => (event, session) => {
3893
+ const ids = selected.includes(session.id) && selected.length > 0 ? selected : [session.id]
3894
+ const titles = (peerSessions[peer.id] ?? []).filter((entry) => ids.includes(entry.id)).map((entry) => entry.title || '')
3895
+ beginDrag(event, { direction: 'pull', peerId: peer.id, ids, titles })
3896
+ }
3897
+
3898
+ const dragLocalSessions = (event, session) => {
3899
+ const titles = [session.title || '']
3900
+ beginDrag(event, { direction: 'push', peerId: null, ids: [session.id], titles })
3901
+ }
3902
+
3903
+ const peers = snapshot.peers ?? []
3904
+ // One IP is one device: drop this machine's own address, and collapse peers
3905
+ // that share an address (two profiles on one box) into the freshest row.
3906
+ const byAddress = new Map()
3907
+ for (const peer of peers) {
3908
+ if (peer.sameMachine === true) continue
3909
+ const key = peer.address ?? peer.id
3910
+ const current = byAddress.get(key)
3911
+ if (current === undefined) { byAddress.set(key, peer); continue }
3912
+ const better = (peer.online === true ? 2 : 0) + (peer.lastSeen ?? 0) > (current.online === true ? 2 : 0) + (current.lastSeen ?? 0)
3913
+ if (better) byAddress.set(key, peer)
3914
+ }
3915
+ const remotePeers = [...byAddress.values()]
3916
+ const online = remotePeers.filter((peer) => peer.online).length
3917
+ // The panel opens on a useful screen: the first online device is selected
3918
+ // (and its sessions loaded) instead of showing an empty pane.
3919
+ React.useEffect(() => {
3920
+ if (openPeer !== null) return
3921
+ const first = remotePeers.find((peer) => peer.online) ?? remotePeers[0]
3922
+ if (first === undefined) return
3923
+ setOpenPeer(first.id)
3924
+ void loadPeerSessions(first)
3925
+ }, [remotePeers.length])
3926
+
3927
+ React.useEffect(() => {
3928
+ if (view !== 'local' || localSessions !== null) return
3929
+ let alive = true
3930
+ api('localSessions', { limit: 80 })
3931
+ .then((body) => { if (alive) setLocalSessions(body.sessions ?? []) })
3932
+ .catch(() => {})
3933
+ return () => { alive = false }
3934
+ }, [view, localSessions])
3935
+
3936
+ const imports = snapshot.imports ?? []
3937
+ const cwds = React.useMemo(() => {
3938
+ const map = new Map()
3939
+ const add = (cwd) => {
3940
+ if (typeof cwd !== 'string' || cwd.length === 0) return
3941
+ map.set(cwd, (map.get(cwd) ?? 0) + 1)
3942
+ }
3943
+ for (const session of localSessions ?? []) add(session.cwd)
3944
+ for (const session of Object.values(peerSessions).flat()) if (session !== undefined && session !== null) add(session.cwd)
3945
+ for (const path of extraPaths) if (!map.has(path)) map.set(path, 0)
3946
+ return [...map.entries()].map(([cwd, count]) => ({ cwd, count }))
3947
+ }, [localSessions, peerSessions, extraPaths])
3948
+
3949
+ const openSession = openPeer === null ? null : peerById(openPeer)
3950
+ const selectedPeer = remotePeers.find((peer) => peer.id === openPeer) ?? null
3951
+ const list = openPeer === null ? [] : (peerSessions[openPeer] ?? [])
3952
+ const pushPeer = pushTarget === null ? null : peerById(pushTarget)
3953
+
3954
+ const selfAddress = typeof snapshot.self?.ip === 'string'
3955
+ ? `${snapshot.self.ip}:${snapshot.ports?.lan ?? snapshot.self?.port ?? '—'}`
3956
+ : ''
3957
+ const selfDiscoveryAddress = typeof snapshot.self?.ip === 'string'
3958
+ ? `${snapshot.self.ip}:${snapshot.ports?.discovery ?? '—'}`
3959
+ : ''
3960
+ const queueDone = queue.filter((item) => item.status === 'done' || item.status === 'skipped').length
3961
+ const queueBusy = queue.some((item) => item.status === 'running' || item.status === 'pending')
3962
+ // A 4 s poll makes a live progress bar lie; while transfers run, ask the
3963
+ // host for byte counters far more often, and go back to idle polling after.
3964
+ React.useEffect(() => {
3965
+ if (!queueBusy) return undefined
3966
+ const timer = setInterval(() => { void snapshot.reload() }, 700)
3967
+ timer?.unref?.()
3968
+ return () => clearInterval(timer)
3969
+ }, [queueBusy, snapshot.reload])
3970
+
3971
+ const queueRunningItem = queue.find((item) => item.status === 'running') ?? null
3972
+ const runningKey = queueRunningItem === null ? null : `${queueRunningItem.peerId}::${queueRunningItem.sessionId}`
3973
+ const runningProgress = runningKey === null ? null : ((snapshot.progress ?? {})[runningKey] ?? null)
3974
+ const runningPercent = runningProgress !== null && Number.isFinite(runningProgress.total) && runningProgress.total > 0
3975
+ ? Math.min(100, Math.round((runningProgress.received / runningProgress.total) * 100))
3976
+ : null
3977
+ const barPercent = Math.round(((queueDone + (runningPercent === null ? 0 : runningPercent / 100)) / Math.max(1, queue.length)) * 100)
3978
+ const queueRunning = queueRunningItem !== null
3979
+ const activeTitle = queueRunningItem === null
3980
+ ? null
3981
+ : (queueRunningItem.title || t('sessions.untitled'))
3982
+ const targetLabel = (() => {
3983
+ const dir = snapshot.syncDir ?? ''
3984
+ if (dir === '') return t('settings.syncUnsorted')
3985
+ const parts = String(dir).split(/[\\/]/).filter((part) => part.length > 0)
3986
+ return parts[parts.length - 1] ?? dir
3987
+ })()
3988
+ const copySelfAddress = async () => {
3989
+ const ok = await copyText(selfAddress)
3990
+ setNotice({ kind: ok ? 'ok' : 'error', key: ok ? 'notice.addressCopied' : 'notice.addressCopyFailed' })
3991
+ }
3992
+ const copyDiscoveryAddress = async () => {
3993
+ const ok = await copyText(selfDiscoveryAddress)
3994
+ setNotice({ kind: ok ? 'ok' : 'error', key: ok ? 'notice.addressCopied' : 'notice.addressCopyFailed' })
3995
+ }
3996
+ const addManualAddress = async () => {
3997
+ const value = manualAddress.trim()
3998
+ if (value === '') return
3999
+ await run('notice.seedsSaved', () => api('settings', {
4000
+ patch: { seeds: [...(snapshot.settings?.seeds ?? []), value] },
4001
+ }))
4002
+ setManualAddress('')
4003
+ }
4004
+
4005
+ const settingsOnly = surface === 'settings'
4006
+ const showSettings = settingsOnly || settingsOpen
4007
+ const syncDirSafe = typeof syncDir === 'string' ? syncDir : ''
4008
+ const effectiveTarget = target ?? (syncDirSafe.length > 0 ? { path: syncDirSafe, label: baseName(syncDirSafe) } : null)
4009
+
4010
+ const switchView = (next) => {
4011
+ setView(next)
4012
+ setSelected([])
4013
+ }
4014
+
4015
+ const openSettings = () => {
4016
+ setSettingsOpen(true)
4017
+ setAdvancedOpen(true)
4018
+ }
4019
+
4020
+ const requestPull = (value) => {
4021
+ const peer = selectedPeer
4022
+ if (peer === null) return
4023
+ const ids = Array.isArray(value) ? value : [value?.id].filter((id) => typeof id === 'string')
4024
+ if (ids.length === 0) return
4025
+ openPullPlan(peer, ids, effectiveTarget)
4026
+ }
4027
+
4028
+ const requestPush = (value) => {
4029
+ const peer = selectedPeer
4030
+ if (peer === null) return
4031
+ const ids = Array.isArray(value) ? value : [value?.id].filter((id) => typeof id === 'string')
4032
+ if (ids.length === 0) return
4033
+ openPushPlan(peer, ids)
4034
+ }
4035
+
4036
+ const pullAll = async () => {
4037
+ const peer = selectedPeer
4038
+ if (peer === null) return
4039
+ try {
4040
+ const sessions = peerSessions[peer.id] ?? await reloadPeer(peer.id)
4041
+ openPullPlan(peer, sessions.map((session) => session.id), effectiveTarget)
4042
+ } catch (error) {
4043
+ setNotice({ kind: 'error', text: String(error?.message ?? error) })
4044
+ }
4045
+ }
4046
+
4047
+ const showPush = (peer) => {
4048
+ void openPush(peer).then(() => {
4049
+ setView('local')
4050
+ setSelected([])
4051
+ })
4052
+ }
4053
+
4054
+ const handleDeviceAction = (action, peer) => {
4055
+ if (action === 'push') { showPush(peer); return }
4056
+ if (action === 'copy') {
4057
+ void (async () => {
4058
+ const ok = await copyText(`${peer.address ?? ''}${peer.port === undefined || peer.port === null ? '' : `:${peer.port}`}`)
4059
+ setNotice({ kind: ok ? 'ok' : 'error', key: ok ? 'notice.addressCopied' : 'notice.addressCopyFailed' })
4060
+ })()
4061
+ return
4062
+ }
4063
+ if (action === 'refresh') {
4064
+ void reloadPeer(peer.id).then(() => setNotice({ kind: 'ok', key: 'notice.refreshed' })).catch((error) => setNotice({ kind: 'error', text: String(error?.message ?? error) }))
4065
+ return
4066
+ }
4067
+ if (action === 'forget') {
4068
+ void forgetPeer(peer).then(() => { if (openPeer === peer.id) setOpenPeer(null) })
4069
+ }
4070
+ }
4071
+
4072
+ const addTargetPath = (value) => {
4073
+ const next = value.trim()
4074
+ if (next.length === 0) return
4075
+ setExtraPaths((current) => (current.includes(next) ? current : [...current, next]))
4076
+ setPathDraft('')
4077
+ setTarget({ path: next, label: baseName(next) })
4078
+ }
4079
+
4080
+ const updateSelfName = (value) => {
4081
+ selfDirty.current = true
4082
+ setSelfName(value)
4083
+ }
4084
+
4085
+ const updateSyncDraft = (value) => {
4086
+ syncDraftDirty.current = true
4087
+ setSyncDraft(value)
4088
+ }
4089
+
4090
+ const updateSeeds = (value) => {
4091
+ seedsDirty.current = true
4092
+ setSeeds(value)
4093
+ }
4094
+
4095
+ const updateSubnets = (value) => {
4096
+ subnetsDirty.current = true
4097
+ setSubnets(value)
4098
+ }
4099
+
4100
+ return h('div', { className: `ss-root${settingsOnly ? ' settings-only' : ''}` },
4101
+ h('div', { className: 'ss-page' },
4102
+ settingsOnly
4103
+ ? null
4104
+ : h('header', { className: 'ss-top' },
4105
+ h('div', { className: 'ss-top-l' },
4106
+ h('h1', { className: 'ss-h1' }, t('page.title')),
4107
+ h('div', { className: 'ss-sub' },
4108
+ h('span', { className: `ss-dot${online > 0 ? ' on' : ''}` }),
4109
+ h('span', null, h('b', null, String(online)), ' ', t('page.onlineShort')),
4110
+ h('span', { className: 'sep' }, '·'),
4111
+ h('span', null, h('b', null, String(remotePeers.length)), ' ', t('page.seenShort')),
4112
+ ),
4113
+ ),
4114
+ h('div', { className: 'ss-top-r' },
4115
+ h('button', {
4116
+ type: 'button',
4117
+ className: 'ss-icon',
4118
+ title: t('common.refresh'),
4119
+ 'aria-label': t('common.refresh'),
4120
+ disabled: busy,
4121
+ onClick: () => { void run('notice.refreshed', () => api('refresh')) },
4122
+ }, h(Icon, { name: 'refresh' })),
4123
+ showSettings
4124
+ ? null
4125
+ : h('button', { type: 'button', className: 'ss-btn sm', onClick: openSettings },
4126
+ h(Icon, { name: 'sliders', size: 14 }),
4127
+ h('span', null, t('settings.short')),
4128
+ ),
4129
+ ),
4130
+ ),
4131
+
4132
+ notice === null
4133
+ ? null
4134
+ : h('div', { className: `ss-notice${notice.kind === 'error' ? ' error' : ' ok'}` },
4135
+ h(Icon, { name: notice.kind === 'error' ? 'alert' : 'check', size: 14 }),
4136
+ h('span', null, notice.key === undefined ? notice.text : t(notice.key, notice.params)),
4137
+ ),
4138
+ snapshot.error === null || snapshot.error === undefined
4139
+ ? null
4140
+ : h('div', { className: 'ss-error' },
4141
+ h(Icon, { name: 'alert', size: 14 }),
4142
+ h('div', { className: 'grow' }, t('page.apiError', { message: snapshot.error })),
4143
+ ),
4144
+ snapshot.hasPersistence === false
4145
+ ? h('div', { className: 'ss-error' },
4146
+ h(Icon, { name: 'alert', size: 14 }),
4147
+ h('div', { className: 'grow' }, t('page.noPersistence')),
4148
+ )
4149
+ : null,
4150
+
4151
+ showSettings
4152
+ ? h(SettingsView, {
4153
+ t,
4154
+ surface,
4155
+ busy,
4156
+ self: snapshot.self,
4157
+ platform: snapshot.platform,
4158
+ workspaces: snapshot.workspaces ?? [],
4159
+ selfAddress,
4160
+ selfName,
4161
+ onSelfNameChange: updateSelfName,
4162
+ onSaveSelfName: () => { void saveSelfName() },
4163
+ onCopySelf: () => { void copySelfAddress() },
4164
+ override,
4165
+ changeLanguage: (value) => { void changeLanguage(value) },
4166
+ syncDir: syncDirSafe,
4167
+ syncDraft,
4168
+ onSyncDraft: updateSyncDraft,
4169
+ onSaveSyncDir: (value) => { void saveSyncDir(value) },
4170
+ autoPull: snapshot.settings?.autoPull === true,
4171
+ onToggleAutoPull: (value) => { void toggleAutoPull(value) },
4172
+ concurrency: snapshot.settings?.concurrency ?? 0,
4173
+ autoConcurrency: snapshot.concurrency ?? 5,
4174
+ onSaveConcurrency: (value) => { void saveConcurrency(value) },
4175
+ seeds,
4176
+ onSeeds: updateSeeds,
4177
+ onSaveSeeds: () => { void saveSeeds() },
4178
+ subnets,
4179
+ onSubnets: updateSubnets,
4180
+ onSaveSubnets: () => { void saveSubnets() },
4181
+ advancedOpen,
4182
+ onAdvancedOpen: setAdvancedOpen,
4183
+ ports: snapshot.ports,
4184
+ probe: snapshot.probe,
4185
+ onRepair: () => { void repairLegacyLogs() },
4186
+ version: CLIENT_VERSION,
4187
+ hostVersion: snapshot.version,
4188
+ repo: snapshot.repo,
4189
+ onBack: () => setSettingsOpen(false),
4190
+ })
4191
+ : h('div', { className: 'ss-desk' },
4192
+ h(DeviceRail, {
4193
+ t,
4194
+ self: snapshot.self,
4195
+ selfAddress,
4196
+ peers: remotePeers,
4197
+ selectedId: openPeer,
4198
+ onSelect: selectPeer,
4199
+ onOpenSettings: openSettings,
4200
+ onCopySelf: () => { void copySelfAddress() },
4201
+ dropPeer,
4202
+ onDrop: (peer, kind, event) => {
4203
+ if (kind === 'over') { setDropPeer(peer.id); return }
4204
+ if (kind === 'leave') { setDropPeer(null); return }
4205
+ setDropPeer(null)
4206
+ const payload = dropPayload(event)
4207
+ if (payload === null || payload.direction !== 'push') return
4208
+ openPushPlan(peer, payload.ids)
4209
+ },
4210
+ }),
4211
+ selectedPeer === null
4212
+ ? (remotePeers.length === 0
4213
+ ? h(EmptyStage, {
4214
+ t,
4215
+ selfAddress: selfDiscoveryAddress,
4216
+ manualAddress,
4217
+ setManualAddress,
4218
+ onAddManual: () => { void addManualAddress() },
4219
+ onCopySelf: () => { void copyDiscoveryAddress() },
4220
+ onOpenSettings: openSettings,
4221
+ ports: snapshot.ports,
4222
+ busy,
4223
+ })
4224
+ : h('section', { className: 'ss-stage' },
4225
+ h('div', { className: 'ss-empty' },
4226
+ h('span', { className: 'glyph' }, h(Icon, { name: 'devices', size: 18 })),
4227
+ h('p', null, t('devices.selectHint')),
4228
+ ),
4229
+ ))
4230
+ : h(SessionStage, {
4231
+ t,
4232
+ peer: selectedPeer,
4233
+ view,
4234
+ onView: switchView,
4235
+ sessions: openPeer === null ? null : (peerSessions[openPeer] ?? null),
4236
+ localSessions,
4237
+ busy,
4238
+ selected,
4239
+ onToggle: toggleSelected,
4240
+ onToggleAll: toggleAll,
4241
+ onClearSelection: () => setSelected([]),
4242
+ digests,
4243
+ onVerify: (session) => { void verifySession(selectedPeer, session) },
4244
+ onCopyDigest: (session) => { void copyDigest(session) },
4245
+ onPull: requestPull,
4246
+ onPush: requestPush,
4247
+ onForgetImport: (session) => { void forgetImport(selectedPeer, session) },
4248
+ onPullAll: () => { void pullAll() },
4249
+ target,
4250
+ onTarget: setTarget,
4251
+ workspaces: snapshot.workspaces ?? [],
4252
+ syncDir: syncDirSafe,
4253
+ extraPaths,
4254
+ pathDraft,
4255
+ onPathDraft: setPathDraft,
4256
+ onAddPath: addTargetPath,
4257
+ queue,
4258
+ progress: snapshot.progress ?? {},
4259
+ queueOpen,
4260
+ onQueueOpen: setQueueOpen,
4261
+ queueDone,
4262
+ barPercent,
4263
+ queueRunning,
4264
+ activeTitle,
4265
+ onClearQueue: clearQueue,
4266
+ onRetry: retryItem,
4267
+ dragStart: view === 'peer' ? dragPeerSessions(selectedPeer) : dragLocalSessions,
4268
+ onAction: handleDeviceAction,
4269
+ onRename: (peer, name) => { void renamePeer(peer, name) },
4270
+ }),
4271
+ ),
4272
+
4273
+ h('footer', { className: 'ss-foot ss-footer' },
4274
+ h('code', null, t('footer.version', { version: CLIENT_VERSION })),
4275
+ h('span', { className: 'sep' }, '·'),
4276
+ h('span', null, t('footer.license')),
4277
+ snapshot.version !== undefined && snapshot.version !== CLIENT_VERSION
4278
+ ? h('span', { className: 'warn' }, t('footer.hostVersion', { version: snapshot.version }))
4279
+ : null,
4280
+ h('span', { className: 'ss-spacer' }),
4281
+ h('a', {
4282
+ href: snapshot.repo ?? 'https://github.com/XXGxiaoyu/dsh-session-sync',
4283
+ target: '_blank',
4284
+ rel: 'noreferrer noopener',
4285
+ style: { textDecoration: 'none' },
4286
+ title: t('footer.repo'),
4287
+ }, h(GitHubIcon, { size: 14 }), h('span', null, t('footer.repo'))),
4288
+ ),
4289
+ ),
4290
+ plan === null ? null : h(PlanDialog, { t, plan, onCancel: () => setPlan(null), onConfirm: confirmPlan }),
4291
+ )
4292
+ }
4293
+
4294
+ // ── sidebar row + full page ───────────────────────────────────────────
4295
+ /** Octocat mark, 16×16, currentColor so it follows the theme. */
4296
+ function GitHubIcon({ size = 16 }) {
4297
+ return h('svg', { width: size, height: size, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': 'true' },
4298
+ h('path', {
4299
+ d: 'M8 .9a7.2 7.2 0 0 0-2.28 14.03c.36.07.49-.15.49-.34l-.01-1.2c-2 .44-2.43-.85-2.43-.85-.33-.83-.8-1.05-.8-1.05-.65-.45.05-.44.05-.44.72.05 1.1.74 1.1.74.64 1.1 1.68.78 2.09.6.06-.47.25-.79.45-.97-1.6-.18-3.28-.8-3.28-3.56 0-.79.28-1.43.74-1.94-.08-.18-.32-.91.07-1.9 0 0 .6-.19 1.97.74a6.8 6.8 0 0 1 3.58 0c1.37-.93 1.97-.74 1.97-.74.39.99.15 1.72.08 1.9.46.51.74 1.15.74 1.94 0 2.77-1.69 3.38-3.3 3.56.26.22.49.66.49 1.33l-.01 1.9c0 .19.13.42.5.34A7.2 7.2 0 0 0 8 .9Z',
4300
+ }),
4301
+ )
4302
+ }
4303
+
4304
+ function SidebarIcon({ size = 16 }) {
4305
+ return h('svg', {
4306
+ className: 'ss-navicon',
4307
+ width: size,
4308
+ height: size,
4309
+ viewBox: '0 0 16 16',
4310
+ fill: 'none',
4311
+ stroke: 'currentColor',
4312
+ strokeWidth: 1.4,
4313
+ strokeLinecap: 'round',
4314
+ strokeLinejoin: 'round',
4315
+ 'aria-hidden': 'true',
4316
+ },
4317
+ h('path', { d: 'M2.5 6.2A5.6 5.6 0 0 1 12.6 4.5' }),
4318
+ h('path', { d: 'M12.9 2v2.7h-2.7' }),
4319
+ h('path', { d: 'M13.5 9.8A5.6 5.6 0 0 1 3.4 11.5' }),
4320
+ h('path', { d: 'M3.1 14v-2.7h2.7' }),
4321
+ h('path', { d: 'M6.1 7.9h3.8M6.1 8V6.4M9.9 8v1.6' }),
4322
+ )
4323
+ }
4324
+
4325
+ /** The `main` panel: the same page as the settings section, full width. */
4326
+ function SyncPanelPage({ t }) {
4327
+ return h(SettingsPage, { t, surface: 'panel' })
4328
+ }
4329
+
4330
+ // Slot components own the translator. The host also injects a `t` prop bound
4331
+ // to the DSH locale (that is what `locale: NS` buys), so it must NOT win
4332
+ // here: the plugin's own `locale` setting has to be able to override it.
4333
+ // Crash isolation: a render error becomes diagnostics, not an empty page.
4334
+ function WrappedSettingsSection() {
4335
+ return h(Boundary, { t: (key) => translateNow(key) }, h(LocalizedSettingsSection))
4336
+ }
4337
+
4338
+ function WrappedPanelPage() {
4339
+ return h(Boundary, { t: (key) => translateNow(key) }, h(LocalizedPanelPage))
4340
+ }
4341
+
4342
+ function LocalizedSettingsSection() {
4343
+ return h(SettingsPage, { t: useT(), surface: 'settings' })
4344
+ }
4345
+
4346
+ function LocalizedPanelPage() {
4347
+ return h(SyncPanelPage, { t: useT() })
4348
+ }
4349
+
4350
+ // ── plugin face ───────────────────────────────────────────────────────
4351
+ const name = 'dsh-session-sync'
4352
+ const inject = ['slots', 'locale']
4353
+
4354
+ function apply(ctx) {
4355
+ // The host locale service is the only thing that knows what language DSH
4356
+ // itself is using; keep its accessors so components can re-render on a
4357
+ // language switch instead of holding a stale translator.
4358
+ runtime = {
4359
+ getActive: () => ctx.locale.getSnapshot?.().active ?? null,
4360
+ subscribe: (listener) => ctx.locale.subscribe(listener),
4361
+ }
4362
+
4363
+ ctx.effect(() => ctx.locale.register(NS, DICTIONARIES), 'dsh-session-sync: dictionaries')
4364
+
4365
+ // Labels are resolved by the host when it renders the sidebar/settings nav,
4366
+ // so a language switch has to re-publish the registration. Without this the
4367
+ // page would already be English while the row above it stayed Chinese until
4368
+ // some unrelated render happened.
4369
+ const publishOnLanguageChange = (register) => {
4370
+ let dispose = null
4371
+ const publish = () => {
4372
+ if (typeof dispose === 'function') dispose()
4373
+ dispose = register()
4374
+ }
4375
+ publish()
4376
+ const stop = subscribeOverride(publish)
4377
+ return () => {
4378
+ stop()
4379
+ if (typeof dispose === 'function') dispose()
4380
+ }
4381
+ }
4382
+
4383
+ ctx.slots.inject('settings.section', () => publishOnLanguageChange(() => ctx.slots.register({
4384
+ name: 'settings.section',
4385
+ id: 'session-sync',
4386
+ order: 60,
4387
+ label: () => translateNow('page.title'),
4388
+ locale: NS,
4389
+ }, WrappedSettingsSection)))
4390
+
4391
+ // The row sits in the sidebar next to the host's own pages (the
4392
+ // `sidebar.panellist` slot is the only seat a plugin may claim), and it
4393
+ // opens the keyed `main` panel below.
4394
+ ctx.slots.inject('sidebar.panellist', () => publishOnLanguageChange(() => ctx.slots.register({
4395
+ name: 'sidebar.panellist',
4396
+ id: PANEL_ID,
4397
+ order: 6,
4398
+ label: () => translateNow('nav.sessionSync'),
4399
+ locale: NS,
4400
+ }, SidebarIcon)))
4401
+
4402
+ ctx.effect(() => installSidebarDrop(), 'dsh-session-sync: sidebar drop')
4403
+
4404
+ ctx.slots.inject('main', () => ctx.slots.register({
4405
+ name: 'main',
4406
+ key: PANEL_ID,
4407
+ locale: NS,
4408
+ }, WrappedPanelPage))
4409
+ }
4410
+
4411
+ return { name, inject, apply }
4412
+ },
4413
+ })