dsh-remote 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -28,12 +28,499 @@ window.__ModuleLoader__.load({
28
28
  const React = require('react')
29
29
  const name = 'dsh-remote'
30
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
+
31
518
  async function api(method, path, body) {
32
519
  const opts = { method, headers: {} }
33
520
  if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
34
521
  const res = await fetch(path, opts)
35
522
  const data = await res.json().catch(() => ({}))
36
- if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
523
+ if (!res.ok) throw new Error(trHostError((data && (data.error || data.message))) || 'HTTP ' + res.status)
37
524
  return data
38
525
  }
39
526
 
@@ -43,6 +530,8 @@ window.__ModuleLoader__.load({
43
530
  if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
44
531
  const res = await fetch(path, opts)
45
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)
46
535
  return { status: res.status, data }
47
536
  }
48
537
 
@@ -142,10 +631,10 @@ window.__ModuleLoader__.load({
142
631
  })
143
632
  .then((r) => {
144
633
  if (r && r.ok) {
145
- const shellTag = r.platform === 'windows' ? (r.gitBash ? '(Windows · Git Bash)' : '(Windows · 未找到 Git Bash)') : (r.platform === 'posix' ? '(POSIX)' : '')
146
- setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)${shellTag}` : '连接成功'); refresh()
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()
147
636
  }
148
- else { setTst('failing'); setErr((r && r.error) || '连接失败') }
637
+ else { setTst('failing'); setErr((r && r.error) || tr('settings.connFail')) }
149
638
  })
150
639
  .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
151
640
  }
@@ -167,30 +656,30 @@ window.__ModuleLoader__.load({
167
656
  setUpdBusy(true); setUpdMsg('')
168
657
  api('GET', '/dsh-remote/update-check')
169
658
  .then((r) => {
170
- if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
659
+ if (!r || !r.ok) { setUpdMsg((r && r.error) || tr('settings.updateFail')); return }
171
660
  setUpd(r)
172
661
  if (r.updateMode) setUpdMode(r.updateMode)
173
- if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
662
+ if (!quiet) setUpdMsg(r.updateAvailable ? tr('settings.updateAvailable', { version: r.latest }) : tr('settings.updateLatest', { version: r.current }))
174
663
  })
175
664
  .catch((e) => setUpdMsg(String((e && e.message) || e)))
176
665
  .finally(() => setUpdBusy(false))
177
666
  }
178
667
  const applyUpdateNow = () => {
179
668
  if (!upd || !upd.latest) return
180
- if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
669
+ if (!window.confirm(tr('settings.updateConfirm', { version: upd.latest }))) return
181
670
  setUpdBusy(true); setUpdMsg('')
182
671
  api('POST', '/dsh-remote/update-apply', { version: upd.latest })
183
672
  .then((r) => {
184
- if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
185
- else setUpdMsg((r && r.error) || '更新失败')
673
+ if (r && r.ok) setUpdMsg(tr('settings.updateApplied', { to: r.to, from: (r.from || '') }))
674
+ else setUpdMsg((r && r.error) || tr('settings.updateFailed'))
186
675
  checkUpdate(true)
187
676
  })
188
- .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
677
+ .catch((e) => setUpdMsg(tr('settings.updateFailedMsg', { msg: String((e && e.message) || e) })))
189
678
  .finally(() => setUpdBusy(false))
190
679
  }
191
680
  const saveUpdateMode = (mode) => {
192
- setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
193
- api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
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) })))
194
683
  }
195
684
  React.useEffect(() => { checkUpdate(true) }, [])
196
685
 
@@ -218,7 +707,7 @@ window.__ModuleLoader__.load({
218
707
  const save = (action) => {
219
708
  setBusy(true); setErr(''); setMsg('')
220
709
  if (action === 'delete') {
221
- api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { clearForm(); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
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))
222
711
  return
223
712
  }
224
713
  api('POST', '/dsh-remote/machines', {
@@ -227,17 +716,17 @@ window.__ModuleLoader__.load({
227
716
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
228
717
  encryptPassword: form.encryptPassword,
229
718
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
230
- }).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
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))
231
720
  }
232
- const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg('已切换为当前远程机') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
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)) }
233
722
  // Issue #13: "active remote = none" — saved machines stay, but no machine
234
723
  // is current, so local sessions are never dragged into a remote context.
235
- const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
236
- const del = (id) => { if (window.confirm('确定删除这台机器?')) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg('已删除')).catch((e) => setErr(String((e && e.message) || e))) } }
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))) } }
237
726
 
238
727
  const importSsh = () => {
239
728
  api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
240
- .catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
729
+ .catch((e) => setErr(tr('settings.sshReadFail', { msg: String((e && e.message) || e) })))
241
730
  }
242
731
  const applySshEntry = (e) => {
243
732
  setForm({ ...form,
@@ -245,12 +734,12 @@ window.__ModuleLoader__.load({
245
734
  username: e.user || 'root', privateKeyPath: e.identityFile || '',
246
735
  })
247
736
  setSshEntries(null)
248
- setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
737
+ setMsg(tr('settings.filledSsh'))
249
738
  }
250
739
 
251
740
  const setWorkspaceNow = (p) => {
252
741
  api('POST', '/dsh-remote/workspace', { path: p })
253
- .then(() => setMsg('已切换到工作区 ' + p))
742
+ .then(() => setMsg(tr('settings.workspaceSwitched', { path: p })))
254
743
  .catch((e) => setErr(String((e && e.message) || e)))
255
744
  }
256
745
 
@@ -271,11 +760,11 @@ window.__ModuleLoader__.load({
271
760
  React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
272
761
 
273
762
  return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
274
- React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
763
+ React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, tr('settings.title')),
275
764
  React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
276
- '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
765
+ tr('settings.intro')),
277
766
  React.createElement('div', { style: boxS },
278
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
767
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.machines')),
279
768
  machines.length
280
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 } },
281
770
  React.createElement('div', { style: { flex: 1, fontSize: 13 } },
@@ -285,134 +774,134 @@ window.__ModuleLoader__.load({
285
774
  (m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
286
775
  m.proxy && m.proxy.host ? ' ⛳' : '',
287
776
  m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
288
- m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
289
- React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
290
- React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
291
- React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
292
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
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')),
293
782
  currentId
294
783
  ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
295
- '当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
296
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
297
- : React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
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')),
298
787
  ),
299
788
  React.createElement('div', { style: boxS },
300
789
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
301
- React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
302
- React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
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'))),
303
792
  sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
304
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 } },
305
794
  e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
306
- : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
795
+ : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, tr('settings.sshNoEntries')))
307
796
  : null,
308
- row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
309
- row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
310
- row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
311
- row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
312
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
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'),
313
802
  // 高级配置折叠开关
314
803
  React.createElement('button', {
315
804
  type: 'button',
316
805
  onClick: () => setAdvOpen(!advOpen),
317
806
  style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
318
- }, advOpen ? '▲ 收起高级配置' : '▼ 高级配置(私钥 / 跳板机 / agent 等)'),
807
+ }, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
319
808
  advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
320
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
321
- row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
322
- row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
323
- row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
324
- React.createElement('option', { value: '' }, '(默认 accept-new)'),
325
- React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
326
- React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
327
- React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
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'),
328
817
  React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
329
818
  React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
330
- React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
819
+ React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), tr('settings.agent')),
331
820
  React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
332
- React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
821
+ React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), tr('settings.keyboardInteractive')),
333
822
  React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
334
- React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
335
- row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
336
- row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
337
- React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
338
- React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
339
- React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
340
- row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
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'),
341
830
  ) : null,
342
831
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
343
832
  msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
344
- form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
345
- React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? '取消编辑' : '清空'),
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')),
346
835
  React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
347
- tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
348
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
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')),
349
838
  ),
350
839
  ),
351
840
  // 快捷切换工作区(该机器的最近工作区)
352
841
  workspaces.length ? React.createElement('div', { style: boxS },
353
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
842
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.recentWorkspaces')),
354
843
  workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
355
844
  React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
356
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
845
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, tr('settings.switch'))))) : null,
357
846
  // 端口转发面板
358
847
  React.createElement('div', { style: boxS },
359
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
848
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.forwards')),
360
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' } },
361
850
  React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
362
- React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
851
+ React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [auto]' : ''}`),
363
852
  f.active
364
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
365
- : React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
366
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
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,
367
856
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
368
857
  React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
369
- React.createElement('option', { value: 'local' }, '本地→远端'),
370
- React.createElement('option', { value: 'reverse' }, '远端→本地')),
371
- React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
372
- React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
373
- React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
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 } }),
374
863
  React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
375
- React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
376
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
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'))),
377
866
  ),
378
867
  // 审计日志
379
868
  React.createElement('div', { style: boxS },
380
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
869
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.audit')),
381
870
  auditLines.length
382
871
  ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
383
872
  auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
384
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
873
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.auditEmpty')),
385
874
  ),
386
875
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
387
876
  React.createElement('div', { style: boxS },
388
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
877
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, tr('settings.update')),
389
878
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
390
879
  upd
391
880
  ? React.createElement('span', { style: { opacity: 0.85 } },
392
- '当前 v' + upd.current,
393
- upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
394
- : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
395
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
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')),
396
885
  upd && upd.updateAvailable
397
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
886
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, tr('settings.updateNow'))
398
887
  : null,
399
888
  ),
400
889
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
401
- React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
890
+ React.createElement('span', { style: { opacity: 0.8 } }, tr('settings.updateMode')),
402
891
  ['manual', 'auto', 'off'].map((m) =>
403
892
  React.createElement('button', {
404
893
  key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
405
894
  onClick: () => saveUpdateMode(m), disabled: updBusy,
406
- }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
895
+ }, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
407
896
  ),
408
897
  ),
409
- updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
898
+ updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
410
899
  ),
411
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 } },
412
- React.createElement('span', {}, '觉得好用?'),
413
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
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')),
414
903
  React.createElement('span', {}, '·'),
415
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
904
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, tr('settings.feedback')),
416
905
  React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
417
906
  ),
418
907
  )
@@ -440,7 +929,7 @@ window.__ModuleLoader__.load({
440
929
  function breadcrumb(active, cur, win, jumpTo) {
441
930
  const raw = String(cur || '')
442
931
  const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
443
- const rootLabel = isWinPath ? '此电脑' : '/'
932
+ const rootLabel = isWinPath ? tr('picker.thisPc') : '/'
444
933
  const crumbs = []
445
934
  const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
446
935
  crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
@@ -467,7 +956,7 @@ window.__ModuleLoader__.load({
467
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))
468
957
  }
469
958
  }
470
- if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? ' 选择驱动器' : ''))
959
+ if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? tr('picker.chooseDrive') : ''))
471
960
  return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
472
961
  }
473
962
 
@@ -581,10 +1070,10 @@ window.__ModuleLoader__.load({
581
1070
  // browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
582
1071
  // → 打开内置本机目录浏览器。
583
1072
  else if (r && r.kind === 'browse') loadLocal('')
584
- else if (r && r.cancelled) { setLoading(false); setErr('已取消选择') }
585
- else { setLoading(false); setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径') }
1073
+ else if (r && r.cancelled) { setLoading(false); setErr(tr('picker.cancelled')) }
1074
+ else { setLoading(false); setErr((r && r.error) || tr('picker.noSysPicker')) }
586
1075
  })
587
- .catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径') })
1076
+ .catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + tr('picker.typePath')) })
588
1077
  }
589
1078
 
590
1079
  // 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
@@ -724,14 +1213,14 @@ window.__ModuleLoader__.load({
724
1213
  const pickHome = () => {
725
1214
  api('POST', '/dsh-remote/home').then((r) => {
726
1215
  if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
727
- else setErr((r && (r.hint || r.error)) || '无法解析主目录')
1216
+ else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
728
1217
  }).catch((e) => setErr(String((e && e.message) || e)))
729
1218
  }
730
1219
 
731
1220
  const mkdirHere = () => {
732
1221
  const last = levels && levels.length ? levels[levels.length - 1] : null
733
1222
  const base = (last && last.path) || '/'
734
- const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
1223
+ const nm = window.prompt(tr('picker.newDirName', { path: base }))
735
1224
  if (!nm || !nm.trim()) return
736
1225
  const sep = base.includes('\\') ? '\\' : '/'
737
1226
  const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
@@ -770,10 +1259,10 @@ window.__ModuleLoader__.load({
770
1259
  },
771
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() },
772
1261
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
773
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, '回上一级 ▴'),
774
- cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: cur.home }, '🏠') : null,
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,
775
1264
  React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
776
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, '关闭 ✕'),
1265
+ React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, tr('picker.closeBtn')),
777
1266
  ),
778
1267
  // 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
779
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 } },
@@ -789,23 +1278,23 @@ window.__ModuleLoader__.load({
789
1278
  }),
790
1279
  ) : null,
791
1280
  React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
792
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
1281
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, tr('picker.loading'))
793
1282
  : (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
794
- key: it.path || i, title: '进入 ' + it.name,
1283
+ key: it.path || i, title: tr('picker.enterDir', { name: it.name }),
795
1284
  onClick: () => loadLocal(it.path),
796
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 },
797
1286
  },
798
1287
  React.createElement('span', null, '📁'),
799
1288
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
800
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(没有子目录 — 可直接选用当前目录)')),
801
- cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, '(条目过多已截断)') : null,
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,
802
1291
  ),
803
1292
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
804
1293
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
805
- React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: '新建文件夹', style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
806
- React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, '新建'),
807
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + cur.path),
808
- React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, '选用此路径'),
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')),
809
1298
  ),
810
1299
  ),
811
1300
  )
@@ -813,21 +1302,21 @@ window.__ModuleLoader__.load({
813
1302
 
814
1303
  function renderDirPopup() {
815
1304
  if (!levels || !levels.length) {
816
- return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
1305
+ return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? tr('picker.loading') : tr('picker.readingRoot')))
817
1306
  }
818
1307
  const last = levels[levels.length - 1]
819
1308
  const entries = last.all || []
820
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' } },
821
1310
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
822
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, '回上一级 ▴'),
1311
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, tr('picker.back')),
823
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 }))),
824
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
825
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
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')),
826
1315
  ),
827
1316
  React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
828
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
1317
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, tr('picker.loading'))
829
1318
  : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
830
- key: i, title: it.drive ? '进入驱动器 ' + it.name : (it.dir ? '进入 ' + it.name : '文件: ' + it.name),
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 })),
831
1320
  onClick: it.dir ? () => enterDir(it) : undefined,
832
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 },
833
1322
  },
@@ -836,11 +1325,11 @@ window.__ModuleLoader__.load({
836
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' })))),
837
1326
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
838
1327
  React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
839
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
1328
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, tr('picker.emptyDir'))),
840
1329
  ),
841
1330
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
842
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + (last.path || (win ? '(驱动器根)' : '/'))),
843
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
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')),
844
1333
  ),
845
1334
  )
846
1335
  }
@@ -850,49 +1339,49 @@ window.__ModuleLoader__.load({
850
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() } },
851
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() },
852
1341
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
853
- React.createElement('div', null, '选择工作目录'),
854
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
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')),
855
1344
  ),
856
1345
  React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
857
- tabBtn('local', '本机'),
858
- tabBtn('remote', '远程'),
1346
+ tabBtn('local', tr('picker.tabLocal')),
1347
+ tabBtn('remote', tr('picker.tabRemote')),
859
1348
  ),
860
1349
  tab === 'local'
861
1350
  ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
862
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。'),
1351
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, tr('picker.localIntro')),
863
1352
  React.createElement('div', { style: { display: 'flex', gap: 6 } },
864
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
865
- React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
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')),
866
1355
  ),
867
- React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
1356
+ React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? tr('picker.opening') : tr('picker.openSysPicker')),
868
1357
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
869
1358
  localPop ? renderLocalPopup() : null,
870
1359
  )
871
1360
  : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
872
1361
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
873
- React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
1362
+ React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
874
1363
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
875
1364
  React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
876
- (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
1365
+ (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : tr('picker.chooseMachine')),
877
1366
  ),
878
1367
  mOpen
879
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 } },
880
1369
  machines.length
881
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 + ')'))
882
- : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
1371
+ : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, tr('picker.noMachines')),
883
1372
  )
884
1373
  : null,
885
1374
  ),
886
1375
  ),
887
1376
  // 最近工作区快捷入口
888
1377
  recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
889
- React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
1378
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, tr('picker.recent')),
890
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,
891
1380
  React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
892
1381
  React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
893
- React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? '输入远程路径,如 C:\\Users\\…(自动补全)' : '输入远程路径(自动补全)') : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
894
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, '浏览…'),
895
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
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')),
896
1385
  ),
897
1386
  (suggestOpen && suggest.length)
898
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)' } },
@@ -903,15 +1392,15 @@ window.__ModuleLoader__.load({
903
1392
  popOpen ? renderDirPopup() : null,
904
1393
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
905
1394
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
906
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
907
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
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')),
908
1397
  ),
909
1398
  ),
910
- (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
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,
911
1400
  React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
912
1401
  React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
913
1402
  React.createElement('span', {}, '·'),
914
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
1403
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, 'Feedback / report issue'),
915
1404
  ),
916
1405
  ),
917
1406
  )
@@ -956,13 +1445,13 @@ window.__ModuleLoader__.load({
956
1445
 
957
1446
  function readRemoteFile(path, maxBytes) {
958
1447
  return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
959
- throw new Error('远程读取失败: ' + ((e && e.message) || e))
1448
+ throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
960
1449
  })
961
1450
  }
962
1451
 
963
1452
  function listRemoteDir(path) {
964
1453
  return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
965
- throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
1454
+ throw new Error(tr('file.listFail', { msg: ((e && e.message) || e) }))
966
1455
  })
967
1456
  }
968
1457
 
@@ -1070,7 +1559,7 @@ window.__ModuleLoader__.load({
1070
1559
  // show a clear disconnected state instead of firing ls requests
1071
1560
  // that all fail with AggregateError / 500 in a loop.
1072
1561
  if (!connected) {
1073
- storeLevel(r, { error: '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)' })
1562
+ storeLevel(r, { error: tr('explorer.notConnected') })
1074
1563
  return
1075
1564
  }
1076
1565
  if (!sameRoot) {
@@ -1140,12 +1629,12 @@ window.__ModuleLoader__.load({
1140
1629
  d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
1141
1630
  return d
1142
1631
  }
1143
- box.appendChild(mkItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
1144
- box.appendChild(mkItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })))
1145
- box.appendChild(mkItem('📋 复制相对地址', false, () => copyPath(relPath(menu.path), menu.path)))
1146
- box.appendChild(mkItem('📋 复制绝对地址', false, () => copyPath(menu.path, menu.path)))
1147
- box.appendChild(mkItem('✏ 重命名', false, () => { const nm = window.prompt('重命名为(新名字)', menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
1148
- box.appendChild(mkItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => { if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true) }))
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) }))
1149
1638
  host.appendChild(box)
1150
1639
  document.body.appendChild(host)
1151
1640
  const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
@@ -1207,7 +1696,7 @@ window.__ModuleLoader__.load({
1207
1696
  }
1208
1697
  /** Trailing「已复制」feedback for a row (after a successful copy). */
1209
1698
  const copiedMark = (path) => (copiedPath === path
1210
- ? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, '已复制')
1699
+ ? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, tr('explorer.copied'))
1211
1700
  : null)
1212
1701
 
1213
1702
  // One tree row (dir toggles via the framework's onToggleDir; file opens).
@@ -1265,17 +1754,17 @@ window.__ModuleLoader__.load({
1265
1754
  const renderLevel = (dir, depth) => {
1266
1755
  const level = data[dir]
1267
1756
  if (level === undefined || (level.loading && !level.entries)) {
1268
- return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
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'))
1269
1758
  }
1270
1759
  if (level.missing) {
1271
- return React.createElement('div', { key: dir + ':missing', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '目录不存在或已被删除')
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'))
1272
1761
  }
1273
1762
  if (level.error !== undefined) {
1274
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)
1275
1764
  }
1276
1765
  const items = level.entries || []
1277
1766
  const soft = level.softError
1278
- ? React.createElement('div', { key: dir + ':softerr', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 11, opacity: 0.6, color: '#e6c07b' } }, '⚠ ' + level.softError + '(显示缓存,可点 ↻ 刷新)')
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 }))
1279
1768
  : null
1280
1769
  if (!items.length) return soft || null
1281
1770
  return React.createElement('div', { key: dir + ':list' },
@@ -1289,22 +1778,22 @@ window.__ModuleLoader__.load({
1289
1778
 
1290
1779
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1291
1780
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
1292
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
1781
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: tr('explorer.refresh') }, '↻'),
1293
1782
  React.createElement('button', {
1294
1783
  style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
1295
- onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
1296
- }, '新建目录'),
1297
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
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')),
1298
1787
  ),
1299
1788
  status && status.workspace && root
1300
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
1301
- : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
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')),
1302
1791
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
1303
1792
  open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
1304
1793
  })) : null,
1305
1794
  React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
1306
1795
  !root
1307
- ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
1796
+ ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, tr('explorer.localSession'))
1308
1797
  : React.createElement('div', { key: root },
1309
1798
  React.createElement('div', {
1310
1799
  role: 'button', tabIndex: 0,
@@ -1322,7 +1811,7 @@ window.__ModuleLoader__.load({
1322
1811
  ),
1323
1812
  // context menu — now rendered via DOM portal in useEffect above
1324
1813
  null,
1325
- err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
1814
+ err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? tr('explorer.busy') : '') + err) : null,
1326
1815
  )
1327
1816
  }
1328
1817
 
@@ -1358,7 +1847,7 @@ window.__ModuleLoader__.load({
1358
1847
  apiRaw('POST', '/dsh-remote/write', { path, content: draft })
1359
1848
  .then((r) => {
1360
1849
  if (r.status === 409) {
1361
- setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
1850
+ setErr(String((r.data && r.data.error) || tr('file.conflict')))
1362
1851
  return
1363
1852
  }
1364
1853
  if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
@@ -1371,7 +1860,7 @@ window.__ModuleLoader__.load({
1371
1860
  const downloadMirror = () => {
1372
1861
  setSaving(true); setErr('')
1373
1862
  api('POST', '/dsh-remote/fs', { op: 'download', path })
1374
- .then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
1863
+ .then((r) => setErr(tr('file.downloaded', { local: (r && r.local ? tr('file.downloadedAt', { path: r.local }) : '') })))
1375
1864
  .catch((e) => setErr(String((e && e.message) || e)))
1376
1865
  .finally(() => setSaving(false))
1377
1866
  }
@@ -1381,27 +1870,27 @@ window.__ModuleLoader__.load({
1381
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 } },
1382
1871
  React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
1383
1872
  React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
1384
- React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
1873
+ React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? tr('file.editing') : tr('file.readonly')),
1385
1874
  edit
1386
1875
  ? React.createElement(React.Fragment, null,
1387
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
1388
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
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')))
1389
1878
  : React.createElement(React.Fragment, null,
1390
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
1391
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
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'))),
1392
1881
  ),
1393
1882
  React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
1394
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
1883
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, tr('file.loading'))
1395
1884
  : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
1396
1885
  : (data && data.binary
1397
1886
  ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
1398
- '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
1887
+ tr('file.binary', { size: (data.size != null ? data.size + ' bytes' : tr('file.unknownSize')) }))
1399
1888
  : (edit
1400
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' } })
1401
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' } },
1402
- (data && data.content != null) ? data.content : '(空文件)',
1891
+ (data && data.content != null) ? data.content : tr('file.empty'),
1403
1892
  (data && data.truncated)
1404
- ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
1893
+ ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, tr('file.truncated'))
1405
1894
  : null,
1406
1895
  ))),
1407
1896
  ),
@@ -1416,7 +1905,7 @@ window.__ModuleLoader__.load({
1416
1905
  try {
1417
1906
  disposers.push(bs.registerTab({
1418
1907
  id: SIDEBAR_EXPLORER_ID,
1419
- title: () => '远程文件',
1908
+ title: () => tr('explorer.tabTitle'),
1420
1909
  icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
1421
1910
  order: 55,
1422
1911
  single: true,
@@ -1424,7 +1913,7 @@ window.__ModuleLoader__.load({
1424
1913
  }))
1425
1914
  disposers.push(bs.registerTab({
1426
1915
  id: SIDEBAR_FILE_ID,
1427
- title: () => '远程文件',
1916
+ title: () => tr('explorer.tabTitle'),
1428
1917
  hidden: true, // not in the + menu; opened from the explorer tree
1429
1918
  dedupeKey: (tab) => tab.path,
1430
1919
  component: (props) => React.createElement(RemoteFileTab, props),
@@ -1450,7 +1939,7 @@ window.__ModuleLoader__.load({
1450
1939
  if (!remotePath || autoOpenedFor.has(sessionId)) return
1451
1940
  autoOpenedFor.add(sessionId)
1452
1941
  try {
1453
- bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
1942
+ bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: tr('explorer.tabTitle'), path: remotePath })
1454
1943
  } catch (e2) {
1455
1944
  console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
1456
1945
  }
@@ -1467,10 +1956,11 @@ window.__ModuleLoader__.load({
1467
1956
  function apply(ctx) {
1468
1957
  const slots = ctx.get('slots')
1469
1958
  if (slots === undefined) return
1959
+ wireLocale(ctx)
1470
1960
  WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
1471
1961
  SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
1472
1962
  slots.inject('settings.section', () =>
1473
- slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
1963
+ slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => tr('settings.title') }, () => React.createElement(RemoteWorkspacePage, null)),
1474
1964
  )
1475
1965
  slots.inject(
1476
1966
  'conversation.hero.workspace.directoryFlow',