dsh-remote 0.8.12 → 0.8.14

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