dsh-remote 0.8.14 → 0.8.15
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 +236 -234
- package/README.zh.md +195 -190
- package/lib/binding.js +97 -97
- package/lib/client.js +1993 -1985
- package/lib/credential.js +177 -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 +3288 -3230
- package/lib/paths.js +217 -217
- 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 +2 -2
package/lib/client.js
CHANGED
|
@@ -1,1985 +1,1993 @@
|
|
|
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 (/dsh-remote/local-pick)
|
|
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
|
-
// ── i18n (issue #14) ────────────────────────────────────────────────────
|
|
32
|
-
// Dictionaries are keyed by stable ids, zh + en share the same key set.
|
|
33
|
-
// Lookup order: active locale → en fallback → the key itself (so a missing
|
|
34
|
-
// translation stays visible instead of blanking the UI).
|
|
35
|
-
const NS = 'dsh-remote'
|
|
36
|
-
const L = {
|
|
37
|
-
zh: {
|
|
38
|
-
'settings.title': '远程工作区(dsh-remote)',
|
|
39
|
-
'settings.intro': '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。',
|
|
40
|
-
'settings.machines': '已配置的机器',
|
|
41
|
-
'settings.noMachines': '还没有机器。在下方添加。',
|
|
42
|
-
'settings.current': '当前',
|
|
43
|
-
'settings.edit': '编辑',
|
|
44
|
-
'settings.delete': '删除',
|
|
45
|
-
'settings.setCurrent': '设为当前',
|
|
46
|
-
'settings.currentMachine': '当前远程机:{name}',
|
|
47
|
-
'settings.clearCurrent': '取消设为当前(active remote = none)',
|
|
48
|
-
'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
|
|
49
|
-
'settings.addMachine': '添加机器',
|
|
50
|
-
'settings.editMachine': '编辑机器',
|
|
51
|
-
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
52
|
-
'settings.sshCollapse': '收起',
|
|
53
|
-
'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
|
|
54
|
-
'settings.name': '名称',
|
|
55
|
-
'settings.host': '主机',
|
|
56
|
-
'settings.port': '端口',
|
|
57
|
-
'settings.user': '用户',
|
|
58
|
-
'settings.password': '密码',
|
|
59
|
-
'settings.advCollapse': '▲ 收起高级配置',
|
|
60
|
-
'settings.advExpand': '▼ 高级配置(私钥 / 跳板机 / agent 等)',
|
|
61
|
-
'settings.privateKey': '私钥路径',
|
|
62
|
-
'settings.passphrase': 'Passphrase',
|
|
63
|
-
'settings.workspace': '默认工作区',
|
|
64
|
-
'settings.hostKeyMode': 'HostKey 模式',
|
|
65
|
-
'settings.hkDefault': '(默认 accept-new)',
|
|
66
|
-
'settings.hkAccept': 'accept-new(信任首次,之后校验)',
|
|
67
|
-
'settings.hkVerify': 'verify(严格:拒绝陌生主机)',
|
|
68
|
-
'settings.hkOff': 'off(不校验,不推荐)',
|
|
69
|
-
'settings.agent': 'SSH agent(SSH_AUTH_SOCK)',
|
|
70
|
-
'settings.keyboardInteractive': 'keyboard-interactive(OTP/动态码)',
|
|
71
|
-
'settings.encryptPassword': '加密保存密码(系统钥匙串)',
|
|
72
|
-
'settings.proxyHost': '跳板机',
|
|
73
|
-
'settings.proxyPortUser': '端口/用户',
|
|
74
|
-
'settings.proxyKey': '跳板私钥',
|
|
75
|
-
'settings.cancelEdit': '取消编辑',
|
|
76
|
-
'settings.clear': '清空',
|
|
77
|
-
'settings.testing': '连接中…',
|
|
78
|
-
'settings.testOk': '✓ 连接成功',
|
|
79
|
-
'settings.testConn': '测试连接',
|
|
80
|
-
'settings.saving': '保存中…',
|
|
81
|
-
'settings.save': '保存',
|
|
82
|
-
'settings.recentWorkspaces': '当前机器最近工作区(一键切换)',
|
|
83
|
-
'settings.switch': '切换',
|
|
84
|
-
'settings.forwards': '端口转发(SSH 隧道)',
|
|
85
|
-
'settings.fwdStop': '停止',
|
|
86
|
-
'settings.fwdStart': '启动',
|
|
87
|
-
'settings.fwdRemove': '删除',
|
|
88
|
-
'settings.fwdDirLocal': '本地→远端',
|
|
89
|
-
'settings.fwdDirReverse': '远端→本地',
|
|
90
|
-
'settings.fwdListen': '监听端口',
|
|
91
|
-
'settings.fwdTargetHost': '目标主机',
|
|
92
|
-
'settings.fwdTargetPort': '目标端口',
|
|
93
|
-
'settings.fwdAuto': '自动重连',
|
|
94
|
-
'settings.fwdAdd': '添加转发',
|
|
95
|
-
'settings.audit': '最近执行的远程命令(审计日志)',
|
|
96
|
-
'settings.auditEmpty': '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)',
|
|
97
|
-
'settings.update': '更新',
|
|
98
|
-
'settings.updateChecking': '版本信息加载中…',
|
|
99
|
-
'settings.checking': '检查中…',
|
|
100
|
-
'settings.checkUpdate': '检查更新',
|
|
101
|
-
'settings.updateNow': '立即更新',
|
|
102
|
-
'settings.updateMode': '更新模式:',
|
|
103
|
-
'settings.updateManual': '手动',
|
|
104
|
-
'settings.updateAuto': '自动',
|
|
105
|
-
'settings.updateOff': '关闭',
|
|
106
|
-
'settings.like': '觉得好用?',
|
|
107
|
-
'settings.star': '⭐ 去 GitHub 点个 Star',
|
|
108
|
-
'settings.feedback': '💬 反馈建议 / 提 issue',
|
|
109
|
-
'settings.deleted': '已删除',
|
|
110
|
-
'settings.saved': '已保存更新',
|
|
111
|
-
'settings.added': '已添加 — 可设为当前',
|
|
112
|
-
'settings.
|
|
113
|
-
'settings.
|
|
114
|
-
'settings.
|
|
115
|
-
'settings.
|
|
116
|
-
'settings.
|
|
117
|
-
'settings.
|
|
118
|
-
'settings.
|
|
119
|
-
'settings.
|
|
120
|
-
'settings.
|
|
121
|
-
'settings.
|
|
122
|
-
'settings.
|
|
123
|
-
'settings.
|
|
124
|
-
'settings.
|
|
125
|
-
'settings.
|
|
126
|
-
'settings.
|
|
127
|
-
'settings.
|
|
128
|
-
'settings.
|
|
129
|
-
'settings.
|
|
130
|
-
'settings.
|
|
131
|
-
'settings.
|
|
132
|
-
'settings.
|
|
133
|
-
'settings.
|
|
134
|
-
'settings.
|
|
135
|
-
'settings.
|
|
136
|
-
'settings.
|
|
137
|
-
'settings.
|
|
138
|
-
'settings.
|
|
139
|
-
'
|
|
140
|
-
'picker.
|
|
141
|
-
'picker.
|
|
142
|
-
'picker.
|
|
143
|
-
'picker.
|
|
144
|
-
'picker.
|
|
145
|
-
'picker.
|
|
146
|
-
'picker.
|
|
147
|
-
'picker.
|
|
148
|
-
'picker.
|
|
149
|
-
'picker.
|
|
150
|
-
'picker.
|
|
151
|
-
'picker.
|
|
152
|
-
'picker.
|
|
153
|
-
'picker.
|
|
154
|
-
'picker.
|
|
155
|
-
'picker.
|
|
156
|
-
'picker.
|
|
157
|
-
'picker.
|
|
158
|
-
'picker.
|
|
159
|
-
'picker.
|
|
160
|
-
'picker.
|
|
161
|
-
'picker.
|
|
162
|
-
'picker.
|
|
163
|
-
'picker.
|
|
164
|
-
'picker.
|
|
165
|
-
'picker.
|
|
166
|
-
'picker.
|
|
167
|
-
'picker.
|
|
168
|
-
'picker.
|
|
169
|
-
'picker.
|
|
170
|
-
'picker.
|
|
171
|
-
'picker.
|
|
172
|
-
'picker.
|
|
173
|
-
'picker.
|
|
174
|
-
'picker.
|
|
175
|
-
'picker.
|
|
176
|
-
'picker.
|
|
177
|
-
'picker.
|
|
178
|
-
'picker.
|
|
179
|
-
'picker.
|
|
180
|
-
'picker.
|
|
181
|
-
'picker.
|
|
182
|
-
'picker.
|
|
183
|
-
'picker.
|
|
184
|
-
'picker.
|
|
185
|
-
'picker.
|
|
186
|
-
'picker.
|
|
187
|
-
'picker.
|
|
188
|
-
'picker.
|
|
189
|
-
'picker.
|
|
190
|
-
'
|
|
191
|
-
'explorer.
|
|
192
|
-
'explorer.
|
|
193
|
-
'explorer.
|
|
194
|
-
'explorer.
|
|
195
|
-
'explorer.
|
|
196
|
-
'explorer.
|
|
197
|
-
'explorer.
|
|
198
|
-
'explorer.
|
|
199
|
-
'explorer.
|
|
200
|
-
'explorer.
|
|
201
|
-
'explorer.
|
|
202
|
-
'explorer.
|
|
203
|
-
'explorer.
|
|
204
|
-
'explorer.
|
|
205
|
-
'explorer.
|
|
206
|
-
'explorer.
|
|
207
|
-
'explorer.
|
|
208
|
-
'explorer.
|
|
209
|
-
'explorer.
|
|
210
|
-
'explorer.
|
|
211
|
-
'explorer.
|
|
212
|
-
'explorer.
|
|
213
|
-
'explorer.
|
|
214
|
-
'explorer.
|
|
215
|
-
'
|
|
216
|
-
'file.
|
|
217
|
-
'file.
|
|
218
|
-
'file.
|
|
219
|
-
'file.
|
|
220
|
-
'file.
|
|
221
|
-
'file.
|
|
222
|
-
'file.
|
|
223
|
-
'file.
|
|
224
|
-
'file.
|
|
225
|
-
'file.
|
|
226
|
-
'file.
|
|
227
|
-
'file.
|
|
228
|
-
'file.
|
|
229
|
-
'file.
|
|
230
|
-
'file.
|
|
231
|
-
'file.
|
|
232
|
-
'
|
|
233
|
-
'common.
|
|
234
|
-
'common.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
'settings.
|
|
239
|
-
'settings.
|
|
240
|
-
'settings.
|
|
241
|
-
'settings.
|
|
242
|
-
'settings.
|
|
243
|
-
'settings.
|
|
244
|
-
'settings.
|
|
245
|
-
'settings.
|
|
246
|
-
'settings.
|
|
247
|
-
'settings.
|
|
248
|
-
'settings.
|
|
249
|
-
'settings.
|
|
250
|
-
'settings.
|
|
251
|
-
'settings.
|
|
252
|
-
'settings.
|
|
253
|
-
'settings.
|
|
254
|
-
'settings.
|
|
255
|
-
'settings.
|
|
256
|
-
'settings.
|
|
257
|
-
'settings.
|
|
258
|
-
'settings.
|
|
259
|
-
'settings.
|
|
260
|
-
'settings.
|
|
261
|
-
'settings.
|
|
262
|
-
'settings.
|
|
263
|
-
'settings.
|
|
264
|
-
'settings.
|
|
265
|
-
'settings.
|
|
266
|
-
'settings.
|
|
267
|
-
'settings.
|
|
268
|
-
'settings.
|
|
269
|
-
'settings.
|
|
270
|
-
'settings.
|
|
271
|
-
'settings.
|
|
272
|
-
'settings.
|
|
273
|
-
'settings.
|
|
274
|
-
'settings.
|
|
275
|
-
'settings.
|
|
276
|
-
'settings.
|
|
277
|
-
'settings.
|
|
278
|
-
'settings.
|
|
279
|
-
'settings.
|
|
280
|
-
'settings.
|
|
281
|
-
'settings.
|
|
282
|
-
'settings.
|
|
283
|
-
'settings.
|
|
284
|
-
'settings.
|
|
285
|
-
'settings.
|
|
286
|
-
'settings.
|
|
287
|
-
'settings.
|
|
288
|
-
'settings.
|
|
289
|
-
'settings.
|
|
290
|
-
'settings.
|
|
291
|
-
'settings.
|
|
292
|
-
'settings.
|
|
293
|
-
'settings.
|
|
294
|
-
'settings.
|
|
295
|
-
'settings.
|
|
296
|
-
'settings.
|
|
297
|
-
'settings.
|
|
298
|
-
'settings.
|
|
299
|
-
'settings.
|
|
300
|
-
'settings.
|
|
301
|
-
'settings.
|
|
302
|
-
'settings.
|
|
303
|
-
'settings.
|
|
304
|
-
'settings.
|
|
305
|
-
'settings.
|
|
306
|
-
'settings.
|
|
307
|
-
'settings.
|
|
308
|
-
'settings.
|
|
309
|
-
'settings.
|
|
310
|
-
'settings.
|
|
311
|
-
'settings.
|
|
312
|
-
'settings.
|
|
313
|
-
'settings.
|
|
314
|
-
'settings.
|
|
315
|
-
'settings.
|
|
316
|
-
'settings.
|
|
317
|
-
'settings.
|
|
318
|
-
'settings.
|
|
319
|
-
'settings.
|
|
320
|
-
'settings.
|
|
321
|
-
'settings.
|
|
322
|
-
'settings.
|
|
323
|
-
'settings.
|
|
324
|
-
'settings.
|
|
325
|
-
'settings.
|
|
326
|
-
'settings.
|
|
327
|
-
'settings.
|
|
328
|
-
'settings.
|
|
329
|
-
'settings.
|
|
330
|
-
'settings.
|
|
331
|
-
'settings.
|
|
332
|
-
'settings.
|
|
333
|
-
'settings.
|
|
334
|
-
'settings.
|
|
335
|
-
'settings.
|
|
336
|
-
'settings.
|
|
337
|
-
'settings.
|
|
338
|
-
'
|
|
339
|
-
'
|
|
340
|
-
'picker.
|
|
341
|
-
'picker.
|
|
342
|
-
'picker.
|
|
343
|
-
'picker.
|
|
344
|
-
'picker.
|
|
345
|
-
'picker.
|
|
346
|
-
'picker.
|
|
347
|
-
'picker.
|
|
348
|
-
'picker.
|
|
349
|
-
'picker.
|
|
350
|
-
'picker.
|
|
351
|
-
'picker.
|
|
352
|
-
'picker.
|
|
353
|
-
'picker.
|
|
354
|
-
'picker.
|
|
355
|
-
'picker.
|
|
356
|
-
'picker.
|
|
357
|
-
'picker.
|
|
358
|
-
'picker.
|
|
359
|
-
'picker.
|
|
360
|
-
'picker.
|
|
361
|
-
'picker.
|
|
362
|
-
'picker.
|
|
363
|
-
'picker.
|
|
364
|
-
'picker.
|
|
365
|
-
'picker.
|
|
366
|
-
'picker.
|
|
367
|
-
'picker.
|
|
368
|
-
'picker.
|
|
369
|
-
'picker.
|
|
370
|
-
'picker.
|
|
371
|
-
'picker.
|
|
372
|
-
'picker.
|
|
373
|
-
'picker.
|
|
374
|
-
'picker.
|
|
375
|
-
'picker.
|
|
376
|
-
'picker.
|
|
377
|
-
'picker.
|
|
378
|
-
'picker.
|
|
379
|
-
'picker.
|
|
380
|
-
'picker.
|
|
381
|
-
'picker.
|
|
382
|
-
'picker.
|
|
383
|
-
'picker.
|
|
384
|
-
'picker.
|
|
385
|
-
'picker.
|
|
386
|
-
'picker.
|
|
387
|
-
'picker.
|
|
388
|
-
'picker.
|
|
389
|
-
'
|
|
390
|
-
'
|
|
391
|
-
'explorer.
|
|
392
|
-
'explorer.
|
|
393
|
-
'explorer.
|
|
394
|
-
'explorer.
|
|
395
|
-
'explorer.
|
|
396
|
-
'explorer.
|
|
397
|
-
'explorer.
|
|
398
|
-
'explorer.
|
|
399
|
-
'explorer.
|
|
400
|
-
'explorer.
|
|
401
|
-
'explorer.
|
|
402
|
-
'explorer.
|
|
403
|
-
'explorer.
|
|
404
|
-
'explorer.
|
|
405
|
-
'explorer.
|
|
406
|
-
'explorer.
|
|
407
|
-
'explorer.
|
|
408
|
-
'explorer.
|
|
409
|
-
'explorer.
|
|
410
|
-
'explorer.
|
|
411
|
-
'explorer.
|
|
412
|
-
'explorer.
|
|
413
|
-
'explorer.
|
|
414
|
-
'
|
|
415
|
-
'
|
|
416
|
-
'file.
|
|
417
|
-
'file.
|
|
418
|
-
'file.
|
|
419
|
-
'file.
|
|
420
|
-
'file.
|
|
421
|
-
'file.
|
|
422
|
-
'file.
|
|
423
|
-
'file.
|
|
424
|
-
'file.
|
|
425
|
-
'file.
|
|
426
|
-
'file.
|
|
427
|
-
'file.
|
|
428
|
-
'file.
|
|
429
|
-
'file.
|
|
430
|
-
'file.
|
|
431
|
-
'
|
|
432
|
-
'
|
|
433
|
-
'common.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
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
|
-
const
|
|
559
|
-
const
|
|
560
|
-
const
|
|
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
|
-
ta
|
|
589
|
-
ta.
|
|
590
|
-
|
|
591
|
-
ta.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
ta.
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return Promise.resolve(
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const [
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const [
|
|
615
|
-
const [
|
|
616
|
-
const [
|
|
617
|
-
const [
|
|
618
|
-
const [
|
|
619
|
-
const [
|
|
620
|
-
const [
|
|
621
|
-
const [
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const [
|
|
652
|
-
const [
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (r.
|
|
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
|
-
const
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
setSshEntries(
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
React.createElement('
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
React.createElement('
|
|
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
|
-
React.createElement('
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
React.createElement('
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
React.createElement('
|
|
826
|
-
|
|
827
|
-
React.createElement('
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
React.createElement('
|
|
837
|
-
),
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
React.createElement('button', { style: { ...buttonS,
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
React.createElement('div', { style: {
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
React.createElement('
|
|
863
|
-
|
|
864
|
-
React.createElement('
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
),
|
|
874
|
-
|
|
875
|
-
React.createElement('div', { style: boxS },
|
|
876
|
-
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.
|
|
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
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
crumbs.push(React.createElement('span', { key:
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const
|
|
972
|
-
const [
|
|
973
|
-
const [
|
|
974
|
-
const [
|
|
975
|
-
const [
|
|
976
|
-
const [
|
|
977
|
-
|
|
978
|
-
const [
|
|
979
|
-
const [
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
React.
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
const
|
|
990
|
-
React.useEffect(() => {
|
|
991
|
-
if (!
|
|
992
|
-
const onDown = (ev) => { if (
|
|
993
|
-
document.addEventListener('mousedown', onDown)
|
|
994
|
-
return () => document.removeEventListener('mousedown', onDown)
|
|
995
|
-
}, [
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
return
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const
|
|
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
|
-
const
|
|
1065
|
-
setLoading(true); setErr('')
|
|
1066
|
-
api('
|
|
1067
|
-
.then((r) => {
|
|
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
|
-
setPath('')
|
|
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
|
-
const
|
|
1143
|
-
if (!
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
),
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
React.createElement('
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
:
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
(
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
:
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
//
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
.
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
})
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
const [
|
|
1493
|
-
const [
|
|
1494
|
-
|
|
1495
|
-
const
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
(
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
:
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
.
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
const
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
disposers.push(bs.registerTab({
|
|
1914
|
-
id:
|
|
1915
|
-
title: () => tr('explorer.tabTitle'),
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
)
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
})
|
|
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 (/dsh-remote/local-pick)
|
|
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
|
+
// ── i18n (issue #14) ────────────────────────────────────────────────────
|
|
32
|
+
// Dictionaries are keyed by stable ids, zh + en share the same key set.
|
|
33
|
+
// Lookup order: active locale → en fallback → the key itself (so a missing
|
|
34
|
+
// translation stays visible instead of blanking the UI).
|
|
35
|
+
const NS = 'dsh-remote'
|
|
36
|
+
const L = {
|
|
37
|
+
zh: {
|
|
38
|
+
'settings.title': '远程工作区(dsh-remote)',
|
|
39
|
+
'settings.intro': '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。',
|
|
40
|
+
'settings.machines': '已配置的机器',
|
|
41
|
+
'settings.noMachines': '还没有机器。在下方添加。',
|
|
42
|
+
'settings.current': '当前',
|
|
43
|
+
'settings.edit': '编辑',
|
|
44
|
+
'settings.delete': '删除',
|
|
45
|
+
'settings.setCurrent': '设为当前',
|
|
46
|
+
'settings.currentMachine': '当前远程机:{name}',
|
|
47
|
+
'settings.clearCurrent': '取消设为当前(active remote = none)',
|
|
48
|
+
'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
|
|
49
|
+
'settings.addMachine': '添加机器',
|
|
50
|
+
'settings.editMachine': '编辑机器',
|
|
51
|
+
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
52
|
+
'settings.sshCollapse': '收起',
|
|
53
|
+
'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
|
|
54
|
+
'settings.name': '名称',
|
|
55
|
+
'settings.host': '主机',
|
|
56
|
+
'settings.port': '端口',
|
|
57
|
+
'settings.user': '用户',
|
|
58
|
+
'settings.password': '密码',
|
|
59
|
+
'settings.advCollapse': '▲ 收起高级配置',
|
|
60
|
+
'settings.advExpand': '▼ 高级配置(私钥 / 跳板机 / agent 等)',
|
|
61
|
+
'settings.privateKey': '私钥路径',
|
|
62
|
+
'settings.passphrase': 'Passphrase',
|
|
63
|
+
'settings.workspace': '默认工作区',
|
|
64
|
+
'settings.hostKeyMode': 'HostKey 模式',
|
|
65
|
+
'settings.hkDefault': '(默认 accept-new)',
|
|
66
|
+
'settings.hkAccept': 'accept-new(信任首次,之后校验)',
|
|
67
|
+
'settings.hkVerify': 'verify(严格:拒绝陌生主机)',
|
|
68
|
+
'settings.hkOff': 'off(不校验,不推荐)',
|
|
69
|
+
'settings.agent': 'SSH agent(SSH_AUTH_SOCK)',
|
|
70
|
+
'settings.keyboardInteractive': 'keyboard-interactive(OTP/动态码)',
|
|
71
|
+
'settings.encryptPassword': '加密保存密码(系统钥匙串)',
|
|
72
|
+
'settings.proxyHost': '跳板机',
|
|
73
|
+
'settings.proxyPortUser': '端口/用户',
|
|
74
|
+
'settings.proxyKey': '跳板私钥',
|
|
75
|
+
'settings.cancelEdit': '取消编辑',
|
|
76
|
+
'settings.clear': '清空',
|
|
77
|
+
'settings.testing': '连接中…',
|
|
78
|
+
'settings.testOk': '✓ 连接成功',
|
|
79
|
+
'settings.testConn': '测试连接',
|
|
80
|
+
'settings.saving': '保存中…',
|
|
81
|
+
'settings.save': '保存',
|
|
82
|
+
'settings.recentWorkspaces': '当前机器最近工作区(一键切换)',
|
|
83
|
+
'settings.switch': '切换',
|
|
84
|
+
'settings.forwards': '端口转发(SSH 隧道)',
|
|
85
|
+
'settings.fwdStop': '停止',
|
|
86
|
+
'settings.fwdStart': '启动',
|
|
87
|
+
'settings.fwdRemove': '删除',
|
|
88
|
+
'settings.fwdDirLocal': '本地→远端',
|
|
89
|
+
'settings.fwdDirReverse': '远端→本地',
|
|
90
|
+
'settings.fwdListen': '监听端口',
|
|
91
|
+
'settings.fwdTargetHost': '目标主机',
|
|
92
|
+
'settings.fwdTargetPort': '目标端口',
|
|
93
|
+
'settings.fwdAuto': '自动重连',
|
|
94
|
+
'settings.fwdAdd': '添加转发',
|
|
95
|
+
'settings.audit': '最近执行的远程命令(审计日志)',
|
|
96
|
+
'settings.auditEmpty': '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)',
|
|
97
|
+
'settings.update': '更新',
|
|
98
|
+
'settings.updateChecking': '版本信息加载中…',
|
|
99
|
+
'settings.checking': '检查中…',
|
|
100
|
+
'settings.checkUpdate': '检查更新',
|
|
101
|
+
'settings.updateNow': '立即更新',
|
|
102
|
+
'settings.updateMode': '更新模式:',
|
|
103
|
+
'settings.updateManual': '手动',
|
|
104
|
+
'settings.updateAuto': '自动',
|
|
105
|
+
'settings.updateOff': '关闭',
|
|
106
|
+
'settings.like': '觉得好用?',
|
|
107
|
+
'settings.star': '⭐ 去 GitHub 点个 Star',
|
|
108
|
+
'settings.feedback': '💬 反馈建议 / 提 issue',
|
|
109
|
+
'settings.deleted': '已删除',
|
|
110
|
+
'settings.saved': '已保存更新',
|
|
111
|
+
'settings.added': '已添加 — 可设为当前',
|
|
112
|
+
'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
|
|
113
|
+
'settings.switched': '已切换为当前远程机',
|
|
114
|
+
'settings.cleared': '已取消当前远程机 — 本地会话不再自动使用远程上下文',
|
|
115
|
+
'settings.filledSsh': '已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)',
|
|
116
|
+
'settings.workspaceSwitched': '已切换到工作区 {path}',
|
|
117
|
+
'settings.connOk': '连接成功',
|
|
118
|
+
'settings.connOkWith': '连接成功({user}@{host},{latency}ms){shell}',
|
|
119
|
+
'settings.connFail': '连接失败',
|
|
120
|
+
'settings.shellWin': '(Windows · Git Bash)',
|
|
121
|
+
'settings.shellWinNoGit': '(Windows · 未找到 Git Bash)',
|
|
122
|
+
'settings.shellPosix': '(POSIX)',
|
|
123
|
+
'settings.updateFail': '检查更新失败',
|
|
124
|
+
'settings.updateAvailable': '发现新版本 v{version}',
|
|
125
|
+
'settings.updateLatest': '已是最新版本(v{version})',
|
|
126
|
+
'settings.updateConfirm': '将更新 dsh-remote 到 v{version},替换本机插件文件。更新后需重启 Harness 生效。继续?',
|
|
127
|
+
'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
|
|
128
|
+
'settings.updateFailed': '更新失败',
|
|
129
|
+
'settings.updateFailedMsg': '更新失败: {msg}',
|
|
130
|
+
'settings.registryUnreachable': '无法连接 npm registry',
|
|
131
|
+
'settings.updateModeSet': '更新模式已设为「{mode}」',
|
|
132
|
+
'settings.updateModeSaveFail': '模式保存失败: {msg}',
|
|
133
|
+
'settings.deleteConfirm': '确定删除这台机器?',
|
|
134
|
+
'settings.sshReadFail': '读取 ~/.ssh/config 失败: {msg}',
|
|
135
|
+
'settings.delFail': '删除失败: {msg}',
|
|
136
|
+
'settings.addFail': '保存失败: {msg}',
|
|
137
|
+
'settings.modeAuto': '自动',
|
|
138
|
+
'settings.modeOff': '关闭',
|
|
139
|
+
'settings.modeManual': '手动',
|
|
140
|
+
'picker.title': '选择工作目录',
|
|
141
|
+
'picker.close': '关闭 ✕',
|
|
142
|
+
'picker.tabLocal': '本机',
|
|
143
|
+
'picker.tabRemote': '远程',
|
|
144
|
+
'picker.localIntro': '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。',
|
|
145
|
+
'picker.localPlaceholder': '本机目录,如 C:\\Users\\you\\project',
|
|
146
|
+
'picker.useLocalPath': '选用此本地路径',
|
|
147
|
+
'picker.openSysPicker': '打开系统文件夹选择器',
|
|
148
|
+
'picker.opening': '打开中…',
|
|
149
|
+
'picker.remoteMachine': '远程机器:',
|
|
150
|
+
'picker.chooseMachine': '— 选择机器 —',
|
|
151
|
+
'picker.noMachines': '还没有机器,请先在设置里添加',
|
|
152
|
+
'picker.recent': '最近:',
|
|
153
|
+
'picker.pathPlaceholder': '输入远程路径(自动补全)',
|
|
154
|
+
'picker.pathPlaceholderWin': '输入远程路径,如 C:\\Users\\…(自动补全)',
|
|
155
|
+
'picker.chooseMachineFirst': '先选远程机器',
|
|
156
|
+
'picker.browse': '浏览…',
|
|
157
|
+
'picker.home': '~ 主目录',
|
|
158
|
+
'picker.setRemoteWs': '设为远程工作区',
|
|
159
|
+
'picker.mirroring': '镜像中…',
|
|
160
|
+
'picker.cancel': '取消',
|
|
161
|
+
'picker.selectDir': '选择工作目录',
|
|
162
|
+
'picker.selected': '所选: {path}',
|
|
163
|
+
'picker.driveRoot': '(驱动器根)',
|
|
164
|
+
'picker.emptyDir': '(空目录)',
|
|
165
|
+
'picker.loading': '加载中…',
|
|
166
|
+
'picker.readingRoot': '正在读取根目录…',
|
|
167
|
+
'picker.back': '回上一级 ▴',
|
|
168
|
+
'picker.closeBtn': '关闭 ✕',
|
|
169
|
+
'picker.newDir': '新建目录',
|
|
170
|
+
'picker.newFolder': '新建文件夹',
|
|
171
|
+
'picker.create': '新建',
|
|
172
|
+
'picker.chooseThis': '选用此路径',
|
|
173
|
+
'picker.enterDir': '进入 {name}',
|
|
174
|
+
'picker.enterDrive': '进入驱动器 {name}',
|
|
175
|
+
'picker.fileAt': '文件: {name}',
|
|
176
|
+
'picker.cancelled': '已取消选择',
|
|
177
|
+
'picker.noSysPicker': '无法打开系统文件夹选择器,可直接在输入框填本地路径',
|
|
178
|
+
'picker.timeout': '目录选择已超时,请重试或直接在输入框填本地路径',
|
|
179
|
+
'picker.pickerBinNotFound': '未找到目录选择器程序',
|
|
180
|
+
'picker.noSysPickerHost': '当前系统不支持自动打开目录选择器,请在输入框直接填本地路径',
|
|
181
|
+
'picker.noHomeWin': 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径',
|
|
182
|
+
'picker.typePath': ' — 可直接在输入框填本地路径',
|
|
183
|
+
'picker.noHome': '无法解析主目录',
|
|
184
|
+
'picker.newDirName': '新建目录名(在 {path} 下)',
|
|
185
|
+
'picker.noSubdir': '(没有子目录 — 可直接选用当前目录)',
|
|
186
|
+
'picker.truncated': '(条目过多已截断)',
|
|
187
|
+
'picker.thisPc': '此电脑',
|
|
188
|
+
'picker.chooseDrive': ' 选择驱动器',
|
|
189
|
+
'picker.homeBtn': '🏠',
|
|
190
|
+
'picker.goHome': '回家目录',
|
|
191
|
+
'explorer.refresh': '刷新',
|
|
192
|
+
'explorer.newDir': '新建目录',
|
|
193
|
+
'explorer.more': '…',
|
|
194
|
+
'explorer.remoteWs': '远程工作区: {path}{host}',
|
|
195
|
+
'explorer.hostTag': ' ({user}@{host})',
|
|
196
|
+
'explorer.noRemoteWs': '未设置远程工作区',
|
|
197
|
+
'explorer.sessionLocal': '当前会话未使用远程工作区',
|
|
198
|
+
'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
|
|
199
|
+
'explorer.loading': '加载中…',
|
|
200
|
+
'explorer.dirMissing': '目录不存在或已被删除',
|
|
201
|
+
'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
|
|
202
|
+
'explorer.softError': '⚠ {msg}(显示缓存,可点 ↻ 刷新)',
|
|
203
|
+
'explorer.busy': '处理中… ',
|
|
204
|
+
'explorer.menuExpand': '📂 展开/收起',
|
|
205
|
+
'explorer.menuOpen': '📄 打开文件',
|
|
206
|
+
'explorer.menuDownload': '⬇ 下载到本地镜像',
|
|
207
|
+
'explorer.menuCopyRel': '📋 复制相对地址',
|
|
208
|
+
'explorer.menuCopyAbs': '📋 复制绝对地址',
|
|
209
|
+
'explorer.menuRename': '✏ 重命名',
|
|
210
|
+
'explorer.menuDelete': '🗑 删除',
|
|
211
|
+
'explorer.menuDeleteDir': '🗑 删除(含子目录)',
|
|
212
|
+
'explorer.renamePrompt': '重命名为(新名字)',
|
|
213
|
+
'explorer.deleteConfirm': '确认删除 {path}?',
|
|
214
|
+
'explorer.copied': '已复制',
|
|
215
|
+
'explorer.tabTitle': '远程文件',
|
|
216
|
+
'file.readonly': '只读',
|
|
217
|
+
'file.editing': '编辑中',
|
|
218
|
+
'file.saveToRemote': '保存到远程',
|
|
219
|
+
'file.saving': '保存中…',
|
|
220
|
+
'file.cancel': '取消',
|
|
221
|
+
'file.edit': '编辑',
|
|
222
|
+
'file.download': '下载',
|
|
223
|
+
'file.loading': '加载中…',
|
|
224
|
+
'file.binary': '二进制文件 ({size}) — 请用「下载」或 rw_download / 本地镜像查看。',
|
|
225
|
+
'file.unknownSize': '未知大小',
|
|
226
|
+
'file.empty': '(空文件)',
|
|
227
|
+
'file.truncated': '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)',
|
|
228
|
+
'file.conflict': '远端文件已变化,请重新读取',
|
|
229
|
+
'file.downloaded': '已下载到本地镜像{local}',
|
|
230
|
+
'file.downloadedAt': ': {path}',
|
|
231
|
+
'file.readFail': '远程读取失败: {msg}',
|
|
232
|
+
'file.listFail': '远程目录读取失败: {msg}',
|
|
233
|
+
'common.refresh': '刷新',
|
|
234
|
+
'common.close': '关闭',
|
|
235
|
+
'common.confirm': '确认',
|
|
236
|
+
},
|
|
237
|
+
en: {
|
|
238
|
+
'settings.title': 'Remote workspace (dsh-remote)',
|
|
239
|
+
'settings.intro': 'Manage multiple SSH machines (password / private key / agent / proxy jump / keychain). Pick a path when creating/selecting a workspace: "Local" opens the system folder dialog; "Remote" picks a machine and a directory on it.',
|
|
240
|
+
'settings.machines': 'Configured machines',
|
|
241
|
+
'settings.noMachines': 'No machines yet. Add one below.',
|
|
242
|
+
'settings.current': 'current',
|
|
243
|
+
'settings.edit': 'Edit',
|
|
244
|
+
'settings.delete': 'Delete',
|
|
245
|
+
'settings.setCurrent': 'Set as current',
|
|
246
|
+
'settings.currentMachine': 'Current remote: {name}',
|
|
247
|
+
'settings.clearCurrent': 'Clear current (active remote = none)',
|
|
248
|
+
'settings.noActive': 'No active remote machine: saved machines are standby connections and never enter a session context automatically (local sessions are unaffected).',
|
|
249
|
+
'settings.addMachine': 'Add machine',
|
|
250
|
+
'settings.editMachine': 'Edit machine',
|
|
251
|
+
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
252
|
+
'settings.sshCollapse': 'Collapse',
|
|
253
|
+
'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
|
|
254
|
+
'settings.name': 'Name',
|
|
255
|
+
'settings.host': 'Host',
|
|
256
|
+
'settings.port': 'Port',
|
|
257
|
+
'settings.user': 'User',
|
|
258
|
+
'settings.password': 'Password',
|
|
259
|
+
'settings.advCollapse': '▲ Collapse advanced config',
|
|
260
|
+
'settings.advExpand': '▼ Advanced (private key / proxy jump / agent, etc.)',
|
|
261
|
+
'settings.privateKey': 'Private key path',
|
|
262
|
+
'settings.passphrase': 'Passphrase',
|
|
263
|
+
'settings.workspace': 'Default workspace',
|
|
264
|
+
'settings.hostKeyMode': 'HostKey mode',
|
|
265
|
+
'settings.hkDefault': '(default accept-new)',
|
|
266
|
+
'settings.hkAccept': 'accept-new (trust first, verify later)',
|
|
267
|
+
'settings.hkVerify': 'verify (strict: reject unknown hosts)',
|
|
268
|
+
'settings.hkOff': 'off (no verification, not recommended)',
|
|
269
|
+
'settings.agent': 'SSH agent (SSH_AUTH_SOCK)',
|
|
270
|
+
'settings.keyboardInteractive': 'keyboard-interactive (OTP / dynamic code)',
|
|
271
|
+
'settings.encryptPassword': 'Encrypt saved password (system keychain)',
|
|
272
|
+
'settings.proxyHost': 'Proxy jump host',
|
|
273
|
+
'settings.proxyPortUser': 'Port / user',
|
|
274
|
+
'settings.proxyKey': 'Proxy jump private key',
|
|
275
|
+
'settings.cancelEdit': 'Cancel edit',
|
|
276
|
+
'settings.clear': 'Clear',
|
|
277
|
+
'settings.testing': 'Connecting…',
|
|
278
|
+
'settings.testOk': '✓ Connected',
|
|
279
|
+
'settings.testConn': 'Test connection',
|
|
280
|
+
'settings.saving': 'Saving…',
|
|
281
|
+
'settings.save': 'Save',
|
|
282
|
+
'settings.recentWorkspaces': 'Recent workspaces of the current machine (one-click switch)',
|
|
283
|
+
'settings.switch': 'Switch',
|
|
284
|
+
'settings.forwards': 'Port forwarding (SSH tunnels)',
|
|
285
|
+
'settings.fwdStop': 'Stop',
|
|
286
|
+
'settings.fwdStart': 'Start',
|
|
287
|
+
'settings.fwdRemove': 'Remove',
|
|
288
|
+
'settings.fwdDirLocal': 'Local → remote',
|
|
289
|
+
'settings.fwdDirReverse': 'Remote → local',
|
|
290
|
+
'settings.fwdListen': 'Listen port',
|
|
291
|
+
'settings.fwdTargetHost': 'Target host',
|
|
292
|
+
'settings.fwdTargetPort': 'Target port',
|
|
293
|
+
'settings.fwdAuto': 'Auto reconnect',
|
|
294
|
+
'settings.fwdAdd': 'Add forward',
|
|
295
|
+
'settings.audit': 'Recent remote commands (audit log)',
|
|
296
|
+
'settings.auditEmpty': 'No records yet (commands run remotely are logged to $DSH_HOME/remote-workspaces/audit.log)',
|
|
297
|
+
'settings.update': 'Update',
|
|
298
|
+
'settings.updateChecking': 'Loading version info…',
|
|
299
|
+
'settings.checking': 'Checking…',
|
|
300
|
+
'settings.checkUpdate': 'Check for updates',
|
|
301
|
+
'settings.updateNow': 'Update now',
|
|
302
|
+
'settings.updateMode': 'Update mode:',
|
|
303
|
+
'settings.updateManual': 'Manual',
|
|
304
|
+
'settings.updateAuto': 'Auto',
|
|
305
|
+
'settings.updateOff': 'Off',
|
|
306
|
+
'settings.like': 'Like it?',
|
|
307
|
+
'settings.star': '⭐ Star on GitHub',
|
|
308
|
+
'settings.feedback': '💬 Feedback / report issue',
|
|
309
|
+
'settings.deleted': 'Deleted',
|
|
310
|
+
'settings.saved': 'Saved',
|
|
311
|
+
'settings.added': 'Added — you can set it as current',
|
|
312
|
+
'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
|
|
313
|
+
'settings.switched': 'Switched to current remote machine',
|
|
314
|
+
'settings.cleared': 'Cleared current remote — local sessions no longer use remote context automatically',
|
|
315
|
+
'settings.filledSsh': 'Filled from ~/.ssh/config (only references the private key path, never reads its content)',
|
|
316
|
+
'settings.workspaceSwitched': 'Switched to workspace {path}',
|
|
317
|
+
'settings.connOk': 'Connection successful',
|
|
318
|
+
'settings.connOkWith': 'Connection successful ({user}@{host}, {latency}ms){shell}',
|
|
319
|
+
'settings.connFail': 'Connection failed',
|
|
320
|
+
'settings.shellWin': ' (Windows · Git Bash)',
|
|
321
|
+
'settings.shellWinNoGit': ' (Windows · no Git Bash found)',
|
|
322
|
+
'settings.shellPosix': ' (POSIX)',
|
|
323
|
+
'settings.updateFail': 'Failed to check for updates',
|
|
324
|
+
'settings.updateAvailable': 'New version found: v{version}',
|
|
325
|
+
'settings.updateLatest': 'Already up to date (v{version})',
|
|
326
|
+
'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and requires a Harness restart to take effect. Continue?',
|
|
327
|
+
'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
|
|
328
|
+
'settings.updateFailed': 'Update failed',
|
|
329
|
+
'settings.updateFailedMsg': 'Update failed: {msg}',
|
|
330
|
+
'settings.registryUnreachable': 'Cannot reach the npm registry',
|
|
331
|
+
'settings.updateModeSet': 'Update mode set to "{mode}"',
|
|
332
|
+
'settings.updateModeSaveFail': 'Failed to save mode: {msg}',
|
|
333
|
+
'settings.deleteConfirm': 'Delete this machine?',
|
|
334
|
+
'settings.sshReadFail': 'Failed to read ~/.ssh/config: {msg}',
|
|
335
|
+
'settings.delFail': 'Delete failed: {msg}',
|
|
336
|
+
'settings.addFail': 'Save failed: {msg}',
|
|
337
|
+
'settings.modeAuto': 'Auto',
|
|
338
|
+
'settings.modeOff': 'Off',
|
|
339
|
+
'settings.modeManual': 'Manual',
|
|
340
|
+
'picker.title': 'Select working directory',
|
|
341
|
+
'picker.close': 'Close ✕',
|
|
342
|
+
'picker.tabLocal': 'Local',
|
|
343
|
+
'picker.tabRemote': 'Remote',
|
|
344
|
+
'picker.localIntro': 'Opens the system folder dialog; when unavailable, uses the built-in directory browser. You can also type a local path directly.',
|
|
345
|
+
'picker.localPlaceholder': 'Local directory, e.g. C:\\Users\\you\\project',
|
|
346
|
+
'picker.useLocalPath': 'Use this local path',
|
|
347
|
+
'picker.openSysPicker': 'Open system folder picker',
|
|
348
|
+
'picker.opening': 'Opening…',
|
|
349
|
+
'picker.remoteMachine': 'Remote machine:',
|
|
350
|
+
'picker.chooseMachine': '— choose a machine —',
|
|
351
|
+
'picker.noMachines': 'No machines yet — add one in Settings first',
|
|
352
|
+
'picker.recent': 'Recent:',
|
|
353
|
+
'picker.pathPlaceholder': 'Enter remote path (auto-complete)',
|
|
354
|
+
'picker.pathPlaceholderWin': 'Enter remote path, e.g. C:\\Users\\… (auto-complete)',
|
|
355
|
+
'picker.chooseMachineFirst': 'Choose a remote machine first',
|
|
356
|
+
'picker.browse': 'Browse…',
|
|
357
|
+
'picker.home': '~ Home',
|
|
358
|
+
'picker.setRemoteWs': 'Set as remote workspace',
|
|
359
|
+
'picker.mirroring': 'Mirroring…',
|
|
360
|
+
'picker.cancel': 'Cancel',
|
|
361
|
+
'picker.selectDir': 'Select working directory',
|
|
362
|
+
'picker.selected': 'Selected: {path}',
|
|
363
|
+
'picker.driveRoot': ' (drive root)',
|
|
364
|
+
'picker.emptyDir': ' (empty directory)',
|
|
365
|
+
'picker.loading': 'Loading…',
|
|
366
|
+
'picker.readingRoot': 'Reading root directory…',
|
|
367
|
+
'picker.back': 'Back ▴',
|
|
368
|
+
'picker.closeBtn': 'Close ✕',
|
|
369
|
+
'picker.newDir': 'New directory',
|
|
370
|
+
'picker.newFolder': 'New folder',
|
|
371
|
+
'picker.create': 'Create',
|
|
372
|
+
'picker.chooseThis': 'Use this path',
|
|
373
|
+
'picker.enterDir': 'Enter {name}',
|
|
374
|
+
'picker.enterDrive': 'Enter drive {name}',
|
|
375
|
+
'picker.fileAt': 'File: {name}',
|
|
376
|
+
'picker.cancelled': 'Selection cancelled',
|
|
377
|
+
'picker.noSysPicker': 'Cannot open the system folder picker — you can type a local path in the input box',
|
|
378
|
+
'picker.timeout': 'Directory selection timed out — retry or type a local path directly',
|
|
379
|
+
'picker.pickerBinNotFound': 'Directory picker program not found',
|
|
380
|
+
'picker.noSysPickerHost': 'This system cannot auto-open a directory picker — type a local path in the input box',
|
|
381
|
+
'picker.noHomeWin': 'Windows remotes do not support ~ resolution — enter an absolute path',
|
|
382
|
+
'picker.typePath': ' — type a local path in the input box instead',
|
|
383
|
+
'picker.noHome': 'Cannot resolve home directory',
|
|
384
|
+
'picker.newDirName': 'New directory name (under {path})',
|
|
385
|
+
'picker.noSubdir': ' (no subdirectories — you can use the current directory)',
|
|
386
|
+
'picker.truncated': ' (too many entries, truncated)',
|
|
387
|
+
'picker.thisPc': 'This PC',
|
|
388
|
+
'picker.chooseDrive': ' choose a drive',
|
|
389
|
+
'picker.homeBtn': '🏠',
|
|
390
|
+
'picker.goHome': 'Go to home',
|
|
391
|
+
'explorer.refresh': 'Refresh',
|
|
392
|
+
'explorer.newDir': 'New directory',
|
|
393
|
+
'explorer.more': '…',
|
|
394
|
+
'explorer.remoteWs': 'Remote workspace: {path}{host}',
|
|
395
|
+
'explorer.hostTag': ' ({user}@{host})',
|
|
396
|
+
'explorer.noRemoteWs': 'No remote workspace set',
|
|
397
|
+
'explorer.sessionLocal': 'This session does not use a remote workspace',
|
|
398
|
+
'explorer.localSession': 'This session is a local workspace and does not use remote files.\n\nTo access remote, set a machine as "current" in Settings, then pick a remote workspace with the "…" button above.',
|
|
399
|
+
'explorer.loading': 'Loading…',
|
|
400
|
+
'explorer.dirMissing': 'Directory does not exist or was deleted',
|
|
401
|
+
'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
|
|
402
|
+
'explorer.softError': '⚠ {msg} (showing cache, click ↻ to refresh)',
|
|
403
|
+
'explorer.busy': 'Processing… ',
|
|
404
|
+
'explorer.menuExpand': '📂 Expand/collapse',
|
|
405
|
+
'explorer.menuOpen': '📄 Open file',
|
|
406
|
+
'explorer.menuDownload': '⬇ Download to local mirror',
|
|
407
|
+
'explorer.menuCopyRel': '📋 Copy relative path',
|
|
408
|
+
'explorer.menuCopyAbs': '📋 Copy absolute path',
|
|
409
|
+
'explorer.menuRename': '✏ Rename',
|
|
410
|
+
'explorer.menuDelete': '🗑 Delete',
|
|
411
|
+
'explorer.menuDeleteDir': '🗑 Delete (with contents)',
|
|
412
|
+
'explorer.renamePrompt': 'Rename to (new name)',
|
|
413
|
+
'explorer.deleteConfirm': 'Delete {path}?',
|
|
414
|
+
'explorer.copied': 'Copied',
|
|
415
|
+
'explorer.tabTitle': 'Remote Files',
|
|
416
|
+
'file.readonly': 'read-only',
|
|
417
|
+
'file.editing': 'editing',
|
|
418
|
+
'file.saveToRemote': 'Save to remote',
|
|
419
|
+
'file.saving': 'Saving…',
|
|
420
|
+
'file.cancel': 'Cancel',
|
|
421
|
+
'file.edit': 'Edit',
|
|
422
|
+
'file.download': 'Download',
|
|
423
|
+
'file.loading': 'Loading…',
|
|
424
|
+
'file.binary': 'Binary file ({size}) — use "Download", rw_download or the local mirror to view.',
|
|
425
|
+
'file.unknownSize': 'unknown size',
|
|
426
|
+
'file.empty': ' (empty file)',
|
|
427
|
+
'file.truncated': '… content truncated (read-only remote view; use rw_read_file / Download for the full file)',
|
|
428
|
+
'file.conflict': 'The remote file changed, please re-read it',
|
|
429
|
+
'file.downloaded': 'Downloaded to local mirror{local}',
|
|
430
|
+
'file.downloadedAt': ': {path}',
|
|
431
|
+
'file.readFail': 'Failed to read remote: {msg}',
|
|
432
|
+
'file.listFail': 'Failed to list remote directory: {msg}',
|
|
433
|
+
'common.refresh': 'Refresh',
|
|
434
|
+
'common.close': 'Close',
|
|
435
|
+
'common.confirm': 'Confirm',
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Active dictionary: resolved from the DSH locale service when available
|
|
440
|
+
// (its active locale + en fallback), else from the browser language, else en.
|
|
441
|
+
let ACTIVE_LANG = 'en'
|
|
442
|
+
try {
|
|
443
|
+
const nav = (typeof navigator !== 'undefined' && navigator.languages) ? navigator.languages : (typeof navigator !== 'undefined' ? [navigator.language] : [])
|
|
444
|
+
for (const tag of nav) {
|
|
445
|
+
const primary = String(tag || '').toLowerCase().split('-')[0]
|
|
446
|
+
if (primary === 'zh') { ACTIVE_LANG = 'zh'; break }
|
|
447
|
+
if (primary === 'en') { ACTIVE_LANG = 'en'; break }
|
|
448
|
+
}
|
|
449
|
+
} catch { /* keep en */ }
|
|
450
|
+
|
|
451
|
+
// The DSH locale service binds a translate thunk; we call it first and fall
|
|
452
|
+
// back to our own dictionary so missing keys never blank the UI.
|
|
453
|
+
function makeTr() {
|
|
454
|
+
const dict = L[ACTIVE_LANG] || L.en
|
|
455
|
+
return (key, params) => {
|
|
456
|
+
let text = dict[key]
|
|
457
|
+
if (text === undefined && ACTIVE_LANG !== 'en') text = L.en[key]
|
|
458
|
+
if (text === undefined) text = key
|
|
459
|
+
if (params) text = String(text).replace(/\{(\w+)\}/g, (m, p) => (p in params ? String(params[p]) : m))
|
|
460
|
+
return text
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
let tr = makeTr()
|
|
464
|
+
// Called from apply() once the real locale service is reachable; registers
|
|
465
|
+
// both dictionaries and rebinds tr so live locale switches take effect.
|
|
466
|
+
function wireLocale(ctx) {
|
|
467
|
+
try {
|
|
468
|
+
const locale = ctx && ctx.get && ctx.get('locale')
|
|
469
|
+
if (!locale || typeof locale.register !== 'function') return
|
|
470
|
+
const dispose = locale.register(NS, { zh: L.zh, en: L.en })
|
|
471
|
+
const bound = locale.bind(NS)
|
|
472
|
+
tr = (key, params) => {
|
|
473
|
+
let text = bound(key, params)
|
|
474
|
+
if (text === key) {
|
|
475
|
+
const d = L[ACTIVE_LANG] || L.en
|
|
476
|
+
text = d[key] !== undefined ? d[key] : L.en[key]
|
|
477
|
+
if (text === undefined) text = key
|
|
478
|
+
if (params) text = String(text).replace(/\{(\w+)\}/g, (m, p) => (p in params ? String(params[p]) : m))
|
|
479
|
+
}
|
|
480
|
+
return text
|
|
481
|
+
}
|
|
482
|
+
if (ctx.effect) ctx.effect(() => dispose, 'dsh-remote.locale')
|
|
483
|
+
} catch (e) { /* keep local dict fallback */ }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Known host-side error messages (lib/index.js) are Chinese. Map them to
|
|
487
|
+
// dictionary keys so non-zh locales see localized errors instead of raw
|
|
488
|
+
// zh text. Unknown / server-generated messages pass through untouched.
|
|
489
|
+
const HOST_ERROR_KEYS = {
|
|
490
|
+
'目录选择已超时,请重试或直接在输入框填本地路径': 'picker.timeout',
|
|
491
|
+
'无法打开系统文件夹选择器': 'picker.noSysPicker',
|
|
492
|
+
'当前系统不支持自动打开目录选择器,请在输入框直接填本地路径': 'picker.noSysPickerHost',
|
|
493
|
+
'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径': 'picker.noHomeWin',
|
|
494
|
+
'无法连接 npm registry': 'settings.registryUnreachable',
|
|
495
|
+
}
|
|
496
|
+
const HOST_ERROR_PREFIX = [
|
|
497
|
+
['未找到目录选择器程序', 'picker.pickerBinNotFound'],
|
|
498
|
+
]
|
|
499
|
+
const HOST_ERROR_SUFFIX = [
|
|
500
|
+
[' — 可直接在输入框填本地路径', 'picker.typePath'],
|
|
501
|
+
]
|
|
502
|
+
function trHostError(msg) {
|
|
503
|
+
if (!msg || ACTIVE_LANG === 'zh') return msg
|
|
504
|
+
if (HOST_ERROR_KEYS[msg]) return tr(HOST_ERROR_KEYS[msg])
|
|
505
|
+
for (const [prefix, key] of HOST_ERROR_PREFIX) {
|
|
506
|
+
if (msg.startsWith(prefix)) {
|
|
507
|
+
const en = L.en[key]
|
|
508
|
+
if (en !== undefined) return en + msg.slice(prefix.length)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const [suffix, key] of HOST_ERROR_SUFFIX) {
|
|
512
|
+
if (msg.endsWith(suffix)) {
|
|
513
|
+
const en = L.en[key]
|
|
514
|
+
if (en !== undefined) return msg.slice(0, -suffix.length) + en
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return msg
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function api(method, path, body) {
|
|
521
|
+
const opts = { method, headers: {} }
|
|
522
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
523
|
+
const res = await fetch(path, opts)
|
|
524
|
+
const data = await res.json().catch(() => ({}))
|
|
525
|
+
if (!res.ok) throw new Error(trHostError((data && (data.error || data.message))) || 'HTTP ' + res.status)
|
|
526
|
+
return data
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Like api() but returns {status, data} so callers can handle 409 etc.
|
|
530
|
+
async function apiRaw(method, path, body) {
|
|
531
|
+
const opts = { method, headers: {} }
|
|
532
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
533
|
+
const res = await fetch(path, opts)
|
|
534
|
+
const data = await res.json().catch(() => ({}))
|
|
535
|
+
if (data && typeof data.error === 'string') data.error = trHostError(data.error)
|
|
536
|
+
if (data && typeof data.message === 'string') data.message = trHostError(data.message)
|
|
537
|
+
return { status: res.status, data }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Theme via DSH design tokens so this follows the harness light/dark theme.
|
|
541
|
+
const v = (name, fb) => `var(${name}, ${fb})`
|
|
542
|
+
const T = {
|
|
543
|
+
bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
|
|
544
|
+
bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
|
|
545
|
+
border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
|
|
546
|
+
borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
|
|
547
|
+
danger: v('--dsw-static-red-500', '#e06c75'),
|
|
548
|
+
dangerText: v('--dsw-static-red-400', '#e06c75'),
|
|
549
|
+
ok: v('--dsw-static-green-500', '#4caf7d'),
|
|
550
|
+
warn: v('--dsw-static-yellow-500', '#e6c07b'),
|
|
551
|
+
radius: 8,
|
|
552
|
+
muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
|
|
553
|
+
label: v('--dsw-alias-label-primary', '#e4e4e7'),
|
|
554
|
+
primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
|
|
555
|
+
onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
|
|
556
|
+
hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
|
|
557
|
+
}
|
|
558
|
+
const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
|
|
559
|
+
const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
|
|
560
|
+
const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
|
|
561
|
+
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
562
|
+
const boxS = box
|
|
563
|
+
|
|
564
|
+
function humanSize(n) {
|
|
565
|
+
if (n == null) return ''
|
|
566
|
+
if (n < 1024) return n + ' B'
|
|
567
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
568
|
+
let i = -1
|
|
569
|
+
let v = n
|
|
570
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
|
571
|
+
return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
|
|
572
|
+
}
|
|
573
|
+
function fmtTime(ts) {
|
|
574
|
+
if (!ts) return ''
|
|
575
|
+
const d = new Date(ts * 1000)
|
|
576
|
+
const pad = (x) => String(x).padStart(2, '0')
|
|
577
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Write text to the clipboard. Prefers the async Clipboard API (works on
|
|
581
|
+
// http://127.0.0.1 secure contexts) and falls back to the legacy
|
|
582
|
+
// execCommand('copy') path, mirroring primitives' writeClipboard semantics.
|
|
583
|
+
function copyToClipboard(text) {
|
|
584
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
585
|
+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
const ta = document.createElement('textarea')
|
|
589
|
+
ta.value = String(text)
|
|
590
|
+
ta.style.position = 'fixed'
|
|
591
|
+
ta.style.opacity = '0'
|
|
592
|
+
document.body.appendChild(ta)
|
|
593
|
+
ta.focus()
|
|
594
|
+
ta.select()
|
|
595
|
+
const ok = document.execCommand('copy')
|
|
596
|
+
ta.remove()
|
|
597
|
+
return Promise.resolve(ok)
|
|
598
|
+
} catch (e) {
|
|
599
|
+
return Promise.resolve(false)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let SESSIONS = null // optional sessions service, tracked by ctx.inject in apply()
|
|
604
|
+
|
|
605
|
+
// ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
|
|
606
|
+
function RemoteWorkspacePage() {
|
|
607
|
+
const [machines, setMachines] = React.useState([])
|
|
608
|
+
const [currentId, setCurrentId] = React.useState('')
|
|
609
|
+
const [form, setForm] = React.useState({
|
|
610
|
+
name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
|
|
611
|
+
passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
|
|
612
|
+
encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
|
|
613
|
+
})
|
|
614
|
+
const [busy, setBusy] = React.useState(false)
|
|
615
|
+
const [msg, setMsg] = React.useState('')
|
|
616
|
+
const [err, setErr] = React.useState('')
|
|
617
|
+
const [tst, setTst] = React.useState('') // idle | testing | ok | failing
|
|
618
|
+
const [forwards, setForwards] = React.useState([])
|
|
619
|
+
const [fwdForm, setFwdForm] = React.useState({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false })
|
|
620
|
+
const [auditLines, setAuditLines] = React.useState([])
|
|
621
|
+
const [sshEntries, setSshEntries] = React.useState(null)
|
|
622
|
+
const [workspaces, setWorkspaces] = React.useState([])
|
|
623
|
+
const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
|
|
624
|
+
|
|
625
|
+
const testConn = () => {
|
|
626
|
+
if (!form.host.trim() || tst === 'testing') return
|
|
627
|
+
setTst('testing'); setErr(''); setMsg('')
|
|
628
|
+
api('POST', '/dsh-remote/test-connect', {
|
|
629
|
+
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
630
|
+
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
631
|
+
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
632
|
+
})
|
|
633
|
+
.then((r) => {
|
|
634
|
+
if (r && r.ok) {
|
|
635
|
+
const shellTag = r.platform === 'windows' ? (r.gitBash ? tr('settings.shellWin') : tr('settings.shellWinNoGit')) : (r.platform === 'posix' ? tr('settings.shellPosix') : '')
|
|
636
|
+
setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk')); refresh()
|
|
637
|
+
}
|
|
638
|
+
else { setTst('failing'); setErr((r && r.error) || tr('settings.connFail')) }
|
|
639
|
+
})
|
|
640
|
+
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const refresh = () => api('GET', '/dsh-remote/machines').then((r) => {
|
|
644
|
+
setMachines(r.machines || []); setCurrentId(r.currentId || '')
|
|
645
|
+
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
646
|
+
setWorkspaces((cur && cur.recentWorkspaces) || [])
|
|
647
|
+
})
|
|
648
|
+
React.useEffect(() => { refresh() }, [])
|
|
649
|
+
|
|
650
|
+
// ── update state ────────────────────────────────────────────────────────
|
|
651
|
+
const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
|
|
652
|
+
const [updBusy, setUpdBusy] = React.useState(false)
|
|
653
|
+
const [updMsg, setUpdMsg] = React.useState('')
|
|
654
|
+
const [updMode, setUpdMode] = React.useState('manual')
|
|
655
|
+
const checkUpdate = (quiet) => {
|
|
656
|
+
if (updBusy) return
|
|
657
|
+
setUpdBusy(true); setUpdMsg('')
|
|
658
|
+
api('GET', '/dsh-remote/update-check')
|
|
659
|
+
.then((r) => {
|
|
660
|
+
if (!r || !r.ok) { setUpdMsg((r && r.error) || tr('settings.updateFail')); return }
|
|
661
|
+
setUpd(r)
|
|
662
|
+
if (r.updateMode) setUpdMode(r.updateMode)
|
|
663
|
+
if (!quiet) setUpdMsg(r.updateAvailable ? tr('settings.updateAvailable', { version: r.latest }) : tr('settings.updateLatest', { version: r.current }))
|
|
664
|
+
})
|
|
665
|
+
.catch((e) => setUpdMsg(String((e && e.message) || e)))
|
|
666
|
+
.finally(() => setUpdBusy(false))
|
|
667
|
+
}
|
|
668
|
+
const applyUpdateNow = () => {
|
|
669
|
+
if (!upd || !upd.latest) return
|
|
670
|
+
if (!window.confirm(tr('settings.updateConfirm', { version: upd.latest }))) return
|
|
671
|
+
setUpdBusy(true); setUpdMsg('')
|
|
672
|
+
api('POST', '/dsh-remote/update-apply', { version: upd.latest })
|
|
673
|
+
.then((r) => {
|
|
674
|
+
if (r && r.ok) setUpdMsg(tr('settings.updateApplied', { to: r.to, from: (r.from || '') }))
|
|
675
|
+
else setUpdMsg((r && r.error) || tr('settings.updateFailed'))
|
|
676
|
+
checkUpdate(true)
|
|
677
|
+
})
|
|
678
|
+
.catch((e) => setUpdMsg(tr('settings.updateFailedMsg', { msg: String((e && e.message) || e) })))
|
|
679
|
+
.finally(() => setUpdBusy(false))
|
|
680
|
+
}
|
|
681
|
+
const saveUpdateMode = (mode) => {
|
|
682
|
+
setUpdMode(mode); setUpdMsg(tr('settings.updateModeSet', { mode: mode === 'auto' ? tr('settings.modeAuto') : mode === 'off' ? tr('settings.modeOff') : tr('settings.modeManual') }))
|
|
683
|
+
api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg(tr('settings.updateModeSaveFail', { msg: String((e && e.message) || e) })))
|
|
684
|
+
}
|
|
685
|
+
React.useEffect(() => { checkUpdate(true) }, [])
|
|
686
|
+
|
|
687
|
+
const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
|
|
688
|
+
React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
|
|
689
|
+
|
|
690
|
+
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
691
|
+
const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
|
|
692
|
+
const startEdit = (m) => {
|
|
693
|
+
setForm({
|
|
694
|
+
name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root',
|
|
695
|
+
password: '', privateKeyPath: m.privateKeyPath || '', passphrase: '', workspace: m.workspace || '',
|
|
696
|
+
hostKeyMode: m.hostKeyMode || '', useAgent: !!m.useAgent, keyboardInteractive: !!m.keyboardInteractive,
|
|
697
|
+
encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
|
|
698
|
+
proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
|
|
699
|
+
proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
|
|
700
|
+
id: m.id,
|
|
701
|
+
})
|
|
702
|
+
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
|
|
703
|
+
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
704
|
+
setAdvOpen(true)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
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: '' })
|
|
708
|
+
const save = (action) => {
|
|
709
|
+
setBusy(true); setErr(''); setMsg('')
|
|
710
|
+
if (action === 'delete') {
|
|
711
|
+
api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { clearForm(); setMsg(tr('settings.deleted')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
api('POST', '/dsh-remote/machines', {
|
|
715
|
+
action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
|
|
716
|
+
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
717
|
+
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
718
|
+
encryptPassword: form.encryptPassword,
|
|
719
|
+
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
720
|
+
}).then((r) => {
|
|
721
|
+
refresh(); clearForm()
|
|
722
|
+
// Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
|
|
723
|
+
// 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
|
|
724
|
+
if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
|
|
725
|
+
else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
|
|
726
|
+
}).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
727
|
+
}
|
|
728
|
+
const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg(tr('settings.switched')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
729
|
+
// Issue #13: "active remote = none" — saved machines stay, but no machine
|
|
730
|
+
// is current, so local sessions are never dragged into a remote context.
|
|
731
|
+
const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg(tr('settings.cleared')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
732
|
+
const del = (id) => { if (window.confirm(tr('settings.deleteConfirm'))) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg(tr('settings.deleted'))).catch((e) => setErr(String((e && e.message) || e))) } }
|
|
733
|
+
|
|
734
|
+
const importSsh = () => {
|
|
735
|
+
api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
|
|
736
|
+
.catch((e) => setErr(tr('settings.sshReadFail', { msg: String((e && e.message) || e) })))
|
|
737
|
+
}
|
|
738
|
+
const applySshEntry = (e) => {
|
|
739
|
+
setForm({ ...form,
|
|
740
|
+
name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
|
|
741
|
+
username: e.user || 'root', privateKeyPath: e.identityFile || '',
|
|
742
|
+
})
|
|
743
|
+
setSshEntries(null)
|
|
744
|
+
setMsg(tr('settings.filledSsh'))
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const setWorkspaceNow = (p) => {
|
|
748
|
+
api('POST', '/dsh-remote/workspace', { path: p })
|
|
749
|
+
.then(() => setMsg(tr('settings.workspaceSwitched', { path: p })))
|
|
750
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── forwards panel ────────────────────────────────────────────────────
|
|
754
|
+
const fwdAction = (action, id) => {
|
|
755
|
+
api('POST', '/dsh-remote/forwards', { action, id })
|
|
756
|
+
.then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
|
|
757
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
758
|
+
}
|
|
759
|
+
const fwdDefine = () => {
|
|
760
|
+
if (!fwdForm.listenPort) return
|
|
761
|
+
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 })
|
|
762
|
+
.then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
|
|
763
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
|
|
767
|
+
React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
|
|
768
|
+
|
|
769
|
+
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
|
|
770
|
+
React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, tr('settings.title')),
|
|
771
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
|
|
772
|
+
tr('settings.intro')),
|
|
773
|
+
React.createElement('div', { style: boxS },
|
|
774
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.machines')),
|
|
775
|
+
machines.length
|
|
776
|
+
? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
|
|
777
|
+
React.createElement('div', { style: { flex: 1, fontSize: 13 } },
|
|
778
|
+
m.name + ' ',
|
|
779
|
+
React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
|
|
780
|
+
m.passwordSet ? ' 🔒' : '',
|
|
781
|
+
(m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
|
|
782
|
+
m.proxy && m.proxy.host ? ' ⛳' : '',
|
|
783
|
+
m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
|
|
784
|
+
m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · ' + tr('settings.current')) : null),
|
|
785
|
+
React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, tr('settings.edit')),
|
|
786
|
+
React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, tr('settings.delete')),
|
|
787
|
+
React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, tr('settings.setCurrent'))))
|
|
788
|
+
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.noMachines')),
|
|
789
|
+
currentId
|
|
790
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
|
|
791
|
+
tr('settings.currentMachine', { name: ((machines.find((m) => m.id === currentId) || {}).name || currentId) }),
|
|
792
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, tr('settings.clearCurrent')))
|
|
793
|
+
: React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, tr('settings.noActive')),
|
|
794
|
+
),
|
|
795
|
+
React.createElement('div', { style: boxS },
|
|
796
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
|
|
797
|
+
React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? tr('settings.editMachine') : tr('settings.addMachine')),
|
|
798
|
+
React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? tr('settings.sshCollapse') : tr('settings.sshImport'))),
|
|
799
|
+
sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
|
|
800
|
+
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 } },
|
|
801
|
+
e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
|
|
802
|
+
: React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, tr('settings.sshNoEntries')))
|
|
803
|
+
: null,
|
|
804
|
+
row(tr('settings.name'), React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: tr('settings.name') + ' (e.g. build-server)', style: inputS }), 'n'),
|
|
805
|
+
row(tr('settings.host'), React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP or hostname', style: inputS }), 'h'),
|
|
806
|
+
row(tr('settings.port'), React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
|
|
807
|
+
row(tr('settings.user'), React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
|
|
808
|
+
row(tr('settings.password'), React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH password (hidden)', style: inputS }), 'w'),
|
|
809
|
+
// 高级配置折叠开关
|
|
810
|
+
React.createElement('button', {
|
|
811
|
+
type: 'button',
|
|
812
|
+
onClick: () => setAdvOpen(!advOpen),
|
|
813
|
+
style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
|
|
814
|
+
}, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
|
|
815
|
+
advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
|
|
816
|
+
row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
|
|
817
|
+
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
|
|
818
|
+
row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
|
|
819
|
+
row(tr('settings.hostKeyMode'), React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
|
|
820
|
+
React.createElement('option', { value: '' }, tr('settings.hkDefault')),
|
|
821
|
+
React.createElement('option', { value: 'accept-new' }, tr('settings.hkAccept')),
|
|
822
|
+
React.createElement('option', { value: 'verify' }, tr('settings.hkVerify')),
|
|
823
|
+
React.createElement('option', { value: 'off' }, tr('settings.hkOff'))), 'hkm'),
|
|
824
|
+
React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
|
|
825
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
826
|
+
React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), tr('settings.agent')),
|
|
827
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
828
|
+
React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), tr('settings.keyboardInteractive')),
|
|
829
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
830
|
+
React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), tr('settings.encryptPassword'))),
|
|
831
|
+
row(tr('settings.proxyHost'), React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: 'Route through this host (optional)', style: inputS }), 'ph'),
|
|
832
|
+
row(tr('settings.proxyPortUser'), React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
|
|
833
|
+
React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: 'Port (22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
|
|
834
|
+
React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: 'Jump user', style: { ...inputS, minWidth: 140 } }),
|
|
835
|
+
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: 'Jump password', style: { ...inputS, minWidth: 140 } })), 'pu'),
|
|
836
|
+
row(tr('settings.proxyKey'), React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: 'Jump private key path (optional)', style: inputS }), 'pk'),
|
|
837
|
+
) : null,
|
|
838
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
|
|
839
|
+
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
840
|
+
form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, tr('settings.delete')) : null,
|
|
841
|
+
React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? tr('settings.cancelEdit') : tr('settings.clear')),
|
|
842
|
+
React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
843
|
+
tst === 'testing' ? tr('settings.testing') : tst === 'ok' ? tr('settings.testOk') : tr('settings.testConn')),
|
|
844
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? tr('settings.saving') : tr('settings.save')),
|
|
845
|
+
),
|
|
846
|
+
),
|
|
847
|
+
// 快捷切换工作区(该机器的最近工作区)
|
|
848
|
+
workspaces.length ? React.createElement('div', { style: boxS },
|
|
849
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.recentWorkspaces')),
|
|
850
|
+
workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
|
|
851
|
+
React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
|
|
852
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, tr('settings.switch'))))) : null,
|
|
853
|
+
// 端口转发面板
|
|
854
|
+
React.createElement('div', { style: boxS },
|
|
855
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.forwards')),
|
|
856
|
+
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' } },
|
|
857
|
+
React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
|
|
858
|
+
React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [auto]' : ''}`),
|
|
859
|
+
f.active
|
|
860
|
+
? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, tr('settings.fwdStop'))
|
|
861
|
+
: React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, tr('settings.fwdStart')),
|
|
862
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, tr('settings.fwdRemove')))) : null,
|
|
863
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
|
|
864
|
+
React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
|
|
865
|
+
React.createElement('option', { value: 'local' }, tr('settings.fwdDirLocal')),
|
|
866
|
+
React.createElement('option', { value: 'reverse' }, tr('settings.fwdDirReverse'))),
|
|
867
|
+
React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: tr('settings.fwdListen'), style: { ...inputS, maxWidth: 90 } }),
|
|
868
|
+
React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: tr('settings.fwdTargetHost'), style: { ...inputS, maxWidth: 120 } }),
|
|
869
|
+
React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: tr('settings.fwdTargetPort'), style: { ...inputS, maxWidth: 90 } }),
|
|
870
|
+
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
871
|
+
React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), tr('settings.fwdAuto')),
|
|
872
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, tr('settings.fwdAdd'))),
|
|
873
|
+
),
|
|
874
|
+
// 审计日志
|
|
875
|
+
React.createElement('div', { style: boxS },
|
|
876
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.audit')),
|
|
877
|
+
auditLines.length
|
|
878
|
+
? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
|
|
879
|
+
auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
|
|
880
|
+
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.auditEmpty')),
|
|
881
|
+
),
|
|
882
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
|
|
883
|
+
React.createElement('div', { style: boxS },
|
|
884
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.update')),
|
|
885
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
|
|
886
|
+
upd
|
|
887
|
+
? React.createElement('span', { style: { opacity: 0.85 } },
|
|
888
|
+
'v' + upd.current,
|
|
889
|
+
upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ v' + upd.latest) : null)
|
|
890
|
+
: React.createElement('span', { style: { opacity: 0.6 } }, tr('settings.updateChecking')),
|
|
891
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? tr('settings.checking') : tr('settings.checkUpdate')),
|
|
892
|
+
upd && upd.updateAvailable
|
|
893
|
+
? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, tr('settings.updateNow'))
|
|
894
|
+
: null,
|
|
895
|
+
),
|
|
896
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
|
|
897
|
+
React.createElement('span', { style: { opacity: 0.8 } }, tr('settings.updateMode')),
|
|
898
|
+
['manual', 'auto', 'off'].map((m) =>
|
|
899
|
+
React.createElement('button', {
|
|
900
|
+
key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
|
|
901
|
+
onClick: () => saveUpdateMode(m), disabled: updBusy,
|
|
902
|
+
}, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
|
|
903
|
+
),
|
|
904
|
+
),
|
|
905
|
+
updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
|
|
906
|
+
),
|
|
907
|
+
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 } },
|
|
908
|
+
React.createElement('span', {}, tr('settings.like')),
|
|
909
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, tr('settings.star')),
|
|
910
|
+
React.createElement('span', {}, '·'),
|
|
911
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, tr('settings.feedback')),
|
|
912
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
|
|
913
|
+
),
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ── Unified picker (fill the directory-flow holes) ───────────────────
|
|
918
|
+
function parseLs(text) {
|
|
919
|
+
const out = []
|
|
920
|
+
for (const ln of String(text || '').split('\n')) {
|
|
921
|
+
const t = ln.trim()
|
|
922
|
+
if (!t || !t.length || /^total\b/i.test(t)) continue
|
|
923
|
+
const parts = t.split(/\s+/).filter(Boolean)
|
|
924
|
+
if (parts.length < 8) continue
|
|
925
|
+
const mode = parts[0]
|
|
926
|
+
const name = parts.slice(8).join(' ')
|
|
927
|
+
if (!name || name === '.' || name === '..') continue
|
|
928
|
+
const isDir = mode.charAt(0) === 'd'
|
|
929
|
+
out.push({ name, dir: isDir })
|
|
930
|
+
}
|
|
931
|
+
return out
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Clickable breadcrumb of the current remote path. Windows remotes show a
|
|
935
|
+
// "This PC" root and `\` separators (此电脑 / C:\ / Users / dev); POSIX `/`.
|
|
936
|
+
function breadcrumb(active, cur, win, jumpTo) {
|
|
937
|
+
const raw = String(cur || '')
|
|
938
|
+
const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
|
|
939
|
+
const rootLabel = isWinPath ? tr('picker.thisPc') : '/'
|
|
940
|
+
const crumbs = []
|
|
941
|
+
const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
|
|
942
|
+
crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
|
|
943
|
+
if (isWinPath && raw !== '') {
|
|
944
|
+
const driveM = raw.match(/^([a-zA-Z]):(.*)$/)
|
|
945
|
+
const drive = driveM[1].toUpperCase() + ':\\'
|
|
946
|
+
crumbs.push(React.createElement('span', { key: 'sep0', style: { color: T.muted } }, '\\'))
|
|
947
|
+
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))
|
|
948
|
+
let acc = drive
|
|
949
|
+
for (const s of (driveM[2] || '').split('\\')) {
|
|
950
|
+
if (!s) continue
|
|
951
|
+
acc += s + '\\'
|
|
952
|
+
const target = acc.slice(0, -1)
|
|
953
|
+
crumbs.push(React.createElement('span', { key: 's|' + target, style: { color: T.muted } }, '\\'))
|
|
954
|
+
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))
|
|
955
|
+
}
|
|
956
|
+
} else if (!isWinPath && raw !== '' && raw !== '/') {
|
|
957
|
+
const segs = raw.replace(/\/+$/, '').split('/')
|
|
958
|
+
let acc = ''
|
|
959
|
+
for (const s of segs) {
|
|
960
|
+
if (!s) continue
|
|
961
|
+
acc += '/' + s
|
|
962
|
+
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
963
|
+
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))
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? tr('picker.chooseDrive') : ''))
|
|
967
|
+
return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function DirPicker(props) {
|
|
971
|
+
const { open, busy, onPicked, onCancel } = props
|
|
972
|
+
const [tab, setTab] = React.useState('local')
|
|
973
|
+
const [machines, setMachines] = React.useState([])
|
|
974
|
+
const [machineId, setMachineId] = React.useState('')
|
|
975
|
+
const [path, setPath] = React.useState('')
|
|
976
|
+
const [items, setItems] = React.useState(null)
|
|
977
|
+
const [err, setErr] = React.useState('')
|
|
978
|
+
const [loading, setLoading] = React.useState(false)
|
|
979
|
+
const [levels, setLevels] = React.useState(null)
|
|
980
|
+
const [popOpen, setPopOpen] = React.useState(false)
|
|
981
|
+
const [suggest, setSuggest] = React.useState([])
|
|
982
|
+
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
983
|
+
const [recent, setRecent] = React.useState([])
|
|
984
|
+
const [win, setWin] = React.useState(false) // remote platform: windows = Git-Bash host
|
|
985
|
+
// 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
|
|
986
|
+
const [localPop, setLocalPop] = React.useState(null)
|
|
987
|
+
const [newDirName, setNewDirName] = React.useState('')
|
|
988
|
+
const suggestTimer = React.useRef(null)
|
|
989
|
+
const suggestRef = React.useRef(null)
|
|
990
|
+
React.useEffect(() => {
|
|
991
|
+
if (!suggestOpen) return
|
|
992
|
+
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
|
|
993
|
+
document.addEventListener('mousedown', onDown)
|
|
994
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
995
|
+
}, [suggestOpen])
|
|
996
|
+
const [mOpen, setMOpen] = React.useState(false)
|
|
997
|
+
const ddRef = React.useRef(null)
|
|
998
|
+
React.useEffect(() => {
|
|
999
|
+
if (!mOpen) return
|
|
1000
|
+
const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
|
|
1001
|
+
document.addEventListener('mousedown', onDown)
|
|
1002
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
1003
|
+
}, [mOpen])
|
|
1004
|
+
|
|
1005
|
+
const sortItems = (list) => {
|
|
1006
|
+
const dirs = list.filter((it) => it.dir)
|
|
1007
|
+
const files = list.filter((it) => !it.dir)
|
|
1008
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
|
1009
|
+
files.sort((a, b) => a.name.localeCompare(b.name))
|
|
1010
|
+
return dirs.concat(files)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Client-side full-path fallback: when the server did not attach a `path`
|
|
1014
|
+
// (older host half), join base + name with the base's own separator.
|
|
1015
|
+
const joinDisplay = (base, name) => {
|
|
1016
|
+
const b = String(base || '')
|
|
1017
|
+
const n = String(name || '')
|
|
1018
|
+
if (!b || b === '/') return '/' + n
|
|
1019
|
+
const sep = /^[a-zA-Z]:/.test(b) || b.includes('\\') ? '\\' : '/'
|
|
1020
|
+
return b.replace(/[\\/]+$/, '') + sep + n
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const loadLevels = (id, p, toIndex) => {
|
|
1024
|
+
if (!id) return
|
|
1025
|
+
setLoading(true); setErr('')
|
|
1026
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
1027
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
1028
|
+
.then((res) => {
|
|
1029
|
+
setWin(res && res.platform === 'windows')
|
|
1030
|
+
const real = res && res.path ? res.path : (p || '')
|
|
1031
|
+
// Server returns [{ type, name, size, mtime, path }] where `path` is
|
|
1032
|
+
// the FULL display-form path (C:\Users\dev on Windows) — the client
|
|
1033
|
+
// never joins paths itself, so drives and Windows paths just work.
|
|
1034
|
+
const list = Array.isArray(res && res.items)
|
|
1035
|
+
? 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 }))
|
|
1036
|
+
: parseLs(res && res.text)
|
|
1037
|
+
const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
|
|
1038
|
+
setLevels((prev) => {
|
|
1039
|
+
const base = prev && prev.length ? prev.slice() : []
|
|
1040
|
+
let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
|
|
1041
|
+
if (idx >= base.length) return base.concat([node])
|
|
1042
|
+
base[idx] = node
|
|
1043
|
+
return base.slice(0, idx + 1)
|
|
1044
|
+
})
|
|
1045
|
+
})
|
|
1046
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1047
|
+
.finally(() => setLoading(false))
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
React.useEffect(() => { if (open) {
|
|
1051
|
+
// Bug fix: the picker must NOT reopen with the previous selection's
|
|
1052
|
+
// path — reset the path bar (and the browse state) on every open.
|
|
1053
|
+
setPath(''); setLevels(null); setWin(false)
|
|
1054
|
+
setLocalPop(null); setNewDirName('')
|
|
1055
|
+
api('GET', '/dsh-remote/machines').then((r) => {
|
|
1056
|
+
setMachines(r.machines || [])
|
|
1057
|
+
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
1058
|
+
setRecent((cur && cur.recentWorkspaces) || [])
|
|
1059
|
+
setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
|
|
1060
|
+
})
|
|
1061
|
+
} }, [open])
|
|
1062
|
+
|
|
1063
|
+
// 本机目录浏览(browse 后端兜底):列出 path 的子目录;path 为空 → 宿主家目录。
|
|
1064
|
+
const loadLocal = (p) => {
|
|
1065
|
+
setLoading(true); setErr('')
|
|
1066
|
+
api('GET', '/dsh-remote/local-list' + (p ? '?path=' + encodeURIComponent(p) : ''))
|
|
1067
|
+
.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) || [] }))
|
|
1068
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1069
|
+
.finally(() => setLoading(false))
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const chooseLocal = () => {
|
|
1073
|
+
setLoading(true); setErr('')
|
|
1074
|
+
api('POST', '/dsh-remote/local-pick')
|
|
1075
|
+
.then((r) => {
|
|
1076
|
+
if (r && r.path) { setLoading(false); onPicked(String(r.path)) }
|
|
1077
|
+
// browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
|
|
1078
|
+
// → 打开内置本机目录浏览器。
|
|
1079
|
+
else if (r && r.kind === 'browse') loadLocal('')
|
|
1080
|
+
else if (r && r.cancelled) { setLoading(false); setErr(tr('picker.cancelled')) }
|
|
1081
|
+
else { setLoading(false); setErr((r && r.error) || tr('picker.noSysPicker')) }
|
|
1082
|
+
})
|
|
1083
|
+
.catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + tr('picker.typePath')) })
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
|
|
1087
|
+
const mkdirLocal = () => {
|
|
1088
|
+
const cur = localPop
|
|
1089
|
+
const name = newDirName.trim()
|
|
1090
|
+
if (!cur || !cur.path || !name || loading) return
|
|
1091
|
+
setLoading(true); setErr('')
|
|
1092
|
+
api('POST', '/dsh-remote/local-mkdir', { path: cur.path, name })
|
|
1093
|
+
.then(() => { setNewDirName(''); loadLocal(cur.path) })
|
|
1094
|
+
.catch((e) => { setErr(String((e && e.message) || e)); setLoading(false) })
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// 选用浮层中的本机目录:回填输入框(不直接提交),复核后再点「选用此本地路径」。
|
|
1098
|
+
const acceptLocalPick = (p) => {
|
|
1099
|
+
setPath(String(p || ''))
|
|
1100
|
+
setErr('')
|
|
1101
|
+
setLocalPop(null)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const switchTab = (t) => {
|
|
1105
|
+
setTab(t); setErr('')
|
|
1106
|
+
// each tab keeps its own path — never leak the other tab's selection
|
|
1107
|
+
setPath('')
|
|
1108
|
+
if (t === 'remote') {
|
|
1109
|
+
// Windows remotes show a "This PC" drive view at the root; POSIX shows
|
|
1110
|
+
// the filesystem root. Either way loadLevels('') lists that root.
|
|
1111
|
+
if (machineId) loadLevels(machineId, '', 0)
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Parent path of a display-form remote path (client-side, both separators).
|
|
1116
|
+
const parentOf = (p) => {
|
|
1117
|
+
const t = String(p || '')
|
|
1118
|
+
if (!t || t === '/') return null
|
|
1119
|
+
const win = /^[a-zA-Z]:/.test(t)
|
|
1120
|
+
if (win && /^[a-zA-Z]:\\?$/.test(t)) return null // drive root: no parent
|
|
1121
|
+
const sep = win ? '\\' : '/'
|
|
1122
|
+
const idx = t.lastIndexOf(sep)
|
|
1123
|
+
if (idx <= 0) return null
|
|
1124
|
+
const par = t.slice(0, idx)
|
|
1125
|
+
if (win && /^[a-zA-Z]:$/.test(par)) return par + '\\'
|
|
1126
|
+
return par || '/'
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// enterDir(it): drive into the given entry — `it.path` is the FULL
|
|
1130
|
+
// display-form path from the server (drives C:\, Windows and POSIX alike).
|
|
1131
|
+
const enterDir = (it) => {
|
|
1132
|
+
if (busy || loading || !it || !it.dir) return
|
|
1133
|
+
loadLevels(machineId, it.path, (levels ? levels.length : 0))
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Navigate one level up in the browse popup. When the current level was
|
|
1137
|
+
// opened directly at a deep path (levels.length === 1 — e.g. the popup
|
|
1138
|
+
// was opened at the path bar's value) the parent is loaded as the new top
|
|
1139
|
+
// level instead of being disabled.
|
|
1140
|
+
const goUp = () => {
|
|
1141
|
+
if (loading) return
|
|
1142
|
+
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
1143
|
+
if (!last) return
|
|
1144
|
+
if (levels.length > 1) { setLevels((p) => p.slice(0, p.length - 1)); return }
|
|
1145
|
+
const par = parentOf(last.path)
|
|
1146
|
+
if (par) loadLevels(machineId, par, 1)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const loadSuggestions = (raw, mid) => {
|
|
1150
|
+
const id = mid || machineId
|
|
1151
|
+
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
1152
|
+
const t = String(raw || '').trim()
|
|
1153
|
+
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
1154
|
+
// Separator-aware split: `\` when the input looks like a Windows path.
|
|
1155
|
+
const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
|
|
1156
|
+
const sep = backslash ? '\\' : '/'
|
|
1157
|
+
const idx = t.lastIndexOf(sep)
|
|
1158
|
+
let parent, lastSeg
|
|
1159
|
+
if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
|
|
1160
|
+
else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
|
|
1161
|
+
else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
|
|
1162
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
1163
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
1164
|
+
.then((res) => {
|
|
1165
|
+
const list = Array.isArray(res && res.items)
|
|
1166
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
|
|
1167
|
+
: []
|
|
1168
|
+
const matches = list
|
|
1169
|
+
.filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
|
|
1170
|
+
.slice(0, 40).map((it) => it.path)
|
|
1171
|
+
setSuggest(matches)
|
|
1172
|
+
setSuggestOpen(!!matches.length)
|
|
1173
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const onPathChange = (raw) => {
|
|
1177
|
+
setPath(raw); setErr('')
|
|
1178
|
+
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
|
1179
|
+
suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const continueSuggest = (dir) => {
|
|
1183
|
+
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
1184
|
+
setSuggestOpen(false)
|
|
1185
|
+
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
1186
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
1187
|
+
.then((res) => {
|
|
1188
|
+
const list = Array.isArray(res && res.items)
|
|
1189
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
|
|
1190
|
+
: []
|
|
1191
|
+
const kids = list.slice(0, 40).map((it) => it.path)
|
|
1192
|
+
setSuggest(kids)
|
|
1193
|
+
setSuggestOpen(!!kids.length)
|
|
1194
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const selectSuggestion = (s) => {
|
|
1198
|
+
if (!s) { setSuggestOpen(false); return }
|
|
1199
|
+
// Auto-append the trailing separator so the path reads as a directory
|
|
1200
|
+
// and the user can keep typing the next segment right away.
|
|
1201
|
+
const sep = String(s).includes('\\') ? '\\' : '/'
|
|
1202
|
+
const withSep = String(s).replace(/[\\/]+$/, '') + sep
|
|
1203
|
+
setPath(withSep); setErr(''); setSuggestOpen(false)
|
|
1204
|
+
continueSuggest(withSep)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const commitPath = (p) => {
|
|
1208
|
+
const target = String(p || '').trim()
|
|
1209
|
+
if (!target || !machineId || busy) return
|
|
1210
|
+
setPopOpen(false); setSuggestOpen(false)
|
|
1211
|
+
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)))
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const acceptBrowserPick = (p) => {
|
|
1215
|
+
setPath(String(p || ''))
|
|
1216
|
+
setSuggestOpen(false)
|
|
1217
|
+
setPopOpen(false)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const pickHome = () => {
|
|
1221
|
+
api('POST', '/dsh-remote/home').then((r) => {
|
|
1222
|
+
if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
|
|
1223
|
+
else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
|
|
1224
|
+
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const mkdirHere = () => {
|
|
1228
|
+
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
1229
|
+
const base = (last && last.path) || '/'
|
|
1230
|
+
const nm = window.prompt(tr('picker.newDirName', { path: base }))
|
|
1231
|
+
if (!nm || !nm.trim()) return
|
|
1232
|
+
const sep = base.includes('\\') ? '\\' : '/'
|
|
1233
|
+
const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
|
|
1234
|
+
api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
|
|
1235
|
+
.then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
|
|
1236
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// 本机浏览浮层的面包屑:browse 后端给出完整祖先链(含盘符根),可点击跳转。
|
|
1240
|
+
const localCrumbs = (cur) => {
|
|
1241
|
+
const crumbs = (cur && cur.crumbs) || []
|
|
1242
|
+
if (!crumbs.length) return React.createElement('span', null, '/')
|
|
1243
|
+
const out = []
|
|
1244
|
+
crumbs.forEach((c, i) => {
|
|
1245
|
+
if (i > 0) out.push(React.createElement('span', { key: 'sep' + i, style: { color: T.muted } }, ' › '))
|
|
1246
|
+
out.push(React.createElement('span', {
|
|
1247
|
+
key: c.path || i,
|
|
1248
|
+
title: c.path,
|
|
1249
|
+
onClick: () => loadLocal(c.path),
|
|
1250
|
+
style: { cursor: 'pointer', color: i === crumbs.length - 1 ? T.label : T.ok, fontWeight: i === crumbs.length - 1 ? 700 : 400 },
|
|
1251
|
+
}, c.name))
|
|
1252
|
+
})
|
|
1253
|
+
return React.createElement('span', null, out)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// 本机目录浏览浮层(browse 后端):进入子目录 / 面包屑跳转 / 回家目录 /
|
|
1257
|
+
// 新建文件夹 / 选用回填。与远程浏览弹层同一套交互。
|
|
1258
|
+
function renderLocalPopup() {
|
|
1259
|
+
const cur = localPop
|
|
1260
|
+
if (!cur) return null
|
|
1261
|
+
const crumbs = cur.crumbs || []
|
|
1262
|
+
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
1263
|
+
return React.createElement('div', {
|
|
1264
|
+
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
1265
|
+
onClick: () => setLocalPop(null),
|
|
1266
|
+
},
|
|
1267
|
+
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() },
|
|
1268
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
1269
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, tr('picker.back')),
|
|
1270
|
+
cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: tr('picker.goHome') }, tr('picker.homeBtn')) : null,
|
|
1271
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
|
|
1272
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, tr('picker.closeBtn')),
|
|
1273
|
+
),
|
|
1274
|
+
// 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
|
|
1275
|
+
(cur.drives && cur.drives.length > 1) ? React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', padding: '6px 12px', borderBottom: '1px solid ' + T.border } },
|
|
1276
|
+
cur.drives.map((d) => {
|
|
1277
|
+
const active = String(cur.path).toUpperCase().startsWith(String(d.name).toUpperCase())
|
|
1278
|
+
return React.createElement('button', {
|
|
1279
|
+
key: d.path,
|
|
1280
|
+
onClick: () => loadLocal(d.path),
|
|
1281
|
+
disabled: loading,
|
|
1282
|
+
title: d.path,
|
|
1283
|
+
style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace', fontWeight: active ? 700 : 400, color: active ? T.ok : T.label },
|
|
1284
|
+
}, d.name)
|
|
1285
|
+
}),
|
|
1286
|
+
) : null,
|
|
1287
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
1288
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, tr('picker.loading'))
|
|
1289
|
+
: (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
1290
|
+
key: it.path || i, title: tr('picker.enterDir', { name: it.name }),
|
|
1291
|
+
onClick: () => loadLocal(it.path),
|
|
1292
|
+
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 },
|
|
1293
|
+
},
|
|
1294
|
+
React.createElement('span', null, '📁'),
|
|
1295
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
1296
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, tr('picker.noSubdir'))),
|
|
1297
|
+
cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, tr('picker.truncated')) : null,
|
|
1298
|
+
),
|
|
1299
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
|
|
1300
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
|
|
1301
|
+
React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: tr('picker.newFolder'), style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
|
|
1302
|
+
React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, tr('picker.create')),
|
|
1303
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, tr('picker.selected', { path: cur.path })),
|
|
1304
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, tr('picker.chooseThis')),
|
|
1305
|
+
),
|
|
1306
|
+
),
|
|
1307
|
+
)
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function renderDirPopup() {
|
|
1311
|
+
if (!levels || !levels.length) {
|
|
1312
|
+
return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? tr('picker.loading') : tr('picker.readingRoot')))
|
|
1313
|
+
}
|
|
1314
|
+
const last = levels[levels.length - 1]
|
|
1315
|
+
const entries = last.all || []
|
|
1316
|
+
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' } },
|
|
1317
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
1318
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, tr('picker.back')),
|
|
1319
|
+
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 }))),
|
|
1320
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, tr('picker.newDir')),
|
|
1321
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, tr('picker.closeBtn')),
|
|
1322
|
+
),
|
|
1323
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
1324
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, tr('picker.loading'))
|
|
1325
|
+
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
1326
|
+
key: i, title: it.drive ? tr('picker.enterDrive', { name: it.name }) : (it.dir ? tr('picker.enterDir', { name: it.name }) : tr('picker.fileAt', { name: it.name })),
|
|
1327
|
+
onClick: it.dir ? () => enterDir(it) : undefined,
|
|
1328
|
+
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 },
|
|
1329
|
+
},
|
|
1330
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label, fontSize: 13 } }, it.drive ? '💽' : (it.dir
|
|
1331
|
+
? 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' }))
|
|
1332
|
+
: 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' })))),
|
|
1333
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
1334
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
|
|
1335
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, tr('picker.emptyDir'))),
|
|
1336
|
+
),
|
|
1337
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
1338
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, tr('picker.selected', { path: (last.path || (win ? tr('picker.driveRoot') : '/')) })),
|
|
1339
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, tr('picker.chooseThis')),
|
|
1340
|
+
),
|
|
1341
|
+
)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (!open) return null
|
|
1345
|
+
const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
|
|
1346
|
+
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() } },
|
|
1347
|
+
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() },
|
|
1348
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
|
|
1349
|
+
React.createElement('div', null, tr('picker.title')),
|
|
1350
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, tr('picker.close')),
|
|
1351
|
+
),
|
|
1352
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
1353
|
+
tabBtn('local', tr('picker.tabLocal')),
|
|
1354
|
+
tabBtn('remote', tr('picker.tabRemote')),
|
|
1355
|
+
),
|
|
1356
|
+
tab === 'local'
|
|
1357
|
+
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
1358
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, tr('picker.localIntro')),
|
|
1359
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
1360
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: tr('picker.localPlaceholder'), style: inputS }),
|
|
1361
|
+
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, tr('picker.useLocalPath')),
|
|
1362
|
+
),
|
|
1363
|
+
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? tr('picker.opening') : tr('picker.openSysPicker')),
|
|
1364
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
1365
|
+
localPop ? renderLocalPopup() : null,
|
|
1366
|
+
)
|
|
1367
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
1368
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
1369
|
+
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
|
|
1370
|
+
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
1371
|
+
React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
|
|
1372
|
+
(machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : tr('picker.chooseMachine')),
|
|
1373
|
+
),
|
|
1374
|
+
mOpen
|
|
1375
|
+
? 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 } },
|
|
1376
|
+
machines.length
|
|
1377
|
+
? 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 + ')'))
|
|
1378
|
+
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, tr('picker.noMachines')),
|
|
1379
|
+
)
|
|
1380
|
+
: null,
|
|
1381
|
+
),
|
|
1382
|
+
),
|
|
1383
|
+
// 最近工作区快捷入口
|
|
1384
|
+
recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1385
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, tr('picker.recent')),
|
|
1386
|
+
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,
|
|
1387
|
+
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
1388
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
1389
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? tr('picker.pathPlaceholderWin') : tr('picker.pathPlaceholder')) : tr('picker.chooseMachineFirst')), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
1390
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, tr('picker.browse')),
|
|
1391
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
|
|
1392
|
+
),
|
|
1393
|
+
(suggestOpen && suggest.length)
|
|
1394
|
+
? 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)' } },
|
|
1395
|
+
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)),
|
|
1396
|
+
)
|
|
1397
|
+
: null,
|
|
1398
|
+
),
|
|
1399
|
+
popOpen ? renderDirPopup() : null,
|
|
1400
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
1401
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
1402
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? tr('picker.selected', { path }) : ''),
|
|
1403
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? tr('picker.mirroring') : tr('picker.setRemoteWs')),
|
|
1404
|
+
),
|
|
1405
|
+
),
|
|
1406
|
+
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, tr('picker.cancel'))) : null,
|
|
1407
|
+
React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
|
|
1408
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
|
|
1409
|
+
React.createElement('span', {}, '·'),
|
|
1410
|
+
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, 'Feedback / report issue'),
|
|
1411
|
+
),
|
|
1412
|
+
),
|
|
1413
|
+
)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// ── better-sidebar integration (optional) ────────────────────────────────
|
|
1417
|
+
const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
|
|
1418
|
+
const SIDEBAR_FILE_ID = 'dsh-remote:file'
|
|
1419
|
+
|
|
1420
|
+
function fetchRemoteStatus() {
|
|
1421
|
+
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Resolve the remote path this session shows. Issue #13: the ONLY source
|
|
1425
|
+
// of truth is the session's own cwd mapped through /dsh-remote/resolve-
|
|
1426
|
+
// mirror — a cwd outside any mirror dir means "this session is NOT a
|
|
1427
|
+
// remote session", and the sidebar must NOT fall back to the machine
|
|
1428
|
+
// workspace (that made an unrelated saved machine leak into local
|
|
1429
|
+
// sessions).
|
|
1430
|
+
function resolveSessionRemote(sessionId) {
|
|
1431
|
+
if (!sessionId) return Promise.resolve('')
|
|
1432
|
+
// 1) Prefer the client-side session list cwd (mirror dir under
|
|
1433
|
+
// $DSH_HOME/remote-workspaces/...) passed as ?local=.
|
|
1434
|
+
let local = ''
|
|
1435
|
+
try {
|
|
1436
|
+
const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
|
|
1437
|
+
? SESSIONS.list.getSnapshot()
|
|
1438
|
+
: null
|
|
1439
|
+
const entry = list && list.byId && list.byId[sessionId]
|
|
1440
|
+
if (entry && entry.cwd) local = String(entry.cwd)
|
|
1441
|
+
} catch { /* sessions store shape differs */ }
|
|
1442
|
+
if (local) {
|
|
1443
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
|
|
1444
|
+
.then((r) => (r && r.remotePath) || '')
|
|
1445
|
+
.catch(() => '')
|
|
1446
|
+
}
|
|
1447
|
+
// 2) Fall back to host-side session header cwd.
|
|
1448
|
+
return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
|
|
1449
|
+
.then((r) => (r && r.remotePath) || '')
|
|
1450
|
+
.catch(() => '')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function readRemoteFile(path, maxBytes) {
|
|
1454
|
+
return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
|
|
1455
|
+
throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
|
|
1456
|
+
})
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function listRemoteDir(path) {
|
|
1460
|
+
return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
|
|
1461
|
+
throw new Error(tr('file.listFail', { msg: ((e && e.message) || e) }))
|
|
1462
|
+
})
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function joinRemote(base, name) {
|
|
1466
|
+
if (!base) return name
|
|
1467
|
+
return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ── Remote explorer tab ────────────────────────────────────────────────
|
|
1471
|
+
// Inline monochrome icons (fill: currentColor → follows the row text color,
|
|
1472
|
+
// i.e. black on the sidebar). Folder open/closed + file.
|
|
1473
|
+
const IconFolder = (open) => React.createElement('svg', {
|
|
1474
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
|
|
1475
|
+
},
|
|
1476
|
+
open
|
|
1477
|
+
? 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' })
|
|
1478
|
+
: 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' }),
|
|
1479
|
+
)
|
|
1480
|
+
const IconFile = () => React.createElement('svg', {
|
|
1481
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
|
|
1482
|
+
},
|
|
1483
|
+
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' }),
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
// Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
|
|
1487
|
+
// contract as the built-in local file tree (better-sidebar framework
|
|
1488
|
+
// manages + persists the expanded set), so interaction and look match the
|
|
1489
|
+
// local files tab. Data is listed live from the remote host over
|
|
1490
|
+
// /dsh-remote/ls, rooted at this session's remote workspace dir.
|
|
1491
|
+
function RemoteExplorerTab(props) {
|
|
1492
|
+
const [status, setStatus] = React.useState(null)
|
|
1493
|
+
const [root, setRoot] = React.useState('') // remote root path for this session
|
|
1494
|
+
const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
|
|
1495
|
+
const dataRef = React.useRef(data)
|
|
1496
|
+
const [err, setErr] = React.useState('')
|
|
1497
|
+
const [busy, setBusy] = React.useState('')
|
|
1498
|
+
const [picker, setPicker] = React.useState(false)
|
|
1499
|
+
const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
|
|
1500
|
+
const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
|
|
1501
|
+
const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
|
|
1502
|
+
/** Row whose path was just copied — shows a brief「已复制」label. */
|
|
1503
|
+
const [copiedPath, setCopiedPath] = React.useState('')
|
|
1504
|
+
const expanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
1505
|
+
|
|
1506
|
+
const storeLevel = (path, level) => {
|
|
1507
|
+
dataRef.current = { ...dataRef.current, [path]: level }
|
|
1508
|
+
setData(dataRef.current)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const loadDir = React.useCallback((dir, opts) => {
|
|
1512
|
+
const cur = dataRef.current
|
|
1513
|
+
const existing = cur[dir]
|
|
1514
|
+
// If we already have data (entries loaded) and this is a background
|
|
1515
|
+
// refresh, keep what we have — don't flash a loading state over a
|
|
1516
|
+
// populated tree.
|
|
1517
|
+
if (existing && !existing.loading && opts && opts.keep) return
|
|
1518
|
+
if (cur[dir] !== undefined && !cur[dir].loading) return
|
|
1519
|
+
storeLevel(dir, { loading: true })
|
|
1520
|
+
listRemoteDir(dir)
|
|
1521
|
+
.then((res) => {
|
|
1522
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
1523
|
+
const sorted = items.slice().sort((a, b) => {
|
|
1524
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
1525
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
1526
|
+
return String(a.name).localeCompare(String(b.name))
|
|
1527
|
+
})
|
|
1528
|
+
storeLevel(dir, { entries: sorted })
|
|
1529
|
+
})
|
|
1530
|
+
.catch((e) => {
|
|
1531
|
+
// Keep any previously-loaded entries under this dir (so a transient
|
|
1532
|
+
// SFTP failure doesn't collapse the whole subtree); surface a
|
|
1533
|
+
// message only when there was nothing to show before.
|
|
1534
|
+
const prev = dataRef.current[dir]
|
|
1535
|
+
if (prev && prev.entries) {
|
|
1536
|
+
storeLevel(dir, { ...prev, softError: String((e && e.message) || e) })
|
|
1537
|
+
} else {
|
|
1538
|
+
storeLevel(dir, { error: String((e && e.message) || e) })
|
|
1539
|
+
}
|
|
1540
|
+
})
|
|
1541
|
+
}, [])
|
|
1542
|
+
|
|
1543
|
+
// Resolve this session's remote root, then load it and every expanded dir.
|
|
1544
|
+
// Re-runs whenever the session scope changes (switching conversations, or
|
|
1545
|
+
// the async sessionCwd fetch landing), so the sidebar follows the session's
|
|
1546
|
+
// own workspace dir.
|
|
1547
|
+
const scopeCwd = props.scope && props.scope.cwd
|
|
1548
|
+
const sessionId = props.scope && props.scope.sessionId
|
|
1549
|
+
const rootRef = React.useRef('')
|
|
1550
|
+
const refreshStatus = React.useCallback((opts) => {
|
|
1551
|
+
const applyRoot = (r, connected) => {
|
|
1552
|
+
const prevRoot = rootRef.current
|
|
1553
|
+
rootRef.current = r
|
|
1554
|
+
setRoot(r)
|
|
1555
|
+
// Full reset ONLY when the session's remote root actually changed
|
|
1556
|
+
// (different workspace / different conversation). When we just come
|
|
1557
|
+
// back to this tab with the same workspace, keep the loaded tree so
|
|
1558
|
+
// expanded dirs and their contents stay put.
|
|
1559
|
+
const sameRoot = prevRoot && r && prevRoot === r
|
|
1560
|
+
if (!sameRoot) {
|
|
1561
|
+
setData({})
|
|
1562
|
+
dataRef.current = {}
|
|
1563
|
+
}
|
|
1564
|
+
if (r) {
|
|
1565
|
+
// Not connected (e.g. freshly restarted, machine no longer active):
|
|
1566
|
+
// show a clear disconnected state instead of firing ls requests
|
|
1567
|
+
// that all fail with AggregateError / 500 in a loop.
|
|
1568
|
+
if (!connected) {
|
|
1569
|
+
storeLevel(r, { error: tr('explorer.notConnected') })
|
|
1570
|
+
return
|
|
1571
|
+
}
|
|
1572
|
+
if (!sameRoot) {
|
|
1573
|
+
storeLevel(r, { loading: true })
|
|
1574
|
+
}
|
|
1575
|
+
listRemoteDir(r)
|
|
1576
|
+
.then((res) => {
|
|
1577
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
1578
|
+
const sorted = items.slice().sort((a, b) => {
|
|
1579
|
+
if (a.type === 'dir' && b.type !== 'dir') return -1
|
|
1580
|
+
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
1581
|
+
return String(a.name).localeCompare(String(b.name))
|
|
1582
|
+
})
|
|
1583
|
+
storeLevel(r, { entries: sorted, missing: !!(res && res.missing) })
|
|
1584
|
+
for (const dir of expanded) loadDir(dir, { keep: true })
|
|
1585
|
+
})
|
|
1586
|
+
.catch((e) => {
|
|
1587
|
+
// Transient connection failure: keep the previously visible
|
|
1588
|
+
// root level so the tree doesn't collapse to an error.
|
|
1589
|
+
const prev = dataRef.current[r]
|
|
1590
|
+
if (prev && prev.entries) {
|
|
1591
|
+
storeLevel(r, { ...prev, softError: String((e && e.message) || e) })
|
|
1592
|
+
} else {
|
|
1593
|
+
storeLevel(r, { error: String((e && e.message) || e) })
|
|
1594
|
+
}
|
|
1595
|
+
})
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
const resolveRoot = () => {
|
|
1599
|
+
if (scopeCwd) {
|
|
1600
|
+
return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
|
|
1601
|
+
.then((r) => (r && r.remotePath) || '')
|
|
1602
|
+
.catch(() => '')
|
|
1603
|
+
}
|
|
1604
|
+
if (sessionId) return resolveSessionRemote(sessionId)
|
|
1605
|
+
return Promise.resolve('')
|
|
1606
|
+
}
|
|
1607
|
+
resolveRoot().then((r) => {
|
|
1608
|
+
fetchRemoteStatus().then((s) => {
|
|
1609
|
+
setStatus(s)
|
|
1610
|
+
// Issue #13: NEVER fall back to the machine-level workspace here —
|
|
1611
|
+
// a session whose cwd is not a mirror is a LOCAL session and shows
|
|
1612
|
+
// the "no remote workspace" empty state instead.
|
|
1613
|
+
applyRoot(r || '', !!(s && s.connected))
|
|
1614
|
+
})
|
|
1615
|
+
})
|
|
1616
|
+
}, [scopeCwd, sessionId])
|
|
1617
|
+
|
|
1618
|
+
React.useEffect(() => { refreshStatus() }, [refreshStatus])
|
|
1619
|
+
React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
|
|
1620
|
+
// Load dirs as they get expanded (keep already-loaded content).
|
|
1621
|
+
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
|
|
1622
|
+
// ── Context-menu portal: append to document.body so the sidebar's
|
|
1623
|
+
// overflow/transform cannot clip it. Pure DOM, no React portal needed.
|
|
1624
|
+
React.useEffect(() => {
|
|
1625
|
+
if (!menu) return
|
|
1626
|
+
const host = document.createElement('div')
|
|
1627
|
+
host.style.cssText = 'position:fixed;z-index:999999;pointer-events:auto'
|
|
1628
|
+
const box = document.createElement('div')
|
|
1629
|
+
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'
|
|
1630
|
+
const mkItem = (label, danger, fn) => {
|
|
1631
|
+
const d = document.createElement('div')
|
|
1632
|
+
d.textContent = label
|
|
1633
|
+
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'
|
|
1634
|
+
d.onmouseenter = () => { d.style.background = 'rgba(128,128,128,0.14)' }
|
|
1635
|
+
d.onmouseleave = () => { d.style.background = 'transparent' }
|
|
1636
|
+
d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
|
|
1637
|
+
return d
|
|
1638
|
+
}
|
|
1639
|
+
box.appendChild(mkItem(menu.isDir ? tr('explorer.menuExpand') : tr('explorer.menuOpen'), false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
|
|
1640
|
+
box.appendChild(mkItem(tr('explorer.menuDownload'), false, () => doFs('download', { path: menu.path })))
|
|
1641
|
+
box.appendChild(mkItem(tr('explorer.menuCopyRel'), false, () => copyPath(relPath(menu.path), menu.path)))
|
|
1642
|
+
box.appendChild(mkItem(tr('explorer.menuCopyAbs'), false, () => copyPath(menu.path, menu.path)))
|
|
1643
|
+
box.appendChild(mkItem(tr('explorer.menuRename'), false, () => { const nm = window.prompt(tr('explorer.renamePrompt'), menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
|
|
1644
|
+
box.appendChild(mkItem(menu.isDir ? tr('explorer.menuDeleteDir') : tr('explorer.menuDelete'), true, () => { if (window.confirm(tr('explorer.deleteConfirm', { path: menu.path }))) doFs('remove', { path: menu.path }, true) }))
|
|
1645
|
+
host.appendChild(box)
|
|
1646
|
+
document.body.appendChild(host)
|
|
1647
|
+
const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
|
|
1648
|
+
// Delay adding listener so the same mousedown that opened the menu doesn't close it
|
|
1649
|
+
const tid = setTimeout(() => document.addEventListener('mousedown', close), 0)
|
|
1650
|
+
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
|
|
1651
|
+
}, [menu])
|
|
1652
|
+
|
|
1653
|
+
const openFile = (p) => {
|
|
1654
|
+
const name = String(p).split(/[\\/]/).pop() || p
|
|
1655
|
+
props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const doFs = (op, payload, refreshParent) => {
|
|
1659
|
+
setBusy(op)
|
|
1660
|
+
return api('POST', '/dsh-remote/fs', { op, ...payload })
|
|
1661
|
+
.then(() => {
|
|
1662
|
+
setErr('')
|
|
1663
|
+
if (refreshParent) {
|
|
1664
|
+
dataRef.current = {}
|
|
1665
|
+
setData({})
|
|
1666
|
+
if (root) { storeLevel(root, { loading: true }); loadDir(root) }
|
|
1667
|
+
for (const dir of expanded) loadDir(dir)
|
|
1668
|
+
}
|
|
1669
|
+
})
|
|
1670
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1671
|
+
.finally(() => setBusy(''))
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const onCtx = (e, path, isDir, name) => {
|
|
1675
|
+
e.preventDefault(); e.stopPropagation()
|
|
1676
|
+
setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
|
|
1677
|
+
}
|
|
1678
|
+
const ctxItem = (label, danger, fn) => React.createElement('div', {
|
|
1679
|
+
onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
|
|
1680
|
+
style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
|
|
1681
|
+
}, label)
|
|
1682
|
+
|
|
1683
|
+
// The path shown by「复制相对地址」: relative to this session's remote
|
|
1684
|
+
// workspace root (mirror of the built-in local tree's cwd-relative copy).
|
|
1685
|
+
const relPath = (p) => {
|
|
1686
|
+
if (!root) return p
|
|
1687
|
+
const r = String(root).replace(/[\\/]+$/, '')
|
|
1688
|
+
const s = String(p)
|
|
1689
|
+
if (s === r) return '.'
|
|
1690
|
+
if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
1691
|
+
if (s.startsWith(r + '\\')) return s.slice(r.length + 1)
|
|
1692
|
+
return s
|
|
1693
|
+
}
|
|
1694
|
+
// Copy `text`, then briefly flag the row whose path was copied.
|
|
1695
|
+
const copyPath = (text, path) => {
|
|
1696
|
+
copyToClipboard(text).then((ok) => {
|
|
1697
|
+
if (!ok) return
|
|
1698
|
+
setCopiedPath(path)
|
|
1699
|
+
window.setTimeout(() => {
|
|
1700
|
+
setCopiedPath((cur) => (cur === path ? '' : cur))
|
|
1701
|
+
}, 1200)
|
|
1702
|
+
})
|
|
1703
|
+
}
|
|
1704
|
+
/** Trailing「已复制」feedback for a row (after a successful copy). */
|
|
1705
|
+
const copiedMark = (path) => (copiedPath === path
|
|
1706
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, tr('explorer.copied'))
|
|
1707
|
+
: null)
|
|
1708
|
+
|
|
1709
|
+
// One tree row (dir toggles via the framework's onToggleDir; file opens).
|
|
1710
|
+
// Text uses the normal foreground (theme-following) — no accent green.
|
|
1711
|
+
const row = (entry, depth) => {
|
|
1712
|
+
const pad = depth * 22 + 6
|
|
1713
|
+
const hovered = hoverPath === entry.path
|
|
1714
|
+
const base = {
|
|
1715
|
+
display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
|
|
1716
|
+
borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
|
|
1717
|
+
overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
|
|
1718
|
+
background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
|
|
1719
|
+
color: T.label,
|
|
1720
|
+
}
|
|
1721
|
+
if (entry.type === 'dir') {
|
|
1722
|
+
const isOpen = expanded.includes(entry.path)
|
|
1723
|
+
return React.createElement('div', { key: entry.path },
|
|
1724
|
+
React.createElement('div', {
|
|
1725
|
+
role: 'button', tabIndex: 0,
|
|
1726
|
+
onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
|
|
1727
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
|
|
1728
|
+
onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
|
|
1729
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
1730
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
1731
|
+
style: base,
|
|
1732
|
+
title: entry.path,
|
|
1733
|
+
},
|
|
1734
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
|
|
1735
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
|
|
1736
|
+
copiedMark(entry.path),
|
|
1737
|
+
),
|
|
1738
|
+
isOpen ? renderLevel(entry.path, depth + 1) : null,
|
|
1739
|
+
)
|
|
1740
|
+
}
|
|
1741
|
+
return React.createElement('div', {
|
|
1742
|
+
role: 'button', tabIndex: 0, key: entry.path,
|
|
1743
|
+
onClick: () => openFile(entry.path),
|
|
1744
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
|
|
1745
|
+
onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
|
|
1746
|
+
onMouseEnter: () => setHoverPath(entry.path),
|
|
1747
|
+
onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
|
|
1748
|
+
style: base,
|
|
1749
|
+
title: entry.path,
|
|
1750
|
+
},
|
|
1751
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
|
|
1752
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
|
|
1753
|
+
copiedMark(entry.path),
|
|
1754
|
+
(entry.size != null || entry.mtime)
|
|
1755
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } },
|
|
1756
|
+
humanSize(entry.size) + (entry.mtime ? ' ' + fmtTime(entry.mtime) : ''))
|
|
1757
|
+
: null,
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const renderLevel = (dir, depth) => {
|
|
1762
|
+
const level = data[dir]
|
|
1763
|
+
if (level === undefined || (level.loading && !level.entries)) {
|
|
1764
|
+
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 } }, tr('explorer.loading'))
|
|
1765
|
+
}
|
|
1766
|
+
if (level.missing) {
|
|
1767
|
+
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 } }, tr('explorer.dirMissing'))
|
|
1768
|
+
}
|
|
1769
|
+
if (level.error !== undefined) {
|
|
1770
|
+
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)
|
|
1771
|
+
}
|
|
1772
|
+
const items = level.entries || []
|
|
1773
|
+
const soft = level.softError
|
|
1774
|
+
? 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' } }, tr('explorer.softError', { msg: level.softError }))
|
|
1775
|
+
: null
|
|
1776
|
+
if (!items.length) return soft || null
|
|
1777
|
+
return React.createElement('div', { key: dir + ':list' },
|
|
1778
|
+
items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)),
|
|
1779
|
+
soft,
|
|
1780
|
+
)
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
|
|
1784
|
+
const rootLevel = data[root]
|
|
1785
|
+
|
|
1786
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
1787
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
|
|
1788
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: tr('explorer.refresh') }, '↻'),
|
|
1789
|
+
React.createElement('button', {
|
|
1790
|
+
style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
|
|
1791
|
+
onClick: () => { const nm = window.prompt(tr('picker.newDirName', { path: (root || '/') })); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
|
|
1792
|
+
}, tr('explorer.newDir')),
|
|
1793
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, tr('explorer.more')),
|
|
1794
|
+
),
|
|
1795
|
+
status && status.workspace && root
|
|
1796
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, tr('explorer.remoteWs', { path: root, host: (status.host ? tr('explorer.hostTag', { user: status.username, host: status.host }) : '') }))
|
|
1797
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? tr('explorer.noRemoteWs') : tr('explorer.sessionLocal')),
|
|
1798
|
+
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
1799
|
+
open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
|
|
1800
|
+
})) : null,
|
|
1801
|
+
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
|
|
1802
|
+
!root
|
|
1803
|
+
? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, tr('explorer.localSession'))
|
|
1804
|
+
: React.createElement('div', { key: root },
|
|
1805
|
+
React.createElement('div', {
|
|
1806
|
+
role: 'button', tabIndex: 0,
|
|
1807
|
+
onClick: () => setRootOpen((v) => !v),
|
|
1808
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
|
|
1809
|
+
onContextMenu: (e) => onCtx(e, root, true, rootName),
|
|
1810
|
+
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 },
|
|
1811
|
+
title: root,
|
|
1812
|
+
},
|
|
1813
|
+
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
|
|
1814
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
|
|
1815
|
+
),
|
|
1816
|
+
rootLevel && rootOpen ? renderLevel(root, 1) : null,
|
|
1817
|
+
),
|
|
1818
|
+
),
|
|
1819
|
+
// context menu — now rendered via DOM portal in useEffect above
|
|
1820
|
+
null,
|
|
1821
|
+
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? tr('explorer.busy') : '') + err) : null,
|
|
1822
|
+
)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ── Remote file tab (opened by the explorer; live read + explicit edit) ──
|
|
1826
|
+
function RemoteFileTab(props) {
|
|
1827
|
+
const { tab, scope } = props
|
|
1828
|
+
const path = tab.path || ''
|
|
1829
|
+
const [data, setData] = React.useState(null)
|
|
1830
|
+
const [err, setErr] = React.useState('')
|
|
1831
|
+
const [loading, setLoading] = React.useState(true)
|
|
1832
|
+
const [edit, setEdit] = React.useState(false)
|
|
1833
|
+
const [draft, setDraft] = React.useState('')
|
|
1834
|
+
const [saving, setSaving] = React.useState(false)
|
|
1835
|
+
|
|
1836
|
+
const load = () => {
|
|
1837
|
+
if (!path) { setLoading(false); return }
|
|
1838
|
+
let cancelled = false
|
|
1839
|
+
setLoading(true); setErr(''); setData(null); setEdit(false)
|
|
1840
|
+
readRemoteFile(path, 256 * 1024)
|
|
1841
|
+
.then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
|
|
1842
|
+
.catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
|
|
1843
|
+
return () => { cancelled = true }
|
|
1844
|
+
}
|
|
1845
|
+
React.useEffect(load, [path])
|
|
1846
|
+
|
|
1847
|
+
const startEdit = () => {
|
|
1848
|
+
if (!data || data.binary) return
|
|
1849
|
+
setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
|
|
1850
|
+
setEdit(true); setErr('')
|
|
1851
|
+
}
|
|
1852
|
+
const saveRemote = () => {
|
|
1853
|
+
setSaving(true); setErr('')
|
|
1854
|
+
apiRaw('POST', '/dsh-remote/write', { path, content: draft })
|
|
1855
|
+
.then((r) => {
|
|
1856
|
+
if (r.status === 409) {
|
|
1857
|
+
setErr(String((r.data && r.data.error) || tr('file.conflict')))
|
|
1858
|
+
return
|
|
1859
|
+
}
|
|
1860
|
+
if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
|
|
1861
|
+
setEdit(false)
|
|
1862
|
+
load()
|
|
1863
|
+
})
|
|
1864
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1865
|
+
.finally(() => setSaving(false))
|
|
1866
|
+
}
|
|
1867
|
+
const downloadMirror = () => {
|
|
1868
|
+
setSaving(true); setErr('')
|
|
1869
|
+
api('POST', '/dsh-remote/fs', { op: 'download', path })
|
|
1870
|
+
.then((r) => setErr(tr('file.downloaded', { local: (r && r.local ? tr('file.downloadedAt', { path: r.local }) : '') })))
|
|
1871
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1872
|
+
.finally(() => setSaving(false))
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const baseName = path.split(/[\\/]/).pop() || path
|
|
1876
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
1877
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
|
|
1878
|
+
React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
|
|
1879
|
+
React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
|
|
1880
|
+
React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? tr('file.editing') : tr('file.readonly')),
|
|
1881
|
+
edit
|
|
1882
|
+
? React.createElement(React.Fragment, null,
|
|
1883
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? tr('file.saving') : tr('file.saveToRemote')),
|
|
1884
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, tr('file.cancel')))
|
|
1885
|
+
: React.createElement(React.Fragment, null,
|
|
1886
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, tr('file.edit')),
|
|
1887
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, tr('file.download'))),
|
|
1888
|
+
),
|
|
1889
|
+
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
|
|
1890
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, tr('file.loading'))
|
|
1891
|
+
: err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
|
|
1892
|
+
: (data && data.binary
|
|
1893
|
+
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
|
|
1894
|
+
tr('file.binary', { size: (data.size != null ? data.size + ' bytes' : tr('file.unknownSize')) }))
|
|
1895
|
+
: (edit
|
|
1896
|
+
? 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' } })
|
|
1897
|
+
: 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' } },
|
|
1898
|
+
(data && data.content != null) ? data.content : tr('file.empty'),
|
|
1899
|
+
(data && data.truncated)
|
|
1900
|
+
? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, tr('file.truncated'))
|
|
1901
|
+
: null,
|
|
1902
|
+
))),
|
|
1903
|
+
),
|
|
1904
|
+
)
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// ── better-sidebar registration (guarded: plugin may be absent) ────────
|
|
1908
|
+
function registerSidebarIntegration(ctx) {
|
|
1909
|
+
const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
|
|
1910
|
+
if (!bs || typeof bs.registerTab !== 'function') return
|
|
1911
|
+
const disposers = []
|
|
1912
|
+
try {
|
|
1913
|
+
disposers.push(bs.registerTab({
|
|
1914
|
+
id: SIDEBAR_EXPLORER_ID,
|
|
1915
|
+
title: () => tr('explorer.tabTitle'),
|
|
1916
|
+
icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
|
|
1917
|
+
order: 55,
|
|
1918
|
+
single: true,
|
|
1919
|
+
component: (props) => React.createElement(RemoteExplorerTab, props),
|
|
1920
|
+
}))
|
|
1921
|
+
disposers.push(bs.registerTab({
|
|
1922
|
+
id: SIDEBAR_FILE_ID,
|
|
1923
|
+
title: () => tr('explorer.tabTitle'),
|
|
1924
|
+
hidden: true, // not in the + menu; opened from the explorer tree
|
|
1925
|
+
dedupeKey: (tab) => tab.path,
|
|
1926
|
+
component: (props) => React.createElement(RemoteFileTab, props),
|
|
1927
|
+
}))
|
|
1928
|
+
} catch (e) {
|
|
1929
|
+
console.warn('[dsh-remote] better-sidebar integration skipped:', e)
|
|
1930
|
+
disposers.forEach((d) => { try { d() } catch {} })
|
|
1931
|
+
return
|
|
1932
|
+
}
|
|
1933
|
+
let autoOpenedFor = new Set()
|
|
1934
|
+
let disposeAuto = null
|
|
1935
|
+
const tryAutoOpen = () => {
|
|
1936
|
+
if (typeof bs.subscribeState !== 'function') return
|
|
1937
|
+
const snap = bs.getSnapshot && bs.getSnapshot()
|
|
1938
|
+
if (!snap || !snap.sessionId) return
|
|
1939
|
+
if (autoOpenedFor.has(snap.sessionId)) return
|
|
1940
|
+
const sessionId = snap.sessionId
|
|
1941
|
+
// Issue #13: only auto-open the Remote Files tab for sessions that are
|
|
1942
|
+
// genuinely remote (cwd inside a mirror). A plain local session must
|
|
1943
|
+
// not get a remote tab popped open by a machine that happens to be
|
|
1944
|
+
// saved as "current".
|
|
1945
|
+
resolveSessionRemote(sessionId).then((remotePath) => {
|
|
1946
|
+
if (!remotePath || autoOpenedFor.has(sessionId)) return
|
|
1947
|
+
autoOpenedFor.add(sessionId)
|
|
1948
|
+
try {
|
|
1949
|
+
bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: tr('explorer.tabTitle'), path: remotePath })
|
|
1950
|
+
} catch (e2) {
|
|
1951
|
+
console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
|
|
1952
|
+
}
|
|
1953
|
+
})
|
|
1954
|
+
}
|
|
1955
|
+
tryAutoOpen()
|
|
1956
|
+
if (typeof bs.subscribeState === 'function') {
|
|
1957
|
+
disposeAuto = bs.subscribeState(tryAutoOpen)
|
|
1958
|
+
disposers.push(disposeAuto)
|
|
1959
|
+
}
|
|
1960
|
+
return () => disposers.forEach((d) => { try { d() } catch {} })
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function apply(ctx) {
|
|
1964
|
+
const slots = ctx.get('slots')
|
|
1965
|
+
wireLocale(ctx)
|
|
1966
|
+
// Optional integrations must not gate the settings page. In particular,
|
|
1967
|
+
// replacement sidebars may disable the native ui-workspace package.
|
|
1968
|
+
ctx.inject(['sessions'], (inner) => {
|
|
1969
|
+
const sessions = inner.get('sessions')
|
|
1970
|
+
SESSIONS = sessions
|
|
1971
|
+
inner.effect(() => () => {
|
|
1972
|
+
if (SESSIONS === sessions) SESSIONS = null
|
|
1973
|
+
}, 'dsh-remote.sessions')
|
|
1974
|
+
})
|
|
1975
|
+
slots.inject('settings.section', () =>
|
|
1976
|
+
slots.register({ name: 'settings.section', id: 'dsh-remote', order: 40, label: () => tr('settings.title') }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
1977
|
+
)
|
|
1978
|
+
for (const slot of ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow']) {
|
|
1979
|
+
slots.inject(slot, () =>
|
|
1980
|
+
slots.register({ name: slot, id: 'dsh-remote', priority: -100 }, DirPicker),
|
|
1981
|
+
)
|
|
1982
|
+
}
|
|
1983
|
+
ctx.inject(['betterSidebar'], (inner) => {
|
|
1984
|
+
inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
|
|
1985
|
+
})
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
exports.name = name
|
|
1989
|
+
exports.inject = ['slots', 'locale']
|
|
1990
|
+
exports.apply = apply
|
|
1991
|
+
return module.exports
|
|
1992
|
+
},
|
|
1993
|
+
})
|