dsh-remote 0.8.8 → 0.8.10
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/LICENSE +21 -21
- package/README.md +234 -231
- package/README.zh.md +190 -187
- package/cordis.patch.yml +53 -53
- package/lib/client.js +1496 -1310
- package/lib/credential.js +107 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +2962 -2747
- package/lib/paths.js +217 -169
- package/lib/registry.js +89 -89
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +66 -66
package/lib/client.js
CHANGED
|
@@ -1,1310 +1,1496 @@
|
|
|
1
|
-
// dsh-remote — client half.
|
|
2
|
-
// Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
|
|
3
|
-
// passphrase / host-key mode / proxy jump / agent / keychain password; password
|
|
4
|
-
// stored host-side and kept private). Plus a port-forward panel, the command
|
|
5
|
-
// audit log view, quick workspace switching, and an ~/.ssh/config importer.
|
|
6
|
-
//
|
|
7
|
-
// A unified workspace directory picker fills ui-workspace's two directory-flow
|
|
8
|
-
// holes (sidebar + conversation hero):
|
|
9
|
-
// • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
|
|
10
|
-
// and returns the picked local path (works with local workspaces).
|
|
11
|
-
// • 远程 tab → pick a machine (dropdown), list its directories over
|
|
12
|
-
// /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
|
|
13
|
-
// real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
|
|
14
|
-
//
|
|
15
|
-
// better-sidebar integration (optional): a live remote file explorer tab and a
|
|
16
|
-
// read-only-by-default remote file tab with an explicit「编辑 → 保存到远程」flow
|
|
17
|
-
// (mtime optimistic lock via POST /dsh-remote/write).
|
|
18
|
-
//
|
|
19
|
-
// Client entries must be classic scripts registered via window.__ModuleLoader__.load
|
|
20
|
-
// ({ id, factory }); the factory receives a synchronous `require`.
|
|
21
|
-
window.__ModuleLoader__.load({
|
|
22
|
-
id: 'dsh-remote',
|
|
23
|
-
factory: (require) => {
|
|
24
|
-
var module = { exports: {} }
|
|
25
|
-
var exports = module.exports
|
|
26
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
27
|
-
|
|
28
|
-
const React = require('react')
|
|
29
|
-
const name = 'dsh-remote'
|
|
30
|
-
|
|
31
|
-
async function api(method, path, body) {
|
|
32
|
-
const opts = { method, headers: {} }
|
|
33
|
-
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
34
|
-
const res = await fetch(path, opts)
|
|
35
|
-
const data = await res.json().catch(() => ({}))
|
|
36
|
-
if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
|
|
37
|
-
return data
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Like api() but returns {status, data} so callers can handle 409 etc.
|
|
41
|
-
async function apiRaw(method, path, body) {
|
|
42
|
-
const opts = { method, headers: {} }
|
|
43
|
-
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
44
|
-
const res = await fetch(path, opts)
|
|
45
|
-
const data = await res.json().catch(() => ({}))
|
|
46
|
-
return { status: res.status, data }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Theme via DSH design tokens so this follows the harness light/dark theme.
|
|
50
|
-
const v = (name, fb) => `var(${name}, ${fb})`
|
|
51
|
-
const T = {
|
|
52
|
-
bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
|
|
53
|
-
bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
|
|
54
|
-
border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
|
|
55
|
-
borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
|
|
56
|
-
danger: v('--dsw-static-red-500', '#e06c75'),
|
|
57
|
-
dangerText: v('--dsw-static-red-400', '#e06c75'),
|
|
58
|
-
ok: v('--dsw-static-green-500', '#4caf7d'),
|
|
59
|
-
warn: v('--dsw-static-yellow-500', '#e6c07b'),
|
|
60
|
-
radius: 8,
|
|
61
|
-
muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
|
|
62
|
-
label: v('--dsw-alias-label-primary', '#e4e4e7'),
|
|
63
|
-
primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
|
|
64
|
-
onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
|
|
65
|
-
hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
|
|
66
|
-
}
|
|
67
|
-
const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
|
|
68
|
-
const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
|
|
69
|
-
const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
|
|
70
|
-
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
71
|
-
const boxS = box
|
|
72
|
-
|
|
73
|
-
function humanSize(n) {
|
|
74
|
-
if (n == null) return ''
|
|
75
|
-
if (n < 1024) return n + ' B'
|
|
76
|
-
const units = ['KB', 'MB', 'GB', 'TB']
|
|
77
|
-
let i = -1
|
|
78
|
-
let v = n
|
|
79
|
-
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
|
80
|
-
return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
|
|
81
|
-
}
|
|
82
|
-
function fmtTime(ts) {
|
|
83
|
-
if (!ts) return ''
|
|
84
|
-
const d = new Date(ts * 1000)
|
|
85
|
-
const pad = (x) => String(x).padStart(2, '0')
|
|
86
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.then((r) => {
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
api('
|
|
240
|
-
.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
React.createElement('
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
React.createElement('
|
|
321
|
-
|
|
322
|
-
React.createElement('
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
React.createElement('
|
|
336
|
-
React.createElement('
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
React.createElement('
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
React.createElement('
|
|
346
|
-
React.createElement('
|
|
347
|
-
|
|
348
|
-
React.createElement('
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
React.createElement('div', { style: {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
React.createElement('
|
|
389
|
-
React.createElement('
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
if (
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
),
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
React.createElement('div', { style: { fontSize:
|
|
776
|
-
React.createElement('
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
),
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
React.createElement('
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (!
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
.
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
)
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
)
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
)
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
(
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1
|
+
// dsh-remote — client half.
|
|
2
|
+
// Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
|
|
3
|
+
// passphrase / host-key mode / proxy jump / agent / keychain password; password
|
|
4
|
+
// stored host-side and kept private). Plus a port-forward panel, the command
|
|
5
|
+
// audit log view, quick workspace switching, and an ~/.ssh/config importer.
|
|
6
|
+
//
|
|
7
|
+
// A unified workspace directory picker fills ui-workspace's two directory-flow
|
|
8
|
+
// holes (sidebar + conversation hero):
|
|
9
|
+
// • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
|
|
10
|
+
// and returns the picked local path (works with local workspaces).
|
|
11
|
+
// • 远程 tab → pick a machine (dropdown), list its directories over
|
|
12
|
+
// /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
|
|
13
|
+
// real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
|
|
14
|
+
//
|
|
15
|
+
// better-sidebar integration (optional): a live remote file explorer tab and a
|
|
16
|
+
// read-only-by-default remote file tab with an explicit「编辑 → 保存到远程」flow
|
|
17
|
+
// (mtime optimistic lock via POST /dsh-remote/write).
|
|
18
|
+
//
|
|
19
|
+
// Client entries must be classic scripts registered via window.__ModuleLoader__.load
|
|
20
|
+
// ({ id, factory }); the factory receives a synchronous `require`.
|
|
21
|
+
window.__ModuleLoader__.load({
|
|
22
|
+
id: 'dsh-remote',
|
|
23
|
+
factory: (require) => {
|
|
24
|
+
var module = { exports: {} }
|
|
25
|
+
var exports = module.exports
|
|
26
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
27
|
+
|
|
28
|
+
const React = require('react')
|
|
29
|
+
const name = 'dsh-remote'
|
|
30
|
+
|
|
31
|
+
async function api(method, path, body) {
|
|
32
|
+
const opts = { method, headers: {} }
|
|
33
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
34
|
+
const res = await fetch(path, opts)
|
|
35
|
+
const data = await res.json().catch(() => ({}))
|
|
36
|
+
if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
|
|
37
|
+
return data
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Like api() but returns {status, data} so callers can handle 409 etc.
|
|
41
|
+
async function apiRaw(method, path, body) {
|
|
42
|
+
const opts = { method, headers: {} }
|
|
43
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
44
|
+
const res = await fetch(path, opts)
|
|
45
|
+
const data = await res.json().catch(() => ({}))
|
|
46
|
+
return { status: res.status, data }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Theme via DSH design tokens so this follows the harness light/dark theme.
|
|
50
|
+
const v = (name, fb) => `var(${name}, ${fb})`
|
|
51
|
+
const T = {
|
|
52
|
+
bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
|
|
53
|
+
bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
|
|
54
|
+
border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
|
|
55
|
+
borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
|
|
56
|
+
danger: v('--dsw-static-red-500', '#e06c75'),
|
|
57
|
+
dangerText: v('--dsw-static-red-400', '#e06c75'),
|
|
58
|
+
ok: v('--dsw-static-green-500', '#4caf7d'),
|
|
59
|
+
warn: v('--dsw-static-yellow-500', '#e6c07b'),
|
|
60
|
+
radius: 8,
|
|
61
|
+
muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
|
|
62
|
+
label: v('--dsw-alias-label-primary', '#e4e4e7'),
|
|
63
|
+
primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
|
|
64
|
+
onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
|
|
65
|
+
hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
|
|
66
|
+
}
|
|
67
|
+
const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
|
|
68
|
+
const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
|
|
69
|
+
const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
|
|
70
|
+
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
71
|
+
const boxS = box
|
|
72
|
+
|
|
73
|
+
function humanSize(n) {
|
|
74
|
+
if (n == null) return ''
|
|
75
|
+
if (n < 1024) return n + ' B'
|
|
76
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
77
|
+
let i = -1
|
|
78
|
+
let v = n
|
|
79
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
|
80
|
+
return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
|
|
81
|
+
}
|
|
82
|
+
function fmtTime(ts) {
|
|
83
|
+
if (!ts) return ''
|
|
84
|
+
const d = new Date(ts * 1000)
|
|
85
|
+
const pad = (x) => String(x).padStart(2, '0')
|
|
86
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Write text to the clipboard. Prefers the async Clipboard API (works on
|
|
90
|
+
// http://127.0.0.1 secure contexts) and falls back to the legacy
|
|
91
|
+
// execCommand('copy') path, mirroring primitives' writeClipboard semantics.
|
|
92
|
+
function copyToClipboard(text) {
|
|
93
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
94
|
+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const ta = document.createElement('textarea')
|
|
98
|
+
ta.value = String(text)
|
|
99
|
+
ta.style.position = 'fixed'
|
|
100
|
+
ta.style.opacity = '0'
|
|
101
|
+
document.body.appendChild(ta)
|
|
102
|
+
ta.focus()
|
|
103
|
+
ta.select()
|
|
104
|
+
const ok = document.execCommand('copy')
|
|
105
|
+
ta.remove()
|
|
106
|
+
return Promise.resolve(ok)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return Promise.resolve(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
113
|
+
let SESSIONS = null // ctx.get('sessions'), set in apply()
|
|
114
|
+
|
|
115
|
+
// ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
|
|
116
|
+
function RemoteWorkspacePage() {
|
|
117
|
+
const [machines, setMachines] = React.useState([])
|
|
118
|
+
const [currentId, setCurrentId] = React.useState('')
|
|
119
|
+
const [form, setForm] = React.useState({
|
|
120
|
+
name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
|
|
121
|
+
passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
|
|
122
|
+
encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
|
|
123
|
+
})
|
|
124
|
+
const [busy, setBusy] = React.useState(false)
|
|
125
|
+
const [msg, setMsg] = React.useState('')
|
|
126
|
+
const [err, setErr] = React.useState('')
|
|
127
|
+
const [tst, setTst] = React.useState('') // idle | testing | ok | failing
|
|
128
|
+
const [forwards, setForwards] = React.useState([])
|
|
129
|
+
const [fwdForm, setFwdForm] = React.useState({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false })
|
|
130
|
+
const [auditLines, setAuditLines] = React.useState([])
|
|
131
|
+
const [sshEntries, setSshEntries] = React.useState(null)
|
|
132
|
+
const [workspaces, setWorkspaces] = React.useState([])
|
|
133
|
+
const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
|
|
134
|
+
|
|
135
|
+
const testConn = () => {
|
|
136
|
+
if (!form.host.trim() || tst === 'testing') return
|
|
137
|
+
setTst('testing'); setErr(''); setMsg('')
|
|
138
|
+
api('POST', '/dsh-remote/test-connect', {
|
|
139
|
+
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
140
|
+
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
141
|
+
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
142
|
+
})
|
|
143
|
+
.then((r) => {
|
|
144
|
+
if (r && r.ok) {
|
|
145
|
+
const shellTag = r.platform === 'windows' ? (r.gitBash ? '(Windows · Git Bash)' : '(Windows · 未找到 Git Bash)') : (r.platform === 'posix' ? '(POSIX)' : '')
|
|
146
|
+
setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)${shellTag}` : '连接成功'); refresh()
|
|
147
|
+
}
|
|
148
|
+
else { setTst('failing'); setErr((r && r.error) || '连接失败') }
|
|
149
|
+
})
|
|
150
|
+
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const refresh = () => api('GET', '/dsh-remote/machines').then((r) => {
|
|
154
|
+
setMachines(r.machines || []); setCurrentId(r.currentId || '')
|
|
155
|
+
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
156
|
+
setWorkspaces((cur && cur.recentWorkspaces) || [])
|
|
157
|
+
})
|
|
158
|
+
React.useEffect(() => { refresh() }, [])
|
|
159
|
+
|
|
160
|
+
// ── update state ────────────────────────────────────────────────────────
|
|
161
|
+
const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
|
|
162
|
+
const [updBusy, setUpdBusy] = React.useState(false)
|
|
163
|
+
const [updMsg, setUpdMsg] = React.useState('')
|
|
164
|
+
const [updMode, setUpdMode] = React.useState('manual')
|
|
165
|
+
const checkUpdate = (quiet) => {
|
|
166
|
+
if (updBusy) return
|
|
167
|
+
setUpdBusy(true); setUpdMsg('')
|
|
168
|
+
api('GET', '/dsh-remote/update-check')
|
|
169
|
+
.then((r) => {
|
|
170
|
+
if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
|
|
171
|
+
setUpd(r)
|
|
172
|
+
if (r.updateMode) setUpdMode(r.updateMode)
|
|
173
|
+
if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
|
|
174
|
+
})
|
|
175
|
+
.catch((e) => setUpdMsg(String((e && e.message) || e)))
|
|
176
|
+
.finally(() => setUpdBusy(false))
|
|
177
|
+
}
|
|
178
|
+
const applyUpdateNow = () => {
|
|
179
|
+
if (!upd || !upd.latest) return
|
|
180
|
+
if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
|
|
181
|
+
setUpdBusy(true); setUpdMsg('')
|
|
182
|
+
api('POST', '/dsh-remote/update-apply', { version: upd.latest })
|
|
183
|
+
.then((r) => {
|
|
184
|
+
if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
|
|
185
|
+
else setUpdMsg((r && r.error) || '更新失败')
|
|
186
|
+
checkUpdate(true)
|
|
187
|
+
})
|
|
188
|
+
.catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
|
|
189
|
+
.finally(() => setUpdBusy(false))
|
|
190
|
+
}
|
|
191
|
+
const saveUpdateMode = (mode) => {
|
|
192
|
+
setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
|
|
193
|
+
api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
|
|
194
|
+
}
|
|
195
|
+
React.useEffect(() => { checkUpdate(true) }, [])
|
|
196
|
+
|
|
197
|
+
const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
|
|
198
|
+
React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
|
|
199
|
+
|
|
200
|
+
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
201
|
+
const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
|
|
202
|
+
const startEdit = (m) => {
|
|
203
|
+
setForm({
|
|
204
|
+
name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root',
|
|
205
|
+
password: '', privateKeyPath: m.privateKeyPath || '', passphrase: '', workspace: m.workspace || '',
|
|
206
|
+
hostKeyMode: m.hostKeyMode || '', useAgent: !!m.useAgent, keyboardInteractive: !!m.keyboardInteractive,
|
|
207
|
+
encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
|
|
208
|
+
proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
|
|
209
|
+
proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
|
|
210
|
+
id: m.id,
|
|
211
|
+
})
|
|
212
|
+
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
|
|
213
|
+
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
214
|
+
setAdvOpen(true)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '' })
|
|
218
|
+
const save = (action) => {
|
|
219
|
+
setBusy(true); setErr(''); setMsg('')
|
|
220
|
+
if (action === 'delete') {
|
|
221
|
+
api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { clearForm(); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
api('POST', '/dsh-remote/machines', {
|
|
225
|
+
action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
|
|
226
|
+
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
227
|
+
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
228
|
+
encryptPassword: form.encryptPassword,
|
|
229
|
+
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
230
|
+
}).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
231
|
+
}
|
|
232
|
+
const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg('已切换为当前远程机') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
233
|
+
// Issue #13: "active remote = none" — saved machines stay, but no machine
|
|
234
|
+
// is current, so local sessions are never dragged into a remote context.
|
|
235
|
+
const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
236
|
+
const del = (id) => { if (window.confirm('确定删除这台机器?')) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg('已删除')).catch((e) => setErr(String((e && e.message) || e))) } }
|
|
237
|
+
|
|
238
|
+
const importSsh = () => {
|
|
239
|
+
api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
|
|
240
|
+
.catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
|
|
241
|
+
}
|
|
242
|
+
const applySshEntry = (e) => {
|
|
243
|
+
setForm({ ...form,
|
|
244
|
+
name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
|
|
245
|
+
username: e.user || 'root', privateKeyPath: e.identityFile || '',
|
|
246
|
+
})
|
|
247
|
+
setSshEntries(null)
|
|
248
|
+
setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const setWorkspaceNow = (p) => {
|
|
252
|
+
api('POST', '/dsh-remote/workspace', { path: p })
|
|
253
|
+
.then(() => setMsg('已切换到工作区 ' + p))
|
|
254
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── forwards panel ────────────────────────────────────────────────────
|
|
258
|
+
const fwdAction = (action, id) => {
|
|
259
|
+
api('POST', '/dsh-remote/forwards', { action, id })
|
|
260
|
+
.then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
|
|
261
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
262
|
+
}
|
|
263
|
+
const fwdDefine = () => {
|
|
264
|
+
if (!fwdForm.listenPort) return
|
|
265
|
+
api('POST', '/dsh-remote/forwards', { action: 'define', listenPort: Number(fwdForm.listenPort), targetHost: fwdForm.targetHost || '127.0.0.1', targetPort: Number(fwdForm.targetPort) || Number(fwdForm.listenPort), direction: fwdForm.direction, autoStart: fwdForm.autoStart })
|
|
266
|
+
.then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
|
|
267
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
|
|
271
|
+
React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
|
|
272
|
+
|
|
273
|
+
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
|
|
274
|
+
React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
|
|
275
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
|
|
276
|
+
'维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
|
|
277
|
+
React.createElement('div', { style: boxS },
|
|
278
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
|
|
279
|
+
machines.length
|
|
280
|
+
? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
|
|
281
|
+
React.createElement('div', { style: { flex: 1, fontSize: 13 } },
|
|
282
|
+
m.name + ' ',
|
|
283
|
+
React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
|
|
284
|
+
m.passwordSet ? ' 🔒' : '',
|
|
285
|
+
(m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
|
|
286
|
+
m.proxy && m.proxy.host ? ' ⛳' : '',
|
|
287
|
+
m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
|
|
288
|
+
m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
|
|
289
|
+
React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
|
|
290
|
+
React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
|
|
291
|
+
React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
|
|
292
|
+
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
|
|
293
|
+
currentId
|
|
294
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
|
|
295
|
+
'当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
|
|
296
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
|
|
297
|
+
: React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
|
|
298
|
+
),
|
|
299
|
+
React.createElement('div', { style: boxS },
|
|
300
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
|
|
301
|
+
React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
|
|
302
|
+
React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
|
|
303
|
+
sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
|
|
304
|
+
sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, onClick: () => applySshEntry(e), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, borderBottom: '1px solid ' + T.border } },
|
|
305
|
+
e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
|
|
306
|
+
: React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
|
|
307
|
+
: null,
|
|
308
|
+
row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
|
|
309
|
+
row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
|
|
310
|
+
row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
|
|
311
|
+
row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
|
|
312
|
+
row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
|
|
313
|
+
// 高级配置折叠开关
|
|
314
|
+
React.createElement('button', {
|
|
315
|
+
type: 'button',
|
|
316
|
+
onClick: () => setAdvOpen(!advOpen),
|
|
317
|
+
style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
|
|
318
|
+
}, advOpen ? '▲ 收起高级配置' : '▼ 高级配置(私钥 / 跳板机 / agent 等)'),
|
|
319
|
+
advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
|
|
320
|
+
row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
|
|
321
|
+
row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
|
|
322
|
+
row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
|
|
323
|
+
row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
|
|
324
|
+
React.createElement('option', { value: '' }, '(默认 accept-new)'),
|
|
325
|
+
React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
|
|
326
|
+
React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
|
|
327
|
+
React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
|
|
328
|
+
React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
|
|
329
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
330
|
+
React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
|
|
331
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
332
|
+
React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
|
|
333
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
334
|
+
React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
|
|
335
|
+
row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
|
|
336
|
+
row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
|
|
337
|
+
React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
|
|
338
|
+
React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
|
|
339
|
+
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
|
|
340
|
+
row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
|
|
341
|
+
) : null,
|
|
342
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
|
|
343
|
+
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
344
|
+
form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
|
|
345
|
+
React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? '取消编辑' : '清空'),
|
|
346
|
+
React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
347
|
+
tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
|
|
348
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
// 快捷切换工作区(该机器的最近工作区)
|
|
352
|
+
workspaces.length ? React.createElement('div', { style: boxS },
|
|
353
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
|
|
354
|
+
workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
|
|
355
|
+
React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
|
|
356
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
|
|
357
|
+
// 端口转发面板
|
|
358
|
+
React.createElement('div', { style: boxS },
|
|
359
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
|
|
360
|
+
forwards.length ? forwards.map((f) => React.createElement('div', { key: f.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: 12, fontFamily: 'monospace' } },
|
|
361
|
+
React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
|
|
362
|
+
React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
|
|
363
|
+
f.active
|
|
364
|
+
? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
|
|
365
|
+
: React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
|
|
366
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
|
|
367
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
|
|
368
|
+
React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
|
|
369
|
+
React.createElement('option', { value: 'local' }, '本地→远端'),
|
|
370
|
+
React.createElement('option', { value: 'reverse' }, '远端→本地')),
|
|
371
|
+
React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
|
|
372
|
+
React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
|
|
373
|
+
React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
|
|
374
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
375
|
+
React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
|
|
376
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
|
|
377
|
+
),
|
|
378
|
+
// 审计日志
|
|
379
|
+
React.createElement('div', { style: boxS },
|
|
380
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
|
|
381
|
+
auditLines.length
|
|
382
|
+
? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
|
|
383
|
+
auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
|
|
384
|
+
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
|
|
385
|
+
),
|
|
386
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
|
|
387
|
+
React.createElement('div', { style: boxS },
|
|
388
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
|
|
389
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
|
|
390
|
+
upd
|
|
391
|
+
? React.createElement('span', { style: { opacity: 0.85 } },
|
|
392
|
+
'当前 v' + upd.current,
|
|
393
|
+
upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
|
|
394
|
+
: React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
|
|
395
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
|
|
396
|
+
upd && upd.updateAvailable
|
|
397
|
+
? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
|
|
398
|
+
: null,
|
|
399
|
+
),
|
|
400
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
|
|
401
|
+
React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
|
|
402
|
+
['manual', 'auto', 'off'].map((m) =>
|
|
403
|
+
React.createElement('button', {
|
|
404
|
+
key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
|
|
405
|
+
onClick: () => saveUpdateMode(m), disabled: updBusy,
|
|
406
|
+
}, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
|
|
410
|
+
),
|
|
411
|
+
React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
|
|
412
|
+
React.createElement('span', {}, '觉得好用?'),
|
|
413
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
|
|
414
|
+
React.createElement('span', {}, '·'),
|
|
415
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
|
|
416
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Unified picker (fill the directory-flow holes) ───────────────────
|
|
422
|
+
function parseLs(text) {
|
|
423
|
+
const out = []
|
|
424
|
+
for (const ln of String(text || '').split('\n')) {
|
|
425
|
+
const t = ln.trim()
|
|
426
|
+
if (!t || !t.length || /^total\b/i.test(t)) continue
|
|
427
|
+
const parts = t.split(/\s+/).filter(Boolean)
|
|
428
|
+
if (parts.length < 8) continue
|
|
429
|
+
const mode = parts[0]
|
|
430
|
+
const name = parts.slice(8).join(' ')
|
|
431
|
+
if (!name || name === '.' || name === '..') continue
|
|
432
|
+
const isDir = mode.charAt(0) === 'd'
|
|
433
|
+
out.push({ name, dir: isDir })
|
|
434
|
+
}
|
|
435
|
+
return out
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Clickable breadcrumb of the current remote path. Windows remotes show a
|
|
439
|
+
// "This PC" root and `\` separators (此电脑 / C:\ / Users / dev); POSIX `/`.
|
|
440
|
+
function breadcrumb(active, cur, win, jumpTo) {
|
|
441
|
+
const raw = String(cur || '')
|
|
442
|
+
const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
|
|
443
|
+
const rootLabel = isWinPath ? '此电脑' : '/'
|
|
444
|
+
const crumbs = []
|
|
445
|
+
const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
|
|
446
|
+
crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
|
|
447
|
+
if (isWinPath && raw !== '') {
|
|
448
|
+
const driveM = raw.match(/^([a-zA-Z]):(.*)$/)
|
|
449
|
+
const drive = driveM[1].toUpperCase() + ':\\'
|
|
450
|
+
crumbs.push(React.createElement('span', { key: 'sep0', style: { color: T.muted } }, '\\'))
|
|
451
|
+
crumbs.push(React.createElement('span', { key: 'drive', style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: raw === drive ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(drive) : undefined }, drive))
|
|
452
|
+
let acc = drive
|
|
453
|
+
for (const s of (driveM[2] || '').split('\\')) {
|
|
454
|
+
if (!s) continue
|
|
455
|
+
acc += s + '\\'
|
|
456
|
+
const target = acc.slice(0, -1)
|
|
457
|
+
crumbs.push(React.createElement('span', { key: 's|' + target, style: { color: T.muted } }, '\\'))
|
|
458
|
+
crumbs.push(React.createElement('span', { key: target, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: target === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(target) : undefined }, s))
|
|
459
|
+
}
|
|
460
|
+
} else if (!isWinPath && raw !== '' && raw !== '/') {
|
|
461
|
+
const segs = raw.replace(/\/+$/, '').split('/')
|
|
462
|
+
let acc = ''
|
|
463
|
+
for (const s of segs) {
|
|
464
|
+
if (!s) continue
|
|
465
|
+
acc += '/' + s
|
|
466
|
+
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
467
|
+
crumbs.push(React.createElement('span', { key: acc, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(acc) : undefined }, s))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? ' 选择驱动器' : ''))
|
|
471
|
+
return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function DirPicker(props) {
|
|
475
|
+
const { open, busy, onPicked, onCancel } = props
|
|
476
|
+
const [tab, setTab] = React.useState('local')
|
|
477
|
+
const [machines, setMachines] = React.useState([])
|
|
478
|
+
const [machineId, setMachineId] = React.useState('')
|
|
479
|
+
const [path, setPath] = React.useState('')
|
|
480
|
+
const [items, setItems] = React.useState(null)
|
|
481
|
+
const [err, setErr] = React.useState('')
|
|
482
|
+
const [loading, setLoading] = React.useState(false)
|
|
483
|
+
const [levels, setLevels] = React.useState(null)
|
|
484
|
+
const [popOpen, setPopOpen] = React.useState(false)
|
|
485
|
+
const [suggest, setSuggest] = React.useState([])
|
|
486
|
+
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
487
|
+
const [recent, setRecent] = React.useState([])
|
|
488
|
+
const [win, setWin] = React.useState(false) // remote platform: windows = Git-Bash host
|
|
489
|
+
// 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
|
|
490
|
+
const [localPop, setLocalPop] = React.useState(null)
|
|
491
|
+
const [newDirName, setNewDirName] = React.useState('')
|
|
492
|
+
const suggestTimer = React.useRef(null)
|
|
493
|
+
const suggestRef = React.useRef(null)
|
|
494
|
+
React.useEffect(() => {
|
|
495
|
+
if (!suggestOpen) return
|
|
496
|
+
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
|
|
497
|
+
document.addEventListener('mousedown', onDown)
|
|
498
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
499
|
+
}, [suggestOpen])
|
|
500
|
+
const [mOpen, setMOpen] = React.useState(false)
|
|
501
|
+
const ddRef = React.useRef(null)
|
|
502
|
+
React.useEffect(() => {
|
|
503
|
+
if (!mOpen) return
|
|
504
|
+
const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
|
|
505
|
+
document.addEventListener('mousedown', onDown)
|
|
506
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
507
|
+
}, [mOpen])
|
|
508
|
+
|
|
509
|
+
const sortItems = (list) => {
|
|
510
|
+
const dirs = list.filter((it) => it.dir)
|
|
511
|
+
const files = list.filter((it) => !it.dir)
|
|
512
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
|
513
|
+
files.sort((a, b) => a.name.localeCompare(b.name))
|
|
514
|
+
return dirs.concat(files)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Client-side full-path fallback: when the server did not attach a `path`
|
|
518
|
+
// (older host half), join base + name with the base's own separator.
|
|
519
|
+
const joinDisplay = (base, name) => {
|
|
520
|
+
const b = String(base || '')
|
|
521
|
+
const n = String(name || '')
|
|
522
|
+
if (!b || b === '/') return '/' + n
|
|
523
|
+
const sep = /^[a-zA-Z]:/.test(b) || b.includes('\\') ? '\\' : '/'
|
|
524
|
+
return b.replace(/[\\/]+$/, '') + sep + n
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const loadLevels = (id, p, toIndex) => {
|
|
528
|
+
if (!id) return
|
|
529
|
+
setLoading(true); setErr('')
|
|
530
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
531
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
532
|
+
.then((res) => {
|
|
533
|
+
setWin(res && res.platform === 'windows')
|
|
534
|
+
const real = res && res.path ? res.path : (p || '')
|
|
535
|
+
// Server returns [{ type, name, size, mtime, path }] where `path` is
|
|
536
|
+
// the FULL display-form path (C:\Users\dev on Windows) — the client
|
|
537
|
+
// never joins paths itself, so drives and Windows paths just work.
|
|
538
|
+
const list = Array.isArray(res && res.items)
|
|
539
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
|
|
540
|
+
: parseLs(res && res.text)
|
|
541
|
+
const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
|
|
542
|
+
setLevels((prev) => {
|
|
543
|
+
const base = prev && prev.length ? prev.slice() : []
|
|
544
|
+
let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
|
|
545
|
+
if (idx >= base.length) return base.concat([node])
|
|
546
|
+
base[idx] = node
|
|
547
|
+
return base.slice(0, idx + 1)
|
|
548
|
+
})
|
|
549
|
+
})
|
|
550
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
551
|
+
.finally(() => setLoading(false))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
React.useEffect(() => { if (open) {
|
|
555
|
+
// Bug fix: the picker must NOT reopen with the previous selection's
|
|
556
|
+
// path — reset the path bar (and the browse state) on every open.
|
|
557
|
+
setPath(''); setLevels(null); setWin(false)
|
|
558
|
+
setLocalPop(null); setNewDirName('')
|
|
559
|
+
api('GET', '/dsh-remote/machines').then((r) => {
|
|
560
|
+
setMachines(r.machines || [])
|
|
561
|
+
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
562
|
+
setRecent((cur && cur.recentWorkspaces) || [])
|
|
563
|
+
setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
|
|
564
|
+
})
|
|
565
|
+
} }, [open])
|
|
566
|
+
|
|
567
|
+
// 本机目录浏览(browse 后端兜底):列出 path 的子目录;path 为空 → 宿主家目录。
|
|
568
|
+
const loadLocal = (p) => {
|
|
569
|
+
setLoading(true); setErr('')
|
|
570
|
+
api('GET', '/dsh-remote/local-list' + (p ? '?path=' + encodeURIComponent(p) : ''))
|
|
571
|
+
.then((r) => setLocalPop({ path: (r && r.path) || p || '', home: (r && r.home) || '', crumbs: (r && r.crumbs) || [], entries: (r && r.entries) || [], truncated: !!(r && r.truncated), drives: (r && r.drives) || [] }))
|
|
572
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
573
|
+
.finally(() => setLoading(false))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const chooseLocal = () => {
|
|
577
|
+
setLoading(true); setErr('')
|
|
578
|
+
api('POST', '/dsh-remote/local-pick')
|
|
579
|
+
.then((r) => {
|
|
580
|
+
if (r && r.path) { setLoading(false); onPicked(String(r.path)) }
|
|
581
|
+
// browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
|
|
582
|
+
// → 打开内置本机目录浏览器。
|
|
583
|
+
else if (r && r.kind === 'browse') loadLocal('')
|
|
584
|
+
else if (r && r.cancelled) { setLoading(false); setErr('已取消选择') }
|
|
585
|
+
else { setLoading(false); setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径') }
|
|
586
|
+
})
|
|
587
|
+
.catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径') })
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
|
|
591
|
+
const mkdirLocal = () => {
|
|
592
|
+
const cur = localPop
|
|
593
|
+
const name = newDirName.trim()
|
|
594
|
+
if (!cur || !cur.path || !name || loading) return
|
|
595
|
+
setLoading(true); setErr('')
|
|
596
|
+
api('POST', '/dsh-remote/local-mkdir', { path: cur.path, name })
|
|
597
|
+
.then(() => { setNewDirName(''); loadLocal(cur.path) })
|
|
598
|
+
.catch((e) => { setErr(String((e && e.message) || e)); setLoading(false) })
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 选用浮层中的本机目录:回填输入框(不直接提交),复核后再点「选用此本地路径」。
|
|
602
|
+
const acceptLocalPick = (p) => {
|
|
603
|
+
setPath(String(p || ''))
|
|
604
|
+
setErr('')
|
|
605
|
+
setLocalPop(null)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const switchTab = (t) => {
|
|
609
|
+
setTab(t); setErr('')
|
|
610
|
+
// each tab keeps its own path — never leak the other tab's selection
|
|
611
|
+
setPath('')
|
|
612
|
+
if (t === 'remote') {
|
|
613
|
+
// Windows remotes show a "This PC" drive view at the root; POSIX shows
|
|
614
|
+
// the filesystem root. Either way loadLevels('') lists that root.
|
|
615
|
+
if (machineId) loadLevels(machineId, '', 0)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Parent path of a display-form remote path (client-side, both separators).
|
|
620
|
+
const parentOf = (p) => {
|
|
621
|
+
const t = String(p || '')
|
|
622
|
+
if (!t || t === '/') return null
|
|
623
|
+
const win = /^[a-zA-Z]:/.test(t)
|
|
624
|
+
if (win && /^[a-zA-Z]:\\?$/.test(t)) return null // drive root: no parent
|
|
625
|
+
const sep = win ? '\\' : '/'
|
|
626
|
+
const idx = t.lastIndexOf(sep)
|
|
627
|
+
if (idx <= 0) return null
|
|
628
|
+
const par = t.slice(0, idx)
|
|
629
|
+
if (win && /^[a-zA-Z]:$/.test(par)) return par + '\\'
|
|
630
|
+
return par || '/'
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// enterDir(it): drive into the given entry — `it.path` is the FULL
|
|
634
|
+
// display-form path from the server (drives C:\, Windows and POSIX alike).
|
|
635
|
+
const enterDir = (it) => {
|
|
636
|
+
if (busy || loading || !it || !it.dir) return
|
|
637
|
+
loadLevels(machineId, it.path, (levels ? levels.length : 0))
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Navigate one level up in the browse popup. When the current level was
|
|
641
|
+
// opened directly at a deep path (levels.length === 1 — e.g. the popup
|
|
642
|
+
// was opened at the path bar's value) the parent is loaded as the new top
|
|
643
|
+
// level instead of being disabled.
|
|
644
|
+
const goUp = () => {
|
|
645
|
+
if (loading) return
|
|
646
|
+
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
647
|
+
if (!last) return
|
|
648
|
+
if (levels.length > 1) { setLevels((p) => p.slice(0, p.length - 1)); return }
|
|
649
|
+
const par = parentOf(last.path)
|
|
650
|
+
if (par) loadLevels(machineId, par, 1)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const loadSuggestions = (raw, mid) => {
|
|
654
|
+
const id = mid || machineId
|
|
655
|
+
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
656
|
+
const t = String(raw || '').trim()
|
|
657
|
+
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
658
|
+
// Separator-aware split: `\` when the input looks like a Windows path.
|
|
659
|
+
const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
|
|
660
|
+
const sep = backslash ? '\\' : '/'
|
|
661
|
+
const idx = t.lastIndexOf(sep)
|
|
662
|
+
let parent, lastSeg
|
|
663
|
+
if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
|
|
664
|
+
else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
|
|
665
|
+
else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
|
|
666
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
667
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
668
|
+
.then((res) => {
|
|
669
|
+
const list = Array.isArray(res && res.items)
|
|
670
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
|
|
671
|
+
: []
|
|
672
|
+
const matches = list
|
|
673
|
+
.filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
|
|
674
|
+
.slice(0, 40).map((it) => it.path)
|
|
675
|
+
setSuggest(matches)
|
|
676
|
+
setSuggestOpen(!!matches.length)
|
|
677
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const onPathChange = (raw) => {
|
|
681
|
+
setPath(raw); setErr('')
|
|
682
|
+
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
|
683
|
+
suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const continueSuggest = (dir) => {
|
|
687
|
+
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
688
|
+
setSuggestOpen(false)
|
|
689
|
+
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
690
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
691
|
+
.then((res) => {
|
|
692
|
+
const list = Array.isArray(res && res.items)
|
|
693
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
|
|
694
|
+
: []
|
|
695
|
+
const kids = list.slice(0, 40).map((it) => it.path)
|
|
696
|
+
setSuggest(kids)
|
|
697
|
+
setSuggestOpen(!!kids.length)
|
|
698
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const selectSuggestion = (s) => {
|
|
702
|
+
if (!s) { setSuggestOpen(false); return }
|
|
703
|
+
// Auto-append the trailing separator so the path reads as a directory
|
|
704
|
+
// and the user can keep typing the next segment right away.
|
|
705
|
+
const sep = String(s).includes('\\') ? '\\' : '/'
|
|
706
|
+
const withSep = String(s).replace(/[\\/]+$/, '') + sep
|
|
707
|
+
setPath(withSep); setErr(''); setSuggestOpen(false)
|
|
708
|
+
continueSuggest(withSep)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const commitPath = (p) => {
|
|
712
|
+
const target = String(p || '').trim()
|
|
713
|
+
if (!target || !machineId || busy) return
|
|
714
|
+
setPopOpen(false); setSuggestOpen(false)
|
|
715
|
+
api('POST', '/dsh-remote/mirror', { path: target }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const acceptBrowserPick = (p) => {
|
|
719
|
+
setPath(String(p || ''))
|
|
720
|
+
setSuggestOpen(false)
|
|
721
|
+
setPopOpen(false)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const pickHome = () => {
|
|
725
|
+
api('POST', '/dsh-remote/home').then((r) => {
|
|
726
|
+
if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
|
|
727
|
+
else setErr((r && (r.hint || r.error)) || '无法解析主目录')
|
|
728
|
+
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const mkdirHere = () => {
|
|
732
|
+
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
733
|
+
const base = (last && last.path) || '/'
|
|
734
|
+
const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
|
|
735
|
+
if (!nm || !nm.trim()) return
|
|
736
|
+
const sep = base.includes('\\') ? '\\' : '/'
|
|
737
|
+
const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
|
|
738
|
+
api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
|
|
739
|
+
.then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
|
|
740
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 本机浏览浮层的面包屑:browse 后端给出完整祖先链(含盘符根),可点击跳转。
|
|
744
|
+
const localCrumbs = (cur) => {
|
|
745
|
+
const crumbs = (cur && cur.crumbs) || []
|
|
746
|
+
if (!crumbs.length) return React.createElement('span', null, '/')
|
|
747
|
+
const out = []
|
|
748
|
+
crumbs.forEach((c, i) => {
|
|
749
|
+
if (i > 0) out.push(React.createElement('span', { key: 'sep' + i, style: { color: T.muted } }, ' › '))
|
|
750
|
+
out.push(React.createElement('span', {
|
|
751
|
+
key: c.path || i,
|
|
752
|
+
title: c.path,
|
|
753
|
+
onClick: () => loadLocal(c.path),
|
|
754
|
+
style: { cursor: 'pointer', color: i === crumbs.length - 1 ? T.label : T.ok, fontWeight: i === crumbs.length - 1 ? 700 : 400 },
|
|
755
|
+
}, c.name))
|
|
756
|
+
})
|
|
757
|
+
return React.createElement('span', null, out)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// 本机目录浏览浮层(browse 后端):进入子目录 / 面包屑跳转 / 回家目录 /
|
|
761
|
+
// 新建文件夹 / 选用回填。与远程浏览弹层同一套交互。
|
|
762
|
+
function renderLocalPopup() {
|
|
763
|
+
const cur = localPop
|
|
764
|
+
if (!cur) return null
|
|
765
|
+
const crumbs = cur.crumbs || []
|
|
766
|
+
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
767
|
+
return React.createElement('div', {
|
|
768
|
+
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
769
|
+
onClick: () => setLocalPop(null),
|
|
770
|
+
},
|
|
771
|
+
React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
|
|
772
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
773
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, '回上一级 ▴'),
|
|
774
|
+
cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: cur.home }, '🏠') : null,
|
|
775
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
|
|
776
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, '关闭 ✕'),
|
|
777
|
+
),
|
|
778
|
+
// 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
|
|
779
|
+
(cur.drives && cur.drives.length > 1) ? React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', padding: '6px 12px', borderBottom: '1px solid ' + T.border } },
|
|
780
|
+
cur.drives.map((d) => {
|
|
781
|
+
const active = String(cur.path).toUpperCase().startsWith(String(d.name).toUpperCase())
|
|
782
|
+
return React.createElement('button', {
|
|
783
|
+
key: d.path,
|
|
784
|
+
onClick: () => loadLocal(d.path),
|
|
785
|
+
disabled: loading,
|
|
786
|
+
title: d.path,
|
|
787
|
+
style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace', fontWeight: active ? 700 : 400, color: active ? T.ok : T.label },
|
|
788
|
+
}, d.name)
|
|
789
|
+
}),
|
|
790
|
+
) : null,
|
|
791
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
792
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
793
|
+
: (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
794
|
+
key: it.path || i, title: '进入 ' + it.name,
|
|
795
|
+
onClick: () => loadLocal(it.path),
|
|
796
|
+
style: { padding: '7px 12px', cursor: 'pointer', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
797
|
+
},
|
|
798
|
+
React.createElement('span', null, '📁'),
|
|
799
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
800
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(没有子目录 — 可直接选用当前目录)')),
|
|
801
|
+
cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, '(条目过多已截断)') : null,
|
|
802
|
+
),
|
|
803
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
|
|
804
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
|
|
805
|
+
React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: '新建文件夹', style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
|
|
806
|
+
React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, '新建'),
|
|
807
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + cur.path),
|
|
808
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, '选用此路径'),
|
|
809
|
+
),
|
|
810
|
+
),
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function renderDirPopup() {
|
|
815
|
+
if (!levels || !levels.length) {
|
|
816
|
+
return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
|
|
817
|
+
}
|
|
818
|
+
const last = levels[levels.length - 1]
|
|
819
|
+
const entries = last.all || []
|
|
820
|
+
return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
|
|
821
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
822
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, '回上一级 ▴'),
|
|
823
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(!!machineId, last.path, win, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
|
|
824
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
|
|
825
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
826
|
+
),
|
|
827
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
828
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
829
|
+
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
830
|
+
key: i, title: it.drive ? '进入驱动器 ' + it.name : (it.dir ? '进入 ' + it.name : '文件: ' + it.name),
|
|
831
|
+
onClick: it.dir ? () => enterDir(it) : undefined,
|
|
832
|
+
style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
833
|
+
},
|
|
834
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label, fontSize: 13 } }, it.drive ? '💽' : (it.dir
|
|
835
|
+
? React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor' }, React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }))
|
|
836
|
+
: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' })))),
|
|
837
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
838
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
|
|
839
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
840
|
+
),
|
|
841
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
842
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + (last.path || (win ? '(驱动器根)' : '/'))),
|
|
843
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
844
|
+
),
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!open) return null
|
|
849
|
+
const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
|
|
850
|
+
return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy) onCancel() } },
|
|
851
|
+
React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
852
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
|
|
853
|
+
React.createElement('div', null, '选择工作目录'),
|
|
854
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
|
|
855
|
+
),
|
|
856
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
857
|
+
tabBtn('local', '本机'),
|
|
858
|
+
tabBtn('remote', '远程'),
|
|
859
|
+
),
|
|
860
|
+
tab === 'local'
|
|
861
|
+
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
862
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。'),
|
|
863
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
864
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
|
|
865
|
+
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
|
|
866
|
+
),
|
|
867
|
+
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
|
|
868
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
869
|
+
localPop ? renderLocalPopup() : null,
|
|
870
|
+
)
|
|
871
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
872
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
873
|
+
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
|
|
874
|
+
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
875
|
+
React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
|
|
876
|
+
(machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
|
|
877
|
+
),
|
|
878
|
+
mOpen
|
|
879
|
+
? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
|
|
880
|
+
machines.length
|
|
881
|
+
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setPath(''); setMOpen(false); if (m.id) loadLevels(m.id, '', 0) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
|
|
882
|
+
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
883
|
+
)
|
|
884
|
+
: null,
|
|
885
|
+
),
|
|
886
|
+
),
|
|
887
|
+
// 最近工作区快捷入口
|
|
888
|
+
recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
|
|
889
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
|
|
890
|
+
recent.map((w, i) => React.createElement('button', { key: w + i, onClick: () => { setPath(w); setErr(''); continueSuggest(w) }, style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace' } }, w))) : null,
|
|
891
|
+
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
892
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
893
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? '输入远程路径,如 C:\\Users\\…(自动补全)' : '输入远程路径(自动补全)') : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
894
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
895
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
|
|
896
|
+
),
|
|
897
|
+
(suggestOpen && suggest.length)
|
|
898
|
+
? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
|
|
899
|
+
suggest.map((s, i) => React.createElement('div', { key: s + i, onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
|
|
900
|
+
)
|
|
901
|
+
: null,
|
|
902
|
+
),
|
|
903
|
+
popOpen ? renderDirPopup() : null,
|
|
904
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
905
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
906
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
|
|
907
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
908
|
+
),
|
|
909
|
+
),
|
|
910
|
+
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
911
|
+
React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
|
|
912
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
|
|
913
|
+
React.createElement('span', {}, '·'),
|
|
914
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
|
|
915
|
+
),
|
|
916
|
+
),
|
|
917
|
+
)
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ── better-sidebar integration (optional) ────────────────────────────────
|
|
921
|
+
const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
|
|
922
|
+
const SIDEBAR_FILE_ID = 'dsh-remote:file'
|
|
923
|
+
|
|
924
|
+
function fetchRemoteStatus() {
|
|
925
|
+
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Resolve the remote path this session shows. Issue #13: the ONLY source
|
|
929
|
+
// of truth is the session's own cwd mapped through /dsh-remote/resolve-
|
|
930
|
+
// mirror — a cwd outside any mirror dir means "this session is NOT a
|
|
931
|
+
// remote session", and the sidebar must NOT fall back to the machine
|
|
932
|
+
// workspace (that made an unrelated saved machine leak into local
|
|
933
|
+
// sessions).
|
|
934
|
+
function resolveSessionRemote(sessionId) {
|
|
935
|
+
if (!sessionId) return Promise.resolve('')
|
|
936
|
+
// 1) Prefer the client-side session list cwd (mirror dir under
|
|
937
|
+
// $DSH_HOME/remote-workspaces/...) passed as ?local=.
|
|
938
|
+
let local = ''
|
|
939
|
+
try {
|
|
940
|
+
const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
|
|
941
|
+
? SESSIONS.list.getSnapshot()
|
|
942
|
+
: null
|
|
943
|
+
const entry = list && list.byId && list.byId[sessionId]
|
|
944
|
+
if (entry && entry.cwd) local = String(entry.cwd)
|
|
945
|
+
} catch { /* sessions store shape differs */ }
|
|
946
|
+
if (local) {
|
|
947
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
|
|
948
|
+
.then((r) => (r && r.remotePath) || '')
|
|
949
|
+
.catch(() => '')
|
|
950
|
+
}
|
|
951
|
+
// 2) Fall back to host-side session header cwd.
|
|
952
|
+
return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
|
|
953
|
+
.then((r) => (r && r.remotePath) || '')
|
|
954
|
+
.catch(() => '')
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function readRemoteFile(path, maxBytes) {
|
|
958
|
+
return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
|
|
959
|
+
throw new Error('远程读取失败: ' + ((e && e.message) || e))
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function listRemoteDir(path) {
|
|
964
|
+
return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
|
|
965
|
+
throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function joinRemote(base, name) {
|
|
970
|
+
if (!base) return name
|
|
971
|
+
return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Remote explorer tab ────────────────────────────────────────────────
|
|
975
|
+
// Inline monochrome icons (fill: currentColor → follows the row text color,
|
|
976
|
+
// i.e. black on the sidebar). Folder open/closed + file.
|
|
977
|
+
const IconFolder = (open) => React.createElement('svg', {
|
|
978
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
|
|
979
|
+
},
|
|
980
|
+
open
|
|
981
|
+
? React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' })
|
|
982
|
+
: React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }),
|
|
983
|
+
)
|
|
984
|
+
const IconFile = () => React.createElement('svg', {
|
|
985
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
|
|
986
|
+
},
|
|
987
|
+
React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }),
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
// Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
|
|
991
|
+
// contract as the built-in local file tree (better-sidebar framework
|
|
992
|
+
// manages + persists the expanded set), so interaction and look match the
|
|
993
|
+
// local files tab. Data is listed live from the remote host over
|
|
994
|
+
// /dsh-remote/ls, rooted at this session's remote workspace dir.
|
|
995
|
+
function RemoteExplorerTab(props) {
|
|
996
|
+
const [status, setStatus] = React.useState(null)
|
|
997
|
+
const [root, setRoot] = React.useState('') // remote root path for this session
|
|
998
|
+
const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
|
|
999
|
+
const dataRef = React.useRef(data)
|
|
1000
|
+
const [err, setErr] = React.useState('')
|
|
1001
|
+
const [busy, setBusy] = React.useState('')
|
|
1002
|
+
const [picker, setPicker] = React.useState(false)
|
|
1003
|
+
const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
|
|
1004
|
+
const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
|
|
1005
|
+
const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
|
|
1006
|
+
/** Row whose path was just copied — shows a brief「已复制」label. */
|
|
1007
|
+
const [copiedPath, setCopiedPath] = React.useState('')
|
|
1008
|
+
const expanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
1009
|
+
|
|
1010
|
+
const storeLevel = (path, level) => {
|
|
1011
|
+
dataRef.current = { ...dataRef.current, [path]: level }
|
|
1012
|
+
setData(dataRef.current)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const loadDir = React.useCallback((dir, opts) => {
|
|
1016
|
+
const cur = dataRef.current
|
|
1017
|
+
const existing = cur[dir]
|
|
1018
|
+
// If we already have data (entries loaded) and this is a background
|
|
1019
|
+
// refresh, keep what we have — don't flash a loading state over a
|
|
1020
|
+
// populated tree.
|
|
1021
|
+
if (existing && !existing.loading && opts && opts.keep) return
|
|
1022
|
+
if (cur[dir] !== undefined && !cur[dir].loading) return
|
|
1023
|
+
storeLevel(dir, { loading: true })
|
|
1024
|
+
listRemoteDir(dir)
|
|
1025
|
+
.then((res) => {
|
|
1026
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
1027
|
+
const sorted = items.slice().sort((a, b) => {
|
|
1028
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
1029
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
1030
|
+
return String(a.name).localeCompare(String(b.name))
|
|
1031
|
+
})
|
|
1032
|
+
storeLevel(dir, { entries: sorted })
|
|
1033
|
+
})
|
|
1034
|
+
.catch((e) => {
|
|
1035
|
+
// Keep any previously-loaded entries under this dir (so a transient
|
|
1036
|
+
// SFTP failure doesn't collapse the whole subtree); surface a
|
|
1037
|
+
// message only when there was nothing to show before.
|
|
1038
|
+
const prev = dataRef.current[dir]
|
|
1039
|
+
if (prev && prev.entries) {
|
|
1040
|
+
storeLevel(dir, { ...prev, softError: String((e && e.message) || e) })
|
|
1041
|
+
} else {
|
|
1042
|
+
storeLevel(dir, { error: String((e && e.message) || e) })
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
}, [])
|
|
1046
|
+
|
|
1047
|
+
// Resolve this session's remote root, then load it and every expanded dir.
|
|
1048
|
+
// Re-runs whenever the session scope changes (switching conversations, or
|
|
1049
|
+
// the async sessionCwd fetch landing), so the sidebar follows the session's
|
|
1050
|
+
// own workspace dir.
|
|
1051
|
+
const scopeCwd = props.scope && props.scope.cwd
|
|
1052
|
+
const sessionId = props.scope && props.scope.sessionId
|
|
1053
|
+
const rootRef = React.useRef('')
|
|
1054
|
+
const refreshStatus = React.useCallback((opts) => {
|
|
1055
|
+
const applyRoot = (r, connected) => {
|
|
1056
|
+
const prevRoot = rootRef.current
|
|
1057
|
+
rootRef.current = r
|
|
1058
|
+
setRoot(r)
|
|
1059
|
+
// Full reset ONLY when the session's remote root actually changed
|
|
1060
|
+
// (different workspace / different conversation). When we just come
|
|
1061
|
+
// back to this tab with the same workspace, keep the loaded tree so
|
|
1062
|
+
// expanded dirs and their contents stay put.
|
|
1063
|
+
const sameRoot = prevRoot && r && prevRoot === r
|
|
1064
|
+
if (!sameRoot) {
|
|
1065
|
+
setData({})
|
|
1066
|
+
dataRef.current = {}
|
|
1067
|
+
}
|
|
1068
|
+
if (r) {
|
|
1069
|
+
// Not connected (e.g. freshly restarted, machine no longer active):
|
|
1070
|
+
// show a clear disconnected state instead of firing ls requests
|
|
1071
|
+
// that all fail with AggregateError / 500 in a loop.
|
|
1072
|
+
if (!connected) {
|
|
1073
|
+
storeLevel(r, { error: '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)' })
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
if (!sameRoot) {
|
|
1077
|
+
storeLevel(r, { loading: true })
|
|
1078
|
+
}
|
|
1079
|
+
listRemoteDir(r)
|
|
1080
|
+
.then((res) => {
|
|
1081
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
1082
|
+
const sorted = items.slice().sort((a, b) => {
|
|
1083
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
1084
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
1085
|
+
return String(a.name).localeCompare(String(b.name))
|
|
1086
|
+
})
|
|
1087
|
+
storeLevel(r, { entries: sorted, missing: !!(res && res.missing) })
|
|
1088
|
+
for (const dir of expanded) loadDir(dir, { keep: true })
|
|
1089
|
+
})
|
|
1090
|
+
.catch((e) => {
|
|
1091
|
+
// Transient connection failure: keep the previously visible
|
|
1092
|
+
// root level so the tree doesn't collapse to an error.
|
|
1093
|
+
const prev = dataRef.current[r]
|
|
1094
|
+
if (prev && prev.entries) {
|
|
1095
|
+
storeLevel(r, { ...prev, softError: String((e && e.message) || e) })
|
|
1096
|
+
} else {
|
|
1097
|
+
storeLevel(r, { error: String((e && e.message) || e) })
|
|
1098
|
+
}
|
|
1099
|
+
})
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const resolveRoot = () => {
|
|
1103
|
+
if (scopeCwd) {
|
|
1104
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
|
|
1105
|
+
.then((r) => (r && r.remotePath) || '')
|
|
1106
|
+
.catch(() => '')
|
|
1107
|
+
}
|
|
1108
|
+
if (sessionId) return resolveSessionRemote(sessionId)
|
|
1109
|
+
return Promise.resolve('')
|
|
1110
|
+
}
|
|
1111
|
+
resolveRoot().then((r) => {
|
|
1112
|
+
fetchRemoteStatus().then((s) => {
|
|
1113
|
+
setStatus(s)
|
|
1114
|
+
// Issue #13: NEVER fall back to the machine-level workspace here —
|
|
1115
|
+
// a session whose cwd is not a mirror is a LOCAL session and shows
|
|
1116
|
+
// the "no remote workspace" empty state instead.
|
|
1117
|
+
applyRoot(r || '', !!(s && s.connected))
|
|
1118
|
+
})
|
|
1119
|
+
})
|
|
1120
|
+
}, [scopeCwd, sessionId])
|
|
1121
|
+
|
|
1122
|
+
React.useEffect(() => { refreshStatus() }, [refreshStatus])
|
|
1123
|
+
React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
|
|
1124
|
+
// Load dirs as they get expanded (keep already-loaded content).
|
|
1125
|
+
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
|
|
1126
|
+
// ── Context-menu portal: append to document.body so the sidebar's
|
|
1127
|
+
// overflow/transform cannot clip it. Pure DOM, no React portal needed.
|
|
1128
|
+
React.useEffect(() => {
|
|
1129
|
+
if (!menu) return
|
|
1130
|
+
const host = document.createElement('div')
|
|
1131
|
+
host.style.cssText = 'position:fixed;z-index:999999;pointer-events:auto'
|
|
1132
|
+
const box = document.createElement('div')
|
|
1133
|
+
box.style.cssText = 'position:fixed;left:' + menu.x + 'px;top:' + menu.y + 'px;z-index:999999;background:#1f1f23;border:1px solid rgba(128,128,128,0.5);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.4);min-width:170;font-family:system-ui,sans-serif'
|
|
1134
|
+
const mkItem = (label, danger, fn) => {
|
|
1135
|
+
const d = document.createElement('div')
|
|
1136
|
+
d.textContent = label
|
|
1137
|
+
d.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:' + (danger ? '#e06c75' : '#e4e4e7') + ';border-radius:4px;white-space:nowrap;transition:background 0.1s'
|
|
1138
|
+
d.onmouseenter = () => { d.style.background = 'rgba(128,128,128,0.14)' }
|
|
1139
|
+
d.onmouseleave = () => { d.style.background = 'transparent' }
|
|
1140
|
+
d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
|
|
1141
|
+
return d
|
|
1142
|
+
}
|
|
1143
|
+
box.appendChild(mkItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
|
|
1144
|
+
box.appendChild(mkItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })))
|
|
1145
|
+
box.appendChild(mkItem('📋 复制相对地址', false, () => copyPath(relPath(menu.path), menu.path)))
|
|
1146
|
+
box.appendChild(mkItem('📋 复制绝对地址', false, () => copyPath(menu.path, menu.path)))
|
|
1147
|
+
box.appendChild(mkItem('✏ 重命名', false, () => { const nm = window.prompt('重命名为(新名字)', menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
|
|
1148
|
+
box.appendChild(mkItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => { if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true) }))
|
|
1149
|
+
host.appendChild(box)
|
|
1150
|
+
document.body.appendChild(host)
|
|
1151
|
+
const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
|
|
1152
|
+
// Delay adding listener so the same mousedown that opened the menu doesn't close it
|
|
1153
|
+
const tid = setTimeout(() => document.addEventListener('mousedown', close), 0)
|
|
1154
|
+
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
|
|
1155
|
+
}, [menu])
|
|
1156
|
+
|
|
1157
|
+
const openFile = (p) => {
|
|
1158
|
+
const name = String(p).split(/[\\/]/).pop() || p
|
|
1159
|
+
props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const doFs = (op, payload, refreshParent) => {
|
|
1163
|
+
setBusy(op)
|
|
1164
|
+
return api('POST', '/dsh-remote/fs', { op, ...payload })
|
|
1165
|
+
.then(() => {
|
|
1166
|
+
setErr('')
|
|
1167
|
+
if (refreshParent) {
|
|
1168
|
+
dataRef.current = {}
|
|
1169
|
+
setData({})
|
|
1170
|
+
if (root) { storeLevel(root, { loading: true }); loadDir(root) }
|
|
1171
|
+
for (const dir of expanded) loadDir(dir)
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1175
|
+
.finally(() => setBusy(''))
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const onCtx = (e, path, isDir, name) => {
|
|
1179
|
+
e.preventDefault(); e.stopPropagation()
|
|
1180
|
+
setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
|
|
1181
|
+
}
|
|
1182
|
+
const ctxItem = (label, danger, fn) => React.createElement('div', {
|
|
1183
|
+
onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
|
|
1184
|
+
style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
|
|
1185
|
+
}, label)
|
|
1186
|
+
|
|
1187
|
+
// The path shown by「复制相对地址」: relative to this session's remote
|
|
1188
|
+
// workspace root (mirror of the built-in local tree's cwd-relative copy).
|
|
1189
|
+
const relPath = (p) => {
|
|
1190
|
+
if (!root) return p
|
|
1191
|
+
const r = String(root).replace(/[\\/]+$/, '')
|
|
1192
|
+
const s = String(p)
|
|
1193
|
+
if (s === r) return '.'
|
|
1194
|
+
if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
1195
|
+
if (s.startsWith(r + '\\')) return s.slice(r.length + 1)
|
|
1196
|
+
return s
|
|
1197
|
+
}
|
|
1198
|
+
// Copy `text`, then briefly flag the row whose path was copied.
|
|
1199
|
+
const copyPath = (text, path) => {
|
|
1200
|
+
copyToClipboard(text).then((ok) => {
|
|
1201
|
+
if (!ok) return
|
|
1202
|
+
setCopiedPath(path)
|
|
1203
|
+
window.setTimeout(() => {
|
|
1204
|
+
setCopiedPath((cur) => (cur === path ? '' : cur))
|
|
1205
|
+
}, 1200)
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
/** Trailing「已复制」feedback for a row (after a successful copy). */
|
|
1209
|
+
const copiedMark = (path) => (copiedPath === path
|
|
1210
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, '已复制')
|
|
1211
|
+
: null)
|
|
1212
|
+
|
|
1213
|
+
// One tree row (dir toggles via the framework's onToggleDir; file opens).
|
|
1214
|
+
// Text uses the normal foreground (theme-following) — no accent green.
|
|
1215
|
+
const row = (entry, depth) => {
|
|
1216
|
+
const pad = depth * 22 + 6
|
|
1217
|
+
const hovered = hoverPath === entry.path
|
|
1218
|
+
const base = {
|
|
1219
|
+
display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
|
|
1220
|
+
borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
|
|
1221
|
+
overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
|
|
1222
|
+
background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
|
|
1223
|
+
color: T.label,
|
|
1224
|
+
}
|
|
1225
|
+
if (entry.type === 'dir') {
|
|
1226
|
+
const isOpen = expanded.includes(entry.path)
|
|
1227
|
+
return React.createElement('div', { key: entry.path },
|
|
1228
|
+
React.createElement('div', {
|
|
1229
|
+
role: 'button', tabIndex: 0,
|
|
1230
|
+
onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
|
|
1231
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
|
|
1232
|
+
onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
|
|
1233
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
1234
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
1235
|
+
style: base,
|
|
1236
|
+
title: entry.path,
|
|
1237
|
+
},
|
|
1238
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
|
|
1239
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
|
|
1240
|
+
copiedMark(entry.path),
|
|
1241
|
+
),
|
|
1242
|
+
isOpen ? renderLevel(entry.path, depth + 1) : null,
|
|
1243
|
+
)
|
|
1244
|
+
}
|
|
1245
|
+
return React.createElement('div', {
|
|
1246
|
+
role: 'button', tabIndex: 0, key: entry.path,
|
|
1247
|
+
onClick: () => openFile(entry.path),
|
|
1248
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
|
|
1249
|
+
onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
|
|
1250
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
1251
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
1252
|
+
style: base,
|
|
1253
|
+
title: entry.path,
|
|
1254
|
+
},
|
|
1255
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
|
|
1256
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
|
|
1257
|
+
copiedMark(entry.path),
|
|
1258
|
+
(entry.size != null || entry.mtime)
|
|
1259
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } },
|
|
1260
|
+
humanSize(entry.size) + (entry.mtime ? ' ' + fmtTime(entry.mtime) : ''))
|
|
1261
|
+
: null,
|
|
1262
|
+
)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const renderLevel = (dir, depth) => {
|
|
1266
|
+
const level = data[dir]
|
|
1267
|
+
if (level === undefined || (level.loading && !level.entries)) {
|
|
1268
|
+
return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
|
|
1269
|
+
}
|
|
1270
|
+
if (level.missing) {
|
|
1271
|
+
return React.createElement('div', { key: dir + ':missing', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '目录不存在或已被删除')
|
|
1272
|
+
}
|
|
1273
|
+
if (level.error !== undefined) {
|
|
1274
|
+
return React.createElement('div', { key: dir + ':err', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, color: '#e06c75' } }, level.error)
|
|
1275
|
+
}
|
|
1276
|
+
const items = level.entries || []
|
|
1277
|
+
const soft = level.softError
|
|
1278
|
+
? React.createElement('div', { key: dir + ':softerr', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 11, opacity: 0.6, color: '#e6c07b' } }, '⚠ ' + level.softError + '(显示缓存,可点 ↻ 刷新)')
|
|
1279
|
+
: null
|
|
1280
|
+
if (!items.length) return soft || null
|
|
1281
|
+
return React.createElement('div', { key: dir + ':list' },
|
|
1282
|
+
items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)),
|
|
1283
|
+
soft,
|
|
1284
|
+
)
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
|
|
1288
|
+
const rootLevel = data[root]
|
|
1289
|
+
|
|
1290
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
1291
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
|
|
1292
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
|
|
1293
|
+
React.createElement('button', {
|
|
1294
|
+
style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
|
|
1295
|
+
onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
|
|
1296
|
+
}, '新建目录'),
|
|
1297
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
|
|
1298
|
+
),
|
|
1299
|
+
status && status.workspace && root
|
|
1300
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
1301
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
|
|
1302
|
+
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
1303
|
+
open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
|
|
1304
|
+
})) : null,
|
|
1305
|
+
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
|
|
1306
|
+
!root
|
|
1307
|
+
? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
|
|
1308
|
+
: React.createElement('div', { key: root },
|
|
1309
|
+
React.createElement('div', {
|
|
1310
|
+
role: 'button', tabIndex: 0,
|
|
1311
|
+
onClick: () => setRootOpen((v) => !v),
|
|
1312
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
|
|
1313
|
+
onContextMenu: (e) => onCtx(e, root, true, rootName),
|
|
1314
|
+
style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: 6, borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: T.label, fontWeight: 600 },
|
|
1315
|
+
title: root,
|
|
1316
|
+
},
|
|
1317
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
|
|
1318
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
|
|
1319
|
+
),
|
|
1320
|
+
rootLevel && rootOpen ? renderLevel(root, 1) : null,
|
|
1321
|
+
),
|
|
1322
|
+
),
|
|
1323
|
+
// context menu — now rendered via DOM portal in useEffect above
|
|
1324
|
+
null,
|
|
1325
|
+
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ── Remote file tab (opened by the explorer; live read + explicit edit) ──
|
|
1330
|
+
function RemoteFileTab(props) {
|
|
1331
|
+
const { tab, scope } = props
|
|
1332
|
+
const path = tab.path || ''
|
|
1333
|
+
const [data, setData] = React.useState(null)
|
|
1334
|
+
const [err, setErr] = React.useState('')
|
|
1335
|
+
const [loading, setLoading] = React.useState(true)
|
|
1336
|
+
const [edit, setEdit] = React.useState(false)
|
|
1337
|
+
const [draft, setDraft] = React.useState('')
|
|
1338
|
+
const [saving, setSaving] = React.useState(false)
|
|
1339
|
+
|
|
1340
|
+
const load = () => {
|
|
1341
|
+
if (!path) { setLoading(false); return }
|
|
1342
|
+
let cancelled = false
|
|
1343
|
+
setLoading(true); setErr(''); setData(null); setEdit(false)
|
|
1344
|
+
readRemoteFile(path, 256 * 1024)
|
|
1345
|
+
.then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
|
|
1346
|
+
.catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
|
|
1347
|
+
return () => { cancelled = true }
|
|
1348
|
+
}
|
|
1349
|
+
React.useEffect(load, [path])
|
|
1350
|
+
|
|
1351
|
+
const startEdit = () => {
|
|
1352
|
+
if (!data || data.binary) return
|
|
1353
|
+
setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
|
|
1354
|
+
setEdit(true); setErr('')
|
|
1355
|
+
}
|
|
1356
|
+
const saveRemote = () => {
|
|
1357
|
+
setSaving(true); setErr('')
|
|
1358
|
+
apiRaw('POST', '/dsh-remote/write', { path, content: draft })
|
|
1359
|
+
.then((r) => {
|
|
1360
|
+
if (r.status === 409) {
|
|
1361
|
+
setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
|
|
1365
|
+
setEdit(false)
|
|
1366
|
+
load()
|
|
1367
|
+
})
|
|
1368
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1369
|
+
.finally(() => setSaving(false))
|
|
1370
|
+
}
|
|
1371
|
+
const downloadMirror = () => {
|
|
1372
|
+
setSaving(true); setErr('')
|
|
1373
|
+
api('POST', '/dsh-remote/fs', { op: 'download', path })
|
|
1374
|
+
.then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
|
|
1375
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1376
|
+
.finally(() => setSaving(false))
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const baseName = path.split(/[\\/]/).pop() || path
|
|
1380
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
1381
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
|
|
1382
|
+
React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
|
|
1383
|
+
React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
|
|
1384
|
+
React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
|
|
1385
|
+
edit
|
|
1386
|
+
? React.createElement(React.Fragment, null,
|
|
1387
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
|
|
1388
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
|
|
1389
|
+
: React.createElement(React.Fragment, null,
|
|
1390
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
|
|
1391
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
|
|
1392
|
+
),
|
|
1393
|
+
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
|
|
1394
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
|
|
1395
|
+
: err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
|
|
1396
|
+
: (data && data.binary
|
|
1397
|
+
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
|
|
1398
|
+
'二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
|
|
1399
|
+
: (edit
|
|
1400
|
+
? React.createElement('textarea', { value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, style: { width: '100%', height: '100%', boxSizing: 'border-box', border: 'none', outline: 'none', resize: 'none', background: 'transparent', color: '#e4e4e7', fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } })
|
|
1401
|
+
: React.createElement('div', { style: { fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
|
|
1402
|
+
(data && data.content != null) ? data.content : '(空文件)',
|
|
1403
|
+
(data && data.truncated)
|
|
1404
|
+
? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
|
|
1405
|
+
: null,
|
|
1406
|
+
))),
|
|
1407
|
+
),
|
|
1408
|
+
)
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ── better-sidebar registration (guarded: plugin may be absent) ────────
|
|
1412
|
+
function registerSidebarIntegration(ctx) {
|
|
1413
|
+
const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
|
|
1414
|
+
if (!bs || typeof bs.registerTab !== 'function') return
|
|
1415
|
+
const disposers = []
|
|
1416
|
+
try {
|
|
1417
|
+
disposers.push(bs.registerTab({
|
|
1418
|
+
id: SIDEBAR_EXPLORER_ID,
|
|
1419
|
+
title: () => '远程文件',
|
|
1420
|
+
icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
|
|
1421
|
+
order: 55,
|
|
1422
|
+
single: true,
|
|
1423
|
+
component: (props) => React.createElement(RemoteExplorerTab, props),
|
|
1424
|
+
}))
|
|
1425
|
+
disposers.push(bs.registerTab({
|
|
1426
|
+
id: SIDEBAR_FILE_ID,
|
|
1427
|
+
title: () => '远程文件',
|
|
1428
|
+
hidden: true, // not in the + menu; opened from the explorer tree
|
|
1429
|
+
dedupeKey: (tab) => tab.path,
|
|
1430
|
+
component: (props) => React.createElement(RemoteFileTab, props),
|
|
1431
|
+
}))
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
console.warn('[dsh-remote] better-sidebar integration skipped:', e)
|
|
1434
|
+
disposers.forEach((d) => { try { d() } catch {} })
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
let autoOpenedFor = new Set()
|
|
1438
|
+
let disposeAuto = null
|
|
1439
|
+
const tryAutoOpen = () => {
|
|
1440
|
+
if (typeof bs.subscribeState !== 'function') return
|
|
1441
|
+
const snap = bs.getSnapshot && bs.getSnapshot()
|
|
1442
|
+
if (!snap || !snap.sessionId) return
|
|
1443
|
+
if (autoOpenedFor.has(snap.sessionId)) return
|
|
1444
|
+
const sessionId = snap.sessionId
|
|
1445
|
+
// Issue #13: only auto-open the Remote Files tab for sessions that are
|
|
1446
|
+
// genuinely remote (cwd inside a mirror). A plain local session must
|
|
1447
|
+
// not get a remote tab popped open by a machine that happens to be
|
|
1448
|
+
// saved as "current".
|
|
1449
|
+
resolveSessionRemote(sessionId).then((remotePath) => {
|
|
1450
|
+
if (!remotePath || autoOpenedFor.has(sessionId)) return
|
|
1451
|
+
autoOpenedFor.add(sessionId)
|
|
1452
|
+
try {
|
|
1453
|
+
bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
|
|
1454
|
+
} catch (e2) {
|
|
1455
|
+
console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
|
|
1456
|
+
}
|
|
1457
|
+
})
|
|
1458
|
+
}
|
|
1459
|
+
tryAutoOpen()
|
|
1460
|
+
if (typeof bs.subscribeState === 'function') {
|
|
1461
|
+
disposeAuto = bs.subscribeState(tryAutoOpen)
|
|
1462
|
+
disposers.push(disposeAuto)
|
|
1463
|
+
}
|
|
1464
|
+
return () => disposers.forEach((d) => { try { d() } catch {} })
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function apply(ctx) {
|
|
1468
|
+
const slots = ctx.get('slots')
|
|
1469
|
+
if (slots === undefined) return
|
|
1470
|
+
WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
|
|
1471
|
+
SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
|
|
1472
|
+
slots.inject('settings.section', () =>
|
|
1473
|
+
slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
1474
|
+
)
|
|
1475
|
+
slots.inject(
|
|
1476
|
+
'conversation.hero.workspace.directoryFlow',
|
|
1477
|
+
() => slots.inject('sidebar.workspaces.directoryFlow',
|
|
1478
|
+
function* () {
|
|
1479
|
+
yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
1480
|
+
yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
1481
|
+
},
|
|
1482
|
+
),
|
|
1483
|
+
)
|
|
1484
|
+
ctx.inject(['betterSidebar'], (inner) => {
|
|
1485
|
+
const bsDispose = registerSidebarIntegration(inner)
|
|
1486
|
+
if (bsDispose) {
|
|
1487
|
+
inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
|
|
1488
|
+
}
|
|
1489
|
+
})
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
exports.name = name
|
|
1493
|
+
exports.apply = apply
|
|
1494
|
+
return module.exports
|
|
1495
|
+
},
|
|
1496
|
+
})
|