@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/CHANGELOG.md +74 -0
- package/CHANGELOG_ZH.md +59 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/README_ZH.md +185 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +9 -0
- package/lib/client.js +4413 -0
- package/lib/discovery.js +354 -0
- package/lib/index.js +1053 -0
- package/lib/lan.js +213 -0
- package/lib/logfile.js +80 -0
- package/lib/probe.js +100 -0
- package/lib/repair.js +150 -0
- package/lib/sessions.js +501 -0
- package/lib/state.js +236 -0
- package/package.json +64 -4
- package/screenshots.json +1 -0
- package/scripts/repair-sessions.mjs +31 -0
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
|
+
})
|