@goodandready/dsh-remote-workspace 0.3.7 → 0.3.9

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
@@ -77,10 +77,10 @@ window.__ModuleLoader__.load({
77
77
  .drw-tab-btn.active{background:var(--dsw-alias-bg-layer-4);border-color:var(--dsw-alias-state-brand-primary);color:var(--dsw-alias-state-brand-primary)}
78
78
 
79
79
  .drw-term-window{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;display:flex;flex-direction:column;height:340px;overflow:hidden;box-shadow:0 4px 14px var(--dsw-alias-border-l1)}
80
- .drw-term-header{background:var(--dsw-alias-bg-layer-2);padding:6px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;color:var(--dsw-alias-label-secondary);font-family:monospace}
81
- .drw-term-body{flex:1;padding:10px 12px;overflow-y:auto;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px;line-height:1.45;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all}
80
+ .drw-term-header{background:var(--dsw-alias-bg-layer-2);padding:6px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12px;color:var(--dsw-alias-label-secondary);font-family:var(--drw-terminal-font,monospace)}
81
+ .drw-term-body{flex:1;padding:10px 12px;overflow-y:auto;font-family:var(--drw-terminal-font,ui-monospace,SFMono-Regular,Consolas,monospace);font-size:12.5px;line-height:1.45;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all}
82
82
  .drw-term-footer{display:flex;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l2);padding:6px}
83
- .drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link-primary, var(--dsw-alias-label-primary));font-family:monospace;font-size:13px;padding:4px 8px;outline:none}
83
+ .drw-term-input{flex:1;background:transparent;border:none;color:var(--dsw-alias-link-primary, var(--dsw-alias-label-primary));font-family:var(--drw-terminal-font,monospace);font-size:13px;padding:4px 8px;outline:none}
84
84
 
85
85
  .drw-explorer-box{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:12px;display:flex;flex-direction:column;gap:10px}
86
86
  .drw-explorer-nav{display:flex;align-items:center;gap:8px;font-family:monospace;font-size:12px;background:var(--dsw-alias-bg-layer-1);padding:6px 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2)}
@@ -109,14 +109,51 @@ window.__ModuleLoader__.load({
109
109
  .drw-alert-warning{background:var(--dsw-alias-state-warning-bg, var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-state-warning-primary);border:1px solid var(--dsw-alias-state-warning-border, var(--dsw-alias-border-l2))}
110
110
 
111
111
 
112
+ .drw-ws{min-height:100%;box-sizing:border-box;color:var(--dsw-alias-label-primary)}
113
+ .drw-ws-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:12px 16px;position:sticky;top:0;background:var(--dsw-alias-bg-base,#171717);z-index:2}
114
+ .drw-ws-panel{padding:0 16px 24px}
115
+ .drw-ws-panel[hidden]{display:none!important}
116
+ [data-pane=conversation],[class*=centerCol]{position:relative!important}
117
+ [data-drw-view]{display:none;position:absolute;inset:0;z-index:60;overflow:auto;background:var(--dsw-alias-bg-base,#171717)}
118
+ html[data-drw-active] [data-drw-view]{display:block!important}
119
+ html[data-drw-active] [data-pane=conversation] > *:not([data-drw-view]),html[data-drw-active] [class*=centerCol] > *:not([data-drw-view]){display:none!important}
120
+ .drw-entry-clone{display:flex;align-items:center;gap:8px}
121
+ .drw-entry-icon{display:flex;align-items:center;justify-content:center;width:16px;height:16px;flex:none}
122
+ [data-dsh-frame][data-sidebar-collapsed] .drw-entry-label{display:none}
123
+
112
124
  `
113
125
  document.head.appendChild(style)
114
126
  }
115
127
 
116
128
  const en = {
117
129
  title: 'Remote Workspace',
130
+ navLabel: 'Remote',
131
+ wsBack: 'Back',
132
+ wsHosts: 'Hosts',
133
+ wsTerminal: 'Terminal',
134
+ wsFiles: 'Files',
135
+ wsDocker: 'Containers',
136
+ wsTunnels: 'Tunnels',
137
+ wsCluster: 'Cluster',
118
138
  subtitle: 'Native SSH connections, SFTP file access, 3-way mirror sync, and port forwarding tunnels.',
119
139
  status: 'Ready',
140
+ actionFailed: 'The request failed.',
141
+ versionUnknown: 'Version unknown',
142
+ updaterTitle: 'Plugin update',
143
+ currentVersion: 'Installed version',
144
+ latestVersion: 'Latest on npm',
145
+ checkUpdate: 'Check for updates',
146
+ checking: 'Checking...',
147
+ updateNow: 'Update now',
148
+ updating: 'Updating...',
149
+ upToDate: 'Up to date',
150
+ updateAvailable: 'Update available',
151
+ restartRequired: 'The update is installed. Restart DSH to use it.',
152
+ updateFailed: 'Update failed',
153
+ statusLoading: 'Checking connection',
154
+ statusEmpty: 'No active host',
155
+ statusReady: 'Host ready',
156
+ statusError: 'Status unavailable',
120
157
 
121
158
  tabProfiles: '🖥️ Host Profiles',
122
159
 
@@ -124,6 +161,11 @@ window.__ModuleLoader__.load({
124
161
 
125
162
  tabEnv: '🗃️ .env Manager',
126
163
  btnImportSsh: 'Import ~/.ssh/config',
164
+ importAdded: 'Imported {count} hosts.',
165
+ skipWildcard: 'pattern is not a single host',
166
+ skipMatch: 'Match blocks are not imported',
167
+ skipDuplicate: 'already imported',
168
+ skipMissing: 'included file was not found',
127
169
  envTitle: 'Remote Environment Variables (.env)',
128
170
  envKey: 'Key',
129
171
  envVal: 'Value',
@@ -208,6 +250,47 @@ window.__ModuleLoader__.load({
208
250
  fAuthType: 'Authentication Method',
209
251
  fAuthKey: 'SSH Private Key',
210
252
  fAuthPass: 'Password',
253
+ fEnvironment: 'Environment',
254
+ fLocation: 'Location',
255
+ fTags: 'Tags',
256
+ fDescription: 'Description',
257
+ groupFlat: 'Flat',
258
+ groupEnvironment: 'By environment',
259
+ groupTag: 'By tag',
260
+ groupUngrouped: 'Ungrouped',
261
+ testGroup: 'Test group',
262
+ groupOnline: 'online, {latency} ms',
263
+ groupOffline: 'offline: {err}',
264
+ tabProfiles: 'Hosts',
265
+ tabCluster: 'Cluster',
266
+ tabFiles: 'Files',
267
+ btnUpload: 'Upload',
268
+ btnDownload: 'Download',
269
+ transferProgress: '{name}: {percent}%',
270
+ transferFailed: 'Transfer failed: {name}',
271
+ transferCancel: 'Cancel',
272
+ clusterTitle: 'Run on a group',
273
+ clusterCommand: 'Command',
274
+ clusterRun: 'Run',
275
+ clusterRunning: 'Running...',
276
+ clusterCount: '{count} hosts configured',
277
+ clusterOk: 'ok',
278
+ clusterFail: 'failed',
279
+ fAuthAgent: 'SSH Agent',
280
+ authPromptTitle: 'Sign-in code',
281
+ authPromptCode: 'One-time code',
282
+ authPromptSubmit: 'Continue',
283
+ fAuthKeyShort: 'key',
284
+ fAuthPassShort: 'pwd',
285
+ fAuthAgentShort: 'agent',
286
+ fAgentPath: 'Agent socket',
287
+ fAgentPathHint: 'Empty uses SSH_AUTH_SOCK. Use pageant on Windows.',
288
+ fJumpHosts: 'Jump hosts',
289
+ fJumpHostsHint: 'Profile ids in order, separated by commas. The first host is the entry bastion.',
290
+ fProxyCommand: 'Proxy command',
291
+ fProxyCommandHint: 'OpenSSH ProxyCommand. Tokens: %h host, %p port, %r user, %n profile name.',
292
+ fTerminalFont: 'Terminal font',
293
+ fTerminalFontHint: 'Font family for the terminal. Leave empty to keep the default monospace stack.',
211
294
  fKeyPath: 'Private Key Path',
212
295
  fKeyPathHint: 'Path to ~/.ssh/id_rsa or /home/user/.ssh/id_ed25519',
213
296
  fPassphrase: 'Key Passphrase (optional)',
@@ -254,13 +337,32 @@ window.__ModuleLoader__.load({
254
337
 
255
338
  const zh = {
256
339
  title: '远程开发工作区',
340
+ navLabel: '远程',
341
+ wsBack: '返回',
342
+ wsHosts: '主机',
343
+ wsTerminal: '终端',
344
+ wsFiles: '文件',
345
+ wsDocker: '容器',
346
+ wsTunnels: '隧道',
347
+ wsCluster: '集群',
257
348
  subtitle: '支持原生 SSH 连接、SFTP 文件访问、3-way 镜像同步与端口转发隧道。',
258
349
  status: '就绪',
350
+ actionFailed: '请求失败。',
351
+ versionUnknown: '版本未知',
352
+ statusLoading: '正在检查连接',
353
+ statusEmpty: '没有活动主机',
354
+ statusReady: '主机已就绪',
355
+ statusError: '状态不可用',
259
356
 
260
357
  tabProfiles: '🖥️ 主机配置',
261
358
  tabContainers: '🐳 容器管理',
262
359
  tabEnv: '🗃️ 环境变量',
263
360
  btnImportSsh: '导入 ~/.ssh/config',
361
+ importAdded: '已导入 {count} 台主机。',
362
+ skipWildcard: '通配主机不会导入',
363
+ skipMatch: '不导入 Match 块',
364
+ skipDuplicate: '已经导入',
365
+ skipMissing: '找不到被包含的文件',
264
366
  envTitle: '远程环境变量配置 (.env)',
265
367
  envKey: '变量名',
266
368
  envVal: '变量值',
@@ -356,6 +458,47 @@ window.__ModuleLoader__.load({
356
458
  fAuthType: '身份认证方式',
357
459
  fAuthKey: 'SSH 私钥',
358
460
  fAuthPass: '账户密码',
461
+ fEnvironment: '环境',
462
+ fLocation: '位置',
463
+ fTags: '标签',
464
+ fDescription: '说明',
465
+ groupFlat: '平铺',
466
+ groupEnvironment: '按环境',
467
+ groupTag: '按标签',
468
+ groupUngrouped: '未分组',
469
+ testGroup: '测试本组',
470
+ groupOnline: '在线,{latency} 毫秒',
471
+ groupOffline: '离线:{err}',
472
+ tabProfiles: '主机',
473
+ tabCluster: '集群',
474
+ tabFiles: '文件',
475
+ btnUpload: '上传',
476
+ btnDownload: '下载',
477
+ transferProgress: '{name}:{percent}%',
478
+ transferFailed: '传输失败:{name}',
479
+ transferCancel: '取消',
480
+ clusterTitle: '在一组主机上运行',
481
+ clusterCommand: '命令',
482
+ clusterRun: '运行',
483
+ clusterRunning: '正在运行...',
484
+ clusterCount: '已配置 {count} 台主机',
485
+ clusterOk: '成功',
486
+ clusterFail: '失败',
487
+ fAuthAgent: 'SSH 代理',
488
+ authPromptTitle: '登录验证码',
489
+ authPromptCode: '一次性验证码',
490
+ authPromptSubmit: '继续',
491
+ fAuthKeyShort: '密钥',
492
+ fAuthPassShort: '密码',
493
+ fAuthAgentShort: '代理',
494
+ fAgentPath: '代理套接字',
495
+ fAgentPathHint: '留空则使用 SSH_AUTH_SOCK。Windows 上填写 pageant。',
496
+ fJumpHosts: '跳板主机',
497
+ fJumpHostsHint: '按经过顺序填写配置编号,用逗号分隔。第一台是入口堡垒机。',
498
+ fProxyCommand: '代理命令',
499
+ fProxyCommandHint: 'OpenSSH ProxyCommand。令牌:%h 主机,%p 端口,%r 用户,%n 配置名称。',
500
+ fTerminalFont: '终端字体',
501
+ fTerminalFontHint: '终端使用的字体族。留空则使用默认等宽字体。',
359
502
  fKeyPath: '私钥文件路径',
360
503
  fKeyPathHint: '例如 ~/.ssh/id_rsa 或 /home/user/.ssh/id_ed25519',
361
504
  fPassphrase: '私钥口令 (可选)',
@@ -694,6 +837,13 @@ window.__ModuleLoader__.load({
694
837
 
695
838
  // --- Sub-component: Web Terminal Tab ---
696
839
 
840
+ function applyTerminalFont(font) {
841
+ if (typeof document === 'undefined') return
842
+ const value = String(font || '').trim()
843
+ if (value) document.documentElement.style.setProperty('--drw-terminal-font', value)
844
+ else document.documentElement.style.removeProperty('--drw-terminal-font')
845
+ }
846
+
697
847
  function TerminalTab(props) {
698
848
  const { activeProfile, t } = props
699
849
  const [connected, setConnected] = React.useState(false)
@@ -879,7 +1029,7 @@ window.__ModuleLoader__.load({
879
1029
  ? React.createElement(
880
1030
  'form',
881
1031
  { className: 'drw-term-footer', onSubmit: sendCommand },
882
- React.createElement('span', { style: { color: 'var(--dsw-alias-link-primary)', padding: '4px 6px', fontFamily: 'monospace' } }, '$'),
1032
+ React.createElement('span', { style: { color: 'var(--dsw-alias-link-primary)', padding: '4px 6px', fontFamily: 'inherit' } }, '$'),
883
1033
  React.createElement('input', {
884
1034
  className: 'drw-term-input',
885
1035
  type: 'text',
@@ -910,6 +1060,8 @@ window.__ModuleLoader__.load({
910
1060
  const [editContent, setEditContent] = React.useState('')
911
1061
  const [previewLoading, setPreviewLoading] = React.useState(false)
912
1062
  const [saveStatus, setSaveStatus] = React.useState(null)
1063
+ const [transfer, setTransfer] = React.useState(null)
1064
+ const transferAbort = React.useRef(null)
913
1065
 
914
1066
  const loadDir = async (dirPath) => {
915
1067
  if (!activeProfile) return
@@ -967,6 +1119,75 @@ window.__ModuleLoader__.load({
967
1119
  setPreviewLoading(false)
968
1120
  }
969
1121
 
1122
+
1123
+ const filePathFor = (entry) => currentPath.endsWith('/') ? currentPath + entry.filename : currentPath + '/' + entry.filename
1124
+
1125
+ const downloadFile = async (entry, event) => {
1126
+ if (event) event.stopPropagation()
1127
+ if (!activeProfile) return
1128
+ const ctrl = new AbortController()
1129
+ transferAbort.current = () => ctrl.abort()
1130
+ setTransfer({ name: entry.filename, percent: 0 })
1131
+ try {
1132
+ const params = new URLSearchParams({ profileId: activeProfile.id, filePath: filePathFor(entry) })
1133
+ const res = await fetch('/dsh-remote-workspace/file/download?' + params.toString(), { signal: ctrl.signal })
1134
+ if (!res.ok || !res.body) {
1135
+ setTransfer({ name: entry.filename, error: true })
1136
+ return
1137
+ }
1138
+ const total = Number(res.headers.get('content-length')) || 0
1139
+ const reader = res.body.getReader()
1140
+ const chunks = []
1141
+ let loaded = 0
1142
+ while (true) {
1143
+ const step = await reader.read()
1144
+ if (step.done) break
1145
+ chunks.push(step.value)
1146
+ loaded += step.value.length
1147
+ setTransfer({ name: entry.filename, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : 0 })
1148
+ }
1149
+ const blob = new Blob(chunks)
1150
+ const link = document.createElement('a')
1151
+ link.href = URL.createObjectURL(blob)
1152
+ link.download = entry.filename
1153
+ link.click()
1154
+ URL.revokeObjectURL(link.href)
1155
+ setTransfer(null)
1156
+ } catch (err) {
1157
+ setTransfer({ name: entry.filename, error: err && err.name === 'AbortError' })
1158
+ }
1159
+ }
1160
+
1161
+ const uploadFile = () => {
1162
+ if (!activeProfile) return
1163
+ const input = document.createElement('input')
1164
+ input.type = 'file'
1165
+ input.onchange = () => {
1166
+ const file = input.files && input.files[0]
1167
+ if (!file) return
1168
+ const remotePath = currentPath.endsWith('/') ? currentPath + file.name : currentPath + '/' + file.name
1169
+ const xhr = new XMLHttpRequest()
1170
+ const params = new URLSearchParams({ profileId: activeProfile.id, filePath: remotePath })
1171
+ xhr.open('POST', '/dsh-remote-workspace/file/upload?' + params.toString())
1172
+ transferAbort.current = () => xhr.abort()
1173
+ setTransfer({ name: file.name, percent: 0 })
1174
+ xhr.upload.onprogress = (event) => {
1175
+ if (event.lengthComputable) {
1176
+ setTransfer({ name: file.name, percent: Math.min(100, Math.round((event.loaded / event.total) * 100)) })
1177
+ }
1178
+ }
1179
+ xhr.onload = () => {
1180
+ setTransfer(null)
1181
+ if (xhr.status >= 200 && xhr.status < 300) loadDir(currentPath)
1182
+ else setTransfer({ name: file.name, error: true })
1183
+ }
1184
+ xhr.onerror = () => setTransfer({ name: file.name, error: true })
1185
+ xhr.onabort = () => setTransfer({ name: file.name, error: true })
1186
+ xhr.send(file)
1187
+ }
1188
+ input.click()
1189
+ }
1190
+
970
1191
  const handleSaveFile = async () => {
971
1192
  if (!previewFile || !activeProfile) return
972
1193
  setSaveStatus('saving')
@@ -1004,8 +1225,15 @@ window.__ModuleLoader__.load({
1004
1225
  disabled: loading
1005
1226
  },
1006
1227
  loading ? 'Refreshing...' : '🔄 Refresh'
1007
- )
1228
+ ),
1229
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: uploadFile }, t('btnUpload'))
1008
1230
  ),
1231
+ transfer
1232
+ ? React.createElement('div', { className: 'drw-hint', role: 'status' },
1233
+ transfer.error ? t('transferFailed', { name: transfer.name }) : t('transferProgress', { name: transfer.name, percent: transfer.percent || 0 }),
1234
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => transferAbort.current && transferAbort.current() }, t('transferCancel'))
1235
+ )
1236
+ : null,
1009
1237
  React.createElement(
1010
1238
  'div',
1011
1239
  { className: 'drw-explorer-nav' },
@@ -1046,7 +1274,8 @@ window.__ModuleLoader__.load({
1046
1274
  'div',
1047
1275
  { className: 'drw-file-meta' },
1048
1276
  e.size !== undefined ? React.createElement('span', null, `${Math.round(e.size / 1024)} KB`) : null,
1049
- e.permissions ? React.createElement('span', { style: { opacity: 0.6 } }, e.permissions) : null
1277
+ e.permissions ? React.createElement('span', { style: { opacity: 0.6 } }, e.permissions) : null,
1278
+ e.isDirectory ? null : React.createElement('button', { type: 'button', className: 'drw-btn', onClick: (event) => downloadFile(e, event) }, t('btnDownload'))
1050
1279
  )
1051
1280
  )
1052
1281
  )
@@ -1135,237 +1364,72 @@ window.__ModuleLoader__.load({
1135
1364
  )
1136
1365
  }
1137
1366
 
1138
- function SettingsView(props) {
1139
- const [activeTab, setActiveTab] = React.useState('profiles')
1140
- const [healthData, setHealthData] = React.useState(null)
1141
- const [autoSync, setAutoSync] = React.useState(false)
1142
-
1143
- const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
1144
- const [profiles, setProfiles] = React.useState([])
1145
- const [activeId, setActiveId] = React.useState(null)
1146
- const [tunnels, setTunnels] = React.useState([])
1147
- const [editing, setEditing] = React.useState(null)
1148
- const [busy, setBusy] = React.useState(null)
1149
- const [testResult, setTestResult] = React.useState(null)
1150
- const [syncMsg, setSyncMsg] = React.useState(null)
1151
- const [tunnelMsg, setTunnelMsg] = React.useState(null)
1152
- const [showPassword, setShowPassword] = React.useState(false)
1153
-
1154
- // Tunnel creation inputs
1155
- const [newTunnelLocal, setNewTunnelLocal] = React.useState('3000')
1156
- const [newTunnelRemote, setNewTunnelRemote] = React.useState('3000')
1157
-
1158
- // Remote Directory Browser State
1159
- const [browserOpen, setBrowserOpen] = React.useState(false)
1160
- const [browserPath, setBrowserPath] = React.useState('/')
1161
- const [browserEntries, setBrowserEntries] = React.useState([])
1162
- const [browserLoading, setBrowserLoading] = React.useState(false)
1163
-
1164
- const loadState = async () => {
1165
- try {
1166
- const res = await fetch('/dsh-remote-workspace/state')
1167
- if (res.ok) {
1168
- const data = await res.json()
1169
- if (data.ok) {
1170
- setProfiles(data.profiles || [])
1171
- setActiveId(data.activeId || null)
1172
- setTunnels(data.tunnels || [])
1173
- setAutoSync(Boolean(data.autoSync))
1174
- if (data.activeId) {
1175
- fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
1176
- }
1177
- }
1178
- }
1179
- } catch (err) { /* best-effort cleanup */ }
1180
- }
1181
-
1182
- React.useEffect(() => {
1183
- ensureCss()
1184
- loadState()
1185
- }, [])
1186
-
1187
- const handleSave = async (profile) => {
1188
- setBusy('saving')
1189
- try {
1190
- const res = await fetch('/dsh-remote-workspace/profiles/save', {
1191
- method: 'POST',
1192
- headers: { 'Content-Type': 'application/json' },
1193
- body: JSON.stringify(profile)
1194
- })
1195
- if (res.ok) {
1196
- setEditing(null)
1197
- setBrowserOpen(false)
1198
- setTestResult(null)
1199
- await loadState()
1200
- }
1201
- } catch (err) { /* best-effort cleanup */ }
1202
- setBusy(null)
1203
- }
1204
-
1205
- const handleDelete = async (id, name) => {
1206
- if (!confirm(t('delConfirm', { name: name || id }))) return
1207
- try {
1208
- const res = await fetch('/dsh-remote-workspace/profiles/delete', {
1209
- method: 'POST',
1210
- headers: { 'Content-Type': 'application/json' },
1211
- body: JSON.stringify({ id })
1212
- })
1213
- if (res.ok) {
1214
- if (editing && editing.id === id) setEditing(null)
1215
- await loadState()
1216
- }
1217
- } catch (err) { /* best-effort cleanup */ }
1218
- }
1219
-
1220
- const handleSetActive = async (id) => {
1221
- try {
1222
- const res = await fetch('/dsh-remote-workspace/profiles/active', {
1223
- method: 'POST',
1224
- headers: { 'Content-Type': 'application/json' },
1225
- body: JSON.stringify({ id })
1226
- })
1227
- if (res.ok) {
1228
- await loadState()
1229
- }
1230
- } catch (err) { /* best-effort cleanup */ }
1231
- }
1232
-
1233
- const handleTest = async (prof) => {
1234
- setBusy('testing')
1235
- setTestResult(null)
1236
- try {
1237
- const res = await fetch('/dsh-remote-workspace/test', {
1238
- method: 'POST',
1239
- headers: { 'Content-Type': 'application/json' },
1240
- body: JSON.stringify({ profile: prof })
1241
- })
1242
- const data = await res.json()
1243
- setTestResult(data)
1244
- } catch (err) {
1245
- setTestResult({ ok: false, error: err.message })
1246
- }
1247
- setBusy(null)
1248
- }
1249
1367
 
1250
- const openDirectoryBrowser = async (initialPath) => {
1251
- setBrowserOpen(true)
1252
- const target = initialPath && initialPath.startsWith('/') ? initialPath : '/'
1253
- await fetchRemoteDir(target)
1368
+ function groupProfiles(list, mode) {
1369
+ const profiles = Array.isArray(list) ? list : []
1370
+ const bucket = (map, key, profile) => {
1371
+ const name = key || 'ungrouped'
1372
+ if (!map.has(name)) map.set(name, [])
1373
+ map.get(name).push(profile)
1254
1374
  }
1255
-
1256
- const fetchRemoteDir = async (dirPath) => {
1257
- setBrowserLoading(true)
1258
- try {
1259
- const res = await fetch('/dsh-remote-workspace/browse', {
1260
- method: 'POST',
1261
- headers: { 'Content-Type': 'application/json' },
1262
- body: JSON.stringify({ profile: editing, remotePath: dirPath })
1263
- })
1264
- const data = await res.json()
1265
- if (data.ok) {
1266
- setBrowserPath(data.currentPath || dirPath)
1267
- setBrowserEntries(data.entries || [])
1268
- }
1269
- } catch (err) { /* best-effort cleanup */ }
1270
- setBrowserLoading(false)
1375
+ if (mode === 'environment') {
1376
+ const map = new Map()
1377
+ profiles.forEach((profile) => bucket(map, String(profile.environment || '').trim(), profile))
1378
+ return Array.from(map.entries()).map(([key, items]) => ({ key, items }))
1271
1379
  }
1272
-
1273
- const handleSync = async (direction) => {
1274
- setBusy(`sync_${direction}`)
1275
- setSyncMsg(null)
1276
- try {
1277
- const res = await fetch('/dsh-remote-workspace/sync', {
1278
- method: 'POST',
1279
- headers: { 'Content-Type': 'application/json' },
1280
- body: JSON.stringify({ direction })
1281
- })
1282
- const data = await res.json()
1283
- if (data.ok) {
1284
- const count = (direction === 'push' ? data.pushed?.length : data.pulled?.length) || 0
1285
- if (data.conflicts && data.conflicts.length > 0) {
1286
- setSyncMsg({ ok: false, text: t('syncConflict', { count: data.conflicts.length }) })
1287
- } else {
1288
- setSyncMsg({ ok: true, text: t('syncOk', { count }) })
1289
- }
1290
- } else {
1291
- setSyncMsg({ ok: false, text: t('syncErr', { err: data.error || 'Unknown error' }) })
1292
- }
1293
- } catch (err) {
1294
- setSyncMsg({ ok: false, text: t('syncErr', { err: err.message }) })
1295
- }
1296
- setBusy(null)
1380
+ if (mode === 'tag') {
1381
+ const map = new Map()
1382
+ profiles.forEach((profile) => {
1383
+ const tags = Array.isArray(profile.tags) ? profile.tags.map((item) => String(item).trim()).filter(Boolean) : []
1384
+ if (!tags.length) bucket(map, '', profile)
1385
+ else tags.forEach((tag) => bucket(map, tag, profile))
1386
+ })
1387
+ return Array.from(map.entries()).map(([key, items]) => ({ key, items }))
1297
1388
  }
1389
+ return [{ key: 'all', items: profiles }]
1390
+ }
1298
1391
 
1299
- const handleStartTunnel = async () => {
1300
- if (!activeId) return
1301
- setBusy('tunnel_open')
1302
- setTunnelMsg(null)
1392
+ function ProfilesPanel(panel) {
1393
+ const {
1394
+ t,
1395
+ editing,
1396
+ setEditing,
1397
+ setTestResult,
1398
+ profiles,
1399
+ activeId,
1400
+ busy,
1401
+ handleSave,
1402
+ testResult,
1403
+ handleTest,
1404
+ handleSetActive,
1405
+ handleDelete,
1406
+ browserOpen,
1407
+ setBrowserOpen,
1408
+ browserPath,
1409
+ browserEntries,
1410
+ browserLoading,
1411
+ openDirectoryBrowser,
1412
+ fetchRemoteDir,
1413
+ showPassword,
1414
+ setShowPassword,
1415
+ } = panel
1416
+ const [groupMode, setGroupMode] = React.useState('flat')
1417
+ const [groupReport, setGroupReport] = React.useState([])
1418
+ const sections = groupProfiles(profiles, groupMode)
1419
+ const testGroup = async (items) => {
1303
1420
  try {
1304
- const res = await fetch('/dsh-remote-workspace/tunnels/start', {
1421
+ const res = await fetch('/dsh-remote-workspace/profiles/test-group', {
1305
1422
  method: 'POST',
1306
1423
  headers: { 'Content-Type': 'application/json' },
1307
- body: JSON.stringify({
1308
- profileId: activeId,
1309
- localPort: newTunnelLocal,
1310
- remotePort: newTunnelRemote
1311
- })
1424
+ body: JSON.stringify({ ids: items.map((item) => item.id) })
1312
1425
  })
1313
1426
  const data = await res.json()
1314
- if (data.ok) {
1315
- setTunnelMsg({ ok: true, text: t('tOpenOk', { localPort: newTunnelLocal, remotePort: newTunnelRemote }) })
1316
- await loadState()
1317
- } else {
1318
- setTunnelMsg({ ok: false, text: t('tOpenErr', { err: data.error || 'Failed' }) })
1319
- }
1427
+ if (data && data.ok) setGroupReport(data.results || [])
1320
1428
  } catch (err) {
1321
- setTunnelMsg({ ok: false, text: t('tOpenErr', { err: err.message }) })
1429
+ setGroupReport([])
1322
1430
  }
1323
- setBusy(null)
1324
- }
1325
-
1326
- const handleStopTunnel = async (tunnelId) => {
1327
- try {
1328
- const res = await fetch('/dsh-remote-workspace/tunnels/stop', {
1329
- method: 'POST',
1330
- headers: { 'Content-Type': 'application/json' },
1331
- body: JSON.stringify({ tunnelId })
1332
- })
1333
- if (res.ok) {
1334
- await loadState()
1335
- }
1336
- } catch (err) { /* best-effort cleanup */ }
1337
1431
  }
1338
-
1339
- const activeProfile = profiles.find((p) => p.id === activeId)
1340
-
1341
- return React.createElement(
1342
- 'div',
1343
- { className: 'drw-page' },
1344
-
1345
- // Header with Live Badges (ClineBot Style)
1346
- React.createElement(
1347
- 'div',
1348
- { className: 'drw-header' },
1349
- React.createElement(
1350
- 'div',
1351
- { className: 'drw-header-top' },
1352
- React.createElement('div', { className: 'drw-page-title' }, '🌐 ' + t('title')),
1353
- React.createElement(
1354
- 'div',
1355
- { className: 'drw-header-badges' },
1356
- activeProfile
1357
- ? React.createElement('span', { className: 'drw-badge drw-badge-ok' }, t('badgeOnline', { name: activeProfile.name || activeProfile.host, latency: testResult?.latencyMs ?? 15 }))
1358
- : React.createElement('span', { className: 'drw-badge drw-badge-warn' }, t('badgeOffline')),
1359
- activeProfile
1360
- ? React.createElement('span', { className: 'drw-badge' }, activeProfile.authType === 'password' ? t('badgePassword') : t('badgeKey'))
1361
- : null,
1362
- React.createElement('span', { className: `drw-badge ${tunnels.length > 0 ? 'drw-badge-ok' : ''}` }, t('badgeTunnels', { count: tunnels.length }))
1363
- )
1364
- ),
1365
- React.createElement('div', { className: 'drw-page-sub' }, t('subtitle'))
1366
- ),
1367
-
1368
- // Section 1: SSH Profiles Table & Management
1432
+ return (
1369
1433
  React.createElement(
1370
1434
  'div',
1371
1435
  { className: 'drw-card' },
@@ -1391,7 +1455,11 @@ window.__ModuleLoader__.load({
1391
1455
  passphrase: '',
1392
1456
  password: '',
1393
1457
  remoteWorkspace: '',
1394
- localMirrorPath: ''
1458
+ localMirrorPath: '',
1459
+ environment: '',
1460
+ tags: [],
1461
+ location: '',
1462
+ description: ''
1395
1463
  })
1396
1464
  setTestResult(null)
1397
1465
  }
@@ -1469,6 +1537,62 @@ window.__ModuleLoader__.load({
1469
1537
  )
1470
1538
  ),
1471
1539
 
1540
+ React.createElement(
1541
+ 'div',
1542
+ { className: 'drw-field' },
1543
+ React.createElement('span', { className: 'drw-label' }, t('fJumpHosts')),
1544
+ React.createElement('input', {
1545
+ className: 'drw-input',
1546
+ placeholder: 'bastion-a, bastion-b',
1547
+ value: editing.jumpHostId || ((editing.jumpHosts || []).join(', ')),
1548
+ onChange: (e) => {
1549
+ const raw = e.target.value;
1550
+ const jumpHosts = raw.split(',').map((item) => item.trim()).filter(Boolean);
1551
+ setEditing({ ...editing, jumpHostId: raw, jumpHosts });
1552
+ }
1553
+ }),
1554
+ React.createElement('span', { className: 'drw-hint' }, t('fJumpHostsHint'))
1555
+ ),
1556
+
1557
+ React.createElement(
1558
+ 'div',
1559
+ { className: 'drw-field' },
1560
+ React.createElement('span', { className: 'drw-label' }, t('fProxyCommand')),
1561
+ React.createElement('input', {
1562
+ className: 'drw-input',
1563
+ placeholder: 'cloudflared access ssh --hostname %h',
1564
+ value: editing.proxyCommand || '',
1565
+ onChange: (e) => setEditing({ ...editing, proxyCommand: e.target.value })
1566
+ }),
1567
+ React.createElement('span', { className: 'drw-hint' }, t('fProxyCommandHint'))
1568
+ ),
1569
+
1570
+
1571
+ React.createElement(
1572
+ 'div',
1573
+ { className: 'drw-grid-2' },
1574
+ React.createElement('div', { className: 'drw-field' },
1575
+ React.createElement('span', { className: 'drw-label' }, t('fEnvironment')),
1576
+ React.createElement('input', { className: 'drw-input', value: editing.environment || '', onChange: (e) => setEditing({ ...editing, environment: e.target.value }) })
1577
+ ),
1578
+ React.createElement('div', { className: 'drw-field' },
1579
+ React.createElement('span', { className: 'drw-label' }, t('fLocation')),
1580
+ React.createElement('input', { className: 'drw-input', value: editing.location || '', onChange: (e) => setEditing({ ...editing, location: e.target.value }) })
1581
+ ),
1582
+ React.createElement('div', { className: 'drw-field' },
1583
+ React.createElement('span', { className: 'drw-label' }, t('fTags')),
1584
+ React.createElement('input', {
1585
+ className: 'drw-input',
1586
+ value: Array.isArray(editing.tags) ? editing.tags.join(', ') : (editing.tags || ''),
1587
+ onChange: (e) => setEditing({ ...editing, tags: e.target.value.split(',').map((item) => item.trim()).filter(Boolean) })
1588
+ })
1589
+ ),
1590
+ React.createElement('div', { className: 'drw-field' },
1591
+ React.createElement('span', { className: 'drw-label' }, t('fDescription')),
1592
+ React.createElement('input', { className: 'drw-input', value: editing.description || '', onChange: (e) => setEditing({ ...editing, description: e.target.value }) })
1593
+ )
1594
+ ),
1595
+
1472
1596
  // Auth Method Segmented Switcher (Key vs Password)
1473
1597
  React.createElement(
1474
1598
  'div',
@@ -1481,11 +1605,20 @@ window.__ModuleLoader__.load({
1481
1605
  'button',
1482
1606
  {
1483
1607
  type: 'button',
1484
- className: `drw-segmented-item ${editing.authType !== 'password' ? 'drw-segmented-item-active' : ''}`,
1608
+ className: `drw-segmented-item ${editing.authType !== 'password' && editing.authType !== 'agent' ? 'drw-segmented-item-active' : ''}`,
1485
1609
  onClick: () => setEditing({ ...editing, authType: 'key' })
1486
1610
  },
1487
1611
  '🔑 ' + t('fAuthKey')
1488
1612
  ),
1613
+ React.createElement(
1614
+ 'button',
1615
+ {
1616
+ type: 'button',
1617
+ className: `drw-segmented-item ${editing.authType === 'agent' ? 'drw-segmented-item-active' : ''}`,
1618
+ onClick: () => setEditing({ ...editing, authType: 'agent' })
1619
+ },
1620
+ t('fAuthAgent')
1621
+ ),
1489
1622
  React.createElement(
1490
1623
  'button',
1491
1624
  {
@@ -1499,7 +1632,19 @@ window.__ModuleLoader__.load({
1499
1632
  ),
1500
1633
 
1501
1634
  // Conditional Auth Inputs
1502
- editing.authType === 'password'
1635
+ editing.authType === 'agent'
1636
+ ? React.createElement(
1637
+ 'div',
1638
+ { className: 'drw-field' },
1639
+ React.createElement('span', { className: 'drw-label' }, t('fAgentPath')),
1640
+ React.createElement('input', {
1641
+ className: 'drw-input',
1642
+ placeholder: t('fAgentPathHint'),
1643
+ value: editing.agentPath || '',
1644
+ onChange: (e) => setEditing({ ...editing, agentPath: e.target.value })
1645
+ })
1646
+ )
1647
+ : editing.authType === 'password'
1503
1648
  ? React.createElement(
1504
1649
  'div',
1505
1650
  { className: 'drw-field' },
@@ -1734,6 +1879,23 @@ window.__ModuleLoader__.load({
1734
1879
  )
1735
1880
  : null,
1736
1881
 
1882
+
1883
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginBottom: '8px' } },
1884
+ ['flat', 'environment', 'tag'].map((mode) => React.createElement('button', {
1885
+ key: mode,
1886
+ type: 'button',
1887
+ className: groupMode === mode ? 'drw-btn drw-btn-primary' : 'drw-btn',
1888
+ onClick: () => setGroupMode(mode)
1889
+ }, t(mode === 'flat' ? 'groupFlat' : mode === 'environment' ? 'groupEnvironment' : 'groupTag')))
1890
+ ),
1891
+ groupReport.length
1892
+ ? React.createElement('div', { className: 'drw-hint', role: 'status' },
1893
+ groupReport.map((item) => React.createElement('div', { key: item.id + (item.name || '') },
1894
+ (item.name || item.id) + ': ' + (item.ok ? t('groupOnline', { latency: item.latencyMs || 0 }) : t('groupOffline', { err: item.error || '' }))
1895
+ ))
1896
+ )
1897
+ : null,
1898
+
1737
1899
  // Profiles List Table
1738
1900
  profiles.length > 0
1739
1901
  ? React.createElement(
@@ -1755,7 +1917,17 @@ window.__ModuleLoader__.load({
1755
1917
  React.createElement(
1756
1918
  'tbody',
1757
1919
  null,
1758
- profiles.map((p) => {
1920
+ sections.flatMap((section) => {
1921
+ const header = groupMode === 'flat' ? [] : [
1922
+ React.createElement('tr', { key: 'group-' + section.key },
1923
+ React.createElement('td', { colSpan: 5 },
1924
+ React.createElement('strong', null, section.key === 'ungrouped' ? t('groupUngrouped') : section.key),
1925
+ ' ',
1926
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => testGroup(section.items) }, t('testGroup'))
1927
+ )
1928
+ )
1929
+ ]
1930
+ return header.concat(section.items.map((p) => {
1759
1931
  const isAct = p.id === activeId
1760
1932
  return React.createElement(
1761
1933
  'tr',
@@ -1767,7 +1939,7 @@ window.__ModuleLoader__.load({
1767
1939
  React.createElement(
1768
1940
  'span',
1769
1941
  { className: 'drw-badge', style: { marginLeft: '8px' } },
1770
- p.authType === 'password' ? '🔒 pwd' : '🔑 key'
1942
+ p.authType === 'password' ? '🔒 ' + t('fAuthPassShort') : p.authType === 'agent' ? t('fAuthAgentShort') : '🔑 ' + t('fAuthKeyShort')
1771
1943
  ),
1772
1944
  isAct
1773
1945
  ? React.createElement('span', { className: 'drw-badge drw-badge-ok', style: { marginLeft: '6px' } }, t('activeBadge'))
@@ -1820,68 +1992,30 @@ window.__ModuleLoader__.load({
1820
1992
  )
1821
1993
  )
1822
1994
  )
1995
+ }))
1823
1996
  })
1824
1997
  )
1825
1998
  )
1826
1999
  : !editing
1827
2000
  ? React.createElement('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('noHosts'))
1828
2001
  : null
1829
- ),
1830
-
1831
- // Section 2: Active Connection Diagnostics (ClineBot Card 4 style)
1832
- activeProfile
1833
- ? React.createElement(
1834
- 'div',
1835
- { className: 'drw-card' },
1836
- React.createElement(
1837
- 'div',
1838
- { className: 'drw-card-title' },
1839
- React.createElement('span', null, t('diagTitle')),
1840
- React.createElement(
1841
- 'button',
1842
- {
1843
- type: 'button',
1844
- className: 'drw-btn',
1845
- style: { fontSize: '11px', padding: '3px 9px' },
1846
- disabled: busy === 'testing',
1847
- onClick: () => handleTest(activeProfile)
1848
- },
1849
- busy === 'testing' ? t('testing') : t('diagProbeBtn')
1850
- )
1851
- ),
1852
- React.createElement('div', { className: 'drw-card-desc' }, t('diagDesc')),
1853
- React.createElement(
1854
- 'div',
1855
- { className: 'drw-grid-4' },
1856
- React.createElement(
1857
- 'div',
1858
- { className: 'drw-stat-box' },
1859
- React.createElement('div', { className: 'drw-stat-val' }, activeProfile.name || activeProfile.host),
1860
- React.createElement('div', { className: 'drw-stat-lbl' }, `SSH: ${activeProfile.username}@${activeProfile.host}:${activeProfile.port || 22}`)
1861
- ),
1862
- React.createElement(
1863
- 'div',
1864
- { className: 'drw-stat-box' },
1865
- React.createElement('div', { className: 'drw-stat-val' }, activeProfile.remoteWorkspace || '—'),
1866
- React.createElement('div', { className: 'drw-stat-lbl' }, 'Remote Workspace')
1867
- ),
1868
- React.createElement(
1869
- 'div',
1870
- { className: 'drw-stat-box' },
1871
- React.createElement('div', { className: 'drw-stat-val' }, activeProfile.localMirrorPath || '—'),
1872
- React.createElement('div', { className: 'drw-stat-lbl' }, 'Local Mirror Path')
1873
- ),
1874
- React.createElement(
1875
- 'div',
1876
- { className: 'drw-stat-box' },
1877
- React.createElement('div', { className: 'drw-stat-val', style: { color: 'var(--dsw-alias-state-success-primary)' } }, 'Ready ✓'),
1878
- React.createElement('div', { className: 'drw-stat-lbl' }, activeProfile.authType === 'password' ? 'Password Auth' : 'SSH Key Auth')
1879
- )
1880
- )
1881
- )
1882
- : null,
2002
+ )
2003
+ )
2004
+ }
1883
2005
 
1884
- // Section 3: Mirror Sync (ClineBot Action Card style)
2006
+ function SyncPanel(panel) {
2007
+ const {
2008
+ t,
2009
+ busy,
2010
+ activeProfile,
2011
+ handleSync,
2012
+ syncMsg,
2013
+ autoSync,
2014
+ setAutoSync,
2015
+ reportAction,
2016
+ setActionError,
2017
+ } = panel
2018
+ return (
1885
2019
  activeProfile
1886
2020
  ? React.createElement(
1887
2021
  'div',
@@ -1912,9 +2046,11 @@ window.__ModuleLoader__.load({
1912
2046
  headers: { 'Content-Type': 'application/json' },
1913
2047
  body: JSON.stringify({ enabled: next })
1914
2048
  })
1915
- const d = await res.json()
1916
- if (d.ok) setAutoSync(d.autoSync)
1917
- } catch (err) { /* best-effort cleanup */ }
2049
+ const data = await reportAction(res)
2050
+ if (data && data.ok) setAutoSync(data.autoSync)
2051
+ } catch (err) {
2052
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2053
+ }
1918
2054
  }
1919
2055
  },
1920
2056
  autoSync ? '⚡ Active (ON)' : '⚪ Off'
@@ -1958,9 +2094,25 @@ window.__ModuleLoader__.load({
1958
2094
  )
1959
2095
  : null
1960
2096
  )
1961
- : null,
2097
+ : null
2098
+ )
2099
+ }
1962
2100
 
1963
- // Section 4: SSH Port Forwarding (Tunnels Manager)
2101
+ function TunnelsPanel(panel) {
2102
+ const {
2103
+ t,
2104
+ busy,
2105
+ activeProfile,
2106
+ newTunnelLocal,
2107
+ setNewTunnelLocal,
2108
+ newTunnelRemote,
2109
+ setNewTunnelRemote,
2110
+ handleStartTunnel,
2111
+ tunnelMsg,
2112
+ tunnels,
2113
+ handleStopTunnel,
2114
+ } = panel
2115
+ return (
1964
2116
  React.createElement(
1965
2117
  'div',
1966
2118
  { className: 'drw-card' },
@@ -2077,143 +2229,692 @@ window.__ModuleLoader__.load({
2077
2229
  )
2078
2230
  }
2079
2231
 
2080
- // Accordion item for Settings -> Plugins section
2081
-
2082
- function UpdaterSection(props) {
2083
- const t = props.t || ((k) => k);
2084
- const [updateStatus, setUpdateStatus] = React.useState(null);
2085
- const [loading, setLoading] = React.useState(false);
2086
- const [updating, setUpdating] = React.useState(false);
2087
- const [msg, setMsg] = React.useState(null);
2088
-
2089
- const checkUpdate = React.useCallback(async () => {
2090
- setLoading(true);
2091
- setMsg(null);
2232
+ function ClusterPanel(props) {
2233
+ const t = props.t
2234
+ const profiles = props.profiles || []
2235
+ const [command, setCommand] = React.useState('')
2236
+ const [environment, setEnvironment] = React.useState('')
2237
+ const [tag, setTag] = React.useState('')
2238
+ const [rows, setRows] = React.useState([])
2239
+ const [open, setOpen] = React.useState('')
2240
+ const [running, setRunning] = React.useState(false)
2241
+ const run = async () => {
2242
+ setRunning(true)
2092
2243
  try {
2093
- const res = await fetch('/dsh-remote-workspace/update', {
2094
- headers: { accept: 'application/json' },
2095
- cache: 'no-store'
2096
- });
2097
- if (!res.ok) throw new Error('HTTP ' + res.status);
2098
- const data = await res.json();
2099
- setUpdateStatus(data);
2100
- } catch (e) {
2101
- setMsg({ ok: false, text: (t('updateFailed') || 'Failed: ') + (e?.message || String(e)) });
2102
- } finally {
2103
- setLoading(false);
2244
+ const res = await fetch('/dsh-remote-workspace/cluster', {
2245
+ method: 'POST',
2246
+ headers: { 'Content-Type': 'application/json' },
2247
+ body: JSON.stringify({
2248
+ command,
2249
+ environment,
2250
+ tags: tag ? [tag] : [],
2251
+ maxWorkers: 8
2252
+ })
2253
+ })
2254
+ const data = await res.json()
2255
+ setRows(data && data.results ? data.results : [])
2256
+ } catch (err) {
2257
+ setRows([])
2104
2258
  }
2105
- }, [t]);
2259
+ setRunning(false)
2260
+ }
2261
+ return React.createElement('div', { className: 'drw-card' },
2262
+ React.createElement('div', { className: 'drw-card-title' }, t('clusterTitle')),
2263
+ React.createElement('div', { className: 'drw-grid-2' },
2264
+ React.createElement('input', { className: 'drw-input', placeholder: t('fEnvironment'), value: environment, onChange: (e) => setEnvironment(e.target.value) }),
2265
+ React.createElement('input', { className: 'drw-input', placeholder: t('fTags'), value: tag, onChange: (e) => setTag(e.target.value) })
2266
+ ),
2267
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '8px' } },
2268
+ React.createElement('input', { className: 'drw-input', placeholder: t('clusterCommand'), value: command, onChange: (e) => setCommand(e.target.value) }),
2269
+ React.createElement('button', { type: 'button', className: 'drw-btn drw-btn-primary', disabled: running || !command.trim(), onClick: run }, running ? t('clusterRunning') : t('clusterRun'))
2270
+ ),
2271
+ React.createElement('div', { className: 'drw-hint' }, t('clusterCount', { count: profiles.length })),
2272
+ rows.map((row, index) => React.createElement('div', { key: (row.alias || '') + index, className: 'drw-field' },
2273
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => setOpen(open === String(index) ? '' : String(index)) },
2274
+ (row.alias || row.host) + ' ' + (row.success ? t('clusterOk') : t('clusterFail')) + ' ' + (row.durationMs || 0) + ' ms'
2275
+ ),
2276
+ open === String(index)
2277
+ ? React.createElement('pre', { className: 'drw-term-body' }, row.error || row.stdout || row.stderr || '')
2278
+ : null
2279
+ ))
2280
+ )
2281
+ }
2106
2282
 
2107
- const doUpdate = async () => {
2108
- setUpdating(true);
2109
- setMsg(null);
2283
+ function SettingsView(props) {
2284
+ const [activeTab, setActiveTab] = React.useState('profiles')
2285
+ const [healthData, setHealthData] = React.useState(null)
2286
+ const [autoSync, setAutoSync] = React.useState(false)
2287
+
2288
+ const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
2289
+ const [profiles, setProfiles] = React.useState([])
2290
+ const [activeId, setActiveId] = React.useState(null)
2291
+ const [tunnels, setTunnels] = React.useState([])
2292
+ const [editing, setEditing] = React.useState(null)
2293
+ const [busy, setBusy] = React.useState(null)
2294
+ const [testResult, setTestResult] = React.useState(null)
2295
+ const [actionError, setActionError] = React.useState('')
2296
+ const [authPrompts, setAuthPrompts] = React.useState([])
2297
+ const [authAnswers, setAuthAnswers] = React.useState({})
2298
+ const [importReport, setImportReport] = React.useState(null)
2299
+ const [syncMsg, setSyncMsg] = React.useState(null)
2300
+ const [tunnelMsg, setTunnelMsg] = React.useState(null)
2301
+ const [showPassword, setShowPassword] = React.useState(false)
2302
+ const [terminalFont, setTerminalFont] = React.useState('')
2303
+
2304
+ // Tunnel creation inputs
2305
+ const [newTunnelLocal, setNewTunnelLocal] = React.useState('3000')
2306
+ const [newTunnelRemote, setNewTunnelRemote] = React.useState('3000')
2307
+
2308
+ // Remote Directory Browser State
2309
+ const [browserOpen, setBrowserOpen] = React.useState(false)
2310
+ const [browserPath, setBrowserPath] = React.useState('/')
2311
+ const [browserEntries, setBrowserEntries] = React.useState([])
2312
+ const [browserLoading, setBrowserLoading] = React.useState(false)
2313
+
2314
+
2315
+ function actionErrorText(status, data) {
2316
+ if (status >= 200 && status < 300 && data && data.ok !== false) return ''
2317
+ if (data && typeof data.error === 'string' && data.error) return data.error
2318
+ return 'HTTP ' + status
2319
+ }
2320
+
2321
+ const reportAction = async (res) => {
2322
+ let data = null
2323
+ try { data = await res.json() } catch (err) { data = null }
2324
+ const text = actionErrorText(res.status, data)
2325
+ setActionError(text ? ((data && data.error) ? text : t('actionFailed')) : '')
2326
+ return text ? null : data
2327
+ }
2328
+
2329
+ const loadInFlight = React.useRef(false)
2330
+
2331
+ const loadState = async (force) => {
2332
+ const hidden = typeof document !== 'undefined' && document.hidden
2333
+ if (!force && (hidden || loadInFlight.current)) return
2334
+ loadInFlight.current = true
2110
2335
  try {
2111
- const res = await fetch('/dsh-remote-workspace/update', {
2112
- method: 'POST',
2113
- headers: {
2114
- 'x-dsh-plugin-update': '1',
2115
- 'content-type': 'application/json'
2336
+ const res = await fetch('/dsh-remote-workspace/state')
2337
+ const data = await reportAction(res)
2338
+ if (data && data.ok) {
2339
+ setProfiles(data.profiles || [])
2340
+ setActiveId(data.activeId || null)
2341
+ setTunnels(data.tunnels || [])
2342
+ setAutoSync(Boolean(data.autoSync))
2343
+ setAuthPrompts(data.authPrompts || [])
2344
+ setTerminalFont(data.terminalFontFamily || '')
2345
+ applyTerminalFont(data.terminalFontFamily || '')
2346
+ if (data.activeId) {
2347
+ fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
2116
2348
  }
2117
- });
2118
- const data = await res.json();
2119
- if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
2120
- setUpdateStatus(data);
2121
- if (data.restartRequired) {
2122
- setMsg({ ok: true, text: t('restartRequired') });
2123
2349
  }
2124
- } catch (e) {
2125
- setMsg({ ok: false, text: (t('updateFailed') || 'Update failed: ') + (e?.message || String(e)) });
2350
+ } catch (err) {
2351
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2126
2352
  } finally {
2127
- setUpdating(false);
2353
+ loadInFlight.current = false
2128
2354
  }
2129
- };
2130
-
2131
- React.useEffect(() => {
2132
- checkUpdate();
2133
- }, [checkUpdate]);
2134
-
2135
- return React.createElement(
2136
- 'div',
2137
- {
2138
- style: {
2139
- display: 'flex',
2140
- alignItems: 'center',
2141
- justifyContent: 'space-between',
2142
- padding: '10px 14px',
2143
- marginBottom: '14px',
2144
- borderRadius: '8px',
2145
- background: 'var(--dsw-alias-bg-layer-2)',
2146
- border: '1px solid var(--dsw-alias-border-l2)',
2147
- fontSize: '13px'
2148
- }
2149
- },
2150
- React.createElement(
2151
- 'div',
2152
- { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2153
- React.createElement('span', null, '📦'),
2154
- React.createElement(
2155
- 'div',
2156
- null,
2157
- React.createElement('span', { style: { fontWeight: 500 } }, 'v' + (updateStatus?.currentVersion || '0.3.1')),
2158
- updateStatus?.latestVersion && updateStatus.latestVersion !== updateStatus.currentVersion
2159
- ? React.createElement('span', { style: { marginLeft: '8px', color: 'var(--dsw-alias-state-warning-text)' } }, '→ v' + updateStatus.latestVersion)
2160
- : null,
2161
- msg
2162
- ? React.createElement('div', { style: { fontSize: '11px', marginTop: '3px', color: msg.ok ? 'var(--dsw-alias-state-success-text)' : 'var(--dsw-alias-state-error-text)' } }, msg.text)
2163
- : null
2164
- )
2165
- ),
2166
- React.createElement(
2167
- 'div',
2168
- { style: { display: 'flex', gap: '8px' } },
2169
- updateStatus?.updateAvailable
2170
- ? React.createElement(
2171
- 'button',
2172
- {
2173
- type: 'button',
2174
- className: 'drw-btn drw-btn-primary',
2175
- disabled: updating,
2176
- onClick: doUpdate,
2177
- style: { fontSize: '12px', padding: '4px 10px' }
2178
- },
2179
- updating ? t('updating') : t('updateNow')
2180
- )
2181
- : React.createElement(
2182
- 'button',
2183
- {
2184
- type: 'button',
2185
- className: 'drw-btn',
2186
- disabled: loading,
2187
- onClick: checkUpdate,
2188
- style: { fontSize: '12px', padding: '4px 10px' }
2189
- },
2190
- loading ? t('checking') : (updateStatus?.updateAvailable === false ? t('upToDate') : t('checkUpdate'))
2191
- )
2192
- )
2193
- );
2194
- }
2195
-
2196
- function PluginCard(props) {
2197
- const page = !!(props && props.view === 'page')
2198
- const [open, setOpen] = React.useState(!!page)
2199
- const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
2355
+ }
2200
2356
 
2201
2357
  React.useEffect(() => {
2202
2358
  ensureCss()
2359
+ loadState(true)
2360
+ const timer = setInterval(() => loadState(false), 2000)
2361
+ const onVisible = () => {
2362
+ if (typeof document !== 'undefined' && !document.hidden) loadState(true)
2363
+ }
2364
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
2365
+ return () => {
2366
+ clearInterval(timer)
2367
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
2368
+ }
2203
2369
  }, [])
2204
2370
 
2205
- // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
2206
- // padding, so the summary is a one-liner and the page drops our card chrome.
2207
- if (props && props.view === 'summary') {
2208
- return React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
2209
- }
2210
2371
 
2211
- return React.createElement(
2212
- page ? 'div' : 'li',
2213
- { className: page ? 'drw-page' : 'drw-card', style: page ? undefined : { listStyle: 'none', marginBottom: '12px' } },
2214
- React.createElement(
2215
- 'button',
2216
- {
2372
+ const saveTerminalFont = async () => {
2373
+ try {
2374
+ const res = await fetch('/dsh-remote-workspace/terminal/font', {
2375
+ method: 'POST',
2376
+ headers: { 'Content-Type': 'application/json' },
2377
+ body: JSON.stringify({ fontFamily: terminalFont })
2378
+ })
2379
+ const data = await reportAction(res)
2380
+ if (data && data.ok) {
2381
+ setTerminalFont(data.terminalFontFamily || '')
2382
+ applyTerminalFont(data.terminalFontFamily || '')
2383
+ }
2384
+ } catch (err) {
2385
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2386
+ }
2387
+ }
2388
+
2389
+ const submitAuth = async (prompt) => {
2390
+ const answers = (prompt.prompts || []).map((_, index) => authAnswers[prompt.profileId + ':' + index] || '')
2391
+ try {
2392
+ const res = await fetch('/dsh-remote-workspace/auth/keyboard', {
2393
+ method: 'POST',
2394
+ headers: { 'Content-Type': 'application/json' },
2395
+ body: JSON.stringify({ id: prompt.profileId, answers })
2396
+ })
2397
+ const data = await reportAction(res)
2398
+ if (data) await loadState()
2399
+ } catch (err) {
2400
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2401
+ }
2402
+ }
2403
+
2404
+ const handleSave = async (profile) => {
2405
+ setBusy('saving')
2406
+ try {
2407
+ const res = await fetch('/dsh-remote-workspace/profiles/save', {
2408
+ method: 'POST',
2409
+ headers: { 'Content-Type': 'application/json' },
2410
+ body: JSON.stringify(profile)
2411
+ })
2412
+ const data = await reportAction(res)
2413
+ if (data) {
2414
+ setEditing(null)
2415
+ setBrowserOpen(false)
2416
+ setTestResult(null)
2417
+ await loadState()
2418
+ }
2419
+ } catch (err) {
2420
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2421
+ }
2422
+ setBusy(null)
2423
+ }
2424
+
2425
+ const handleDelete = async (id, name) => {
2426
+ if (!confirm(t('delConfirm', { name: name || id }))) return
2427
+ try {
2428
+ const res = await fetch('/dsh-remote-workspace/profiles/delete', {
2429
+ method: 'POST',
2430
+ headers: { 'Content-Type': 'application/json' },
2431
+ body: JSON.stringify({ id })
2432
+ })
2433
+ const data = await reportAction(res)
2434
+ if (data) {
2435
+ if (editing && editing.id === id) setEditing(null)
2436
+ await loadState()
2437
+ }
2438
+ } catch (err) {
2439
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2440
+ }
2441
+ }
2442
+
2443
+ const handleSetActive = async (id) => {
2444
+ try {
2445
+ const res = await fetch('/dsh-remote-workspace/profiles/active', {
2446
+ method: 'POST',
2447
+ headers: { 'Content-Type': 'application/json' },
2448
+ body: JSON.stringify({ id })
2449
+ })
2450
+ const data = await reportAction(res)
2451
+ if (data) await loadState()
2452
+ } catch (err) {
2453
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2454
+ }
2455
+ }
2456
+
2457
+ const handleTest = async (prof) => {
2458
+ setBusy('testing')
2459
+ setTestResult(null)
2460
+ try {
2461
+ const res = await fetch('/dsh-remote-workspace/test', {
2462
+ method: 'POST',
2463
+ headers: { 'Content-Type': 'application/json' },
2464
+ body: JSON.stringify(prof)
2465
+ })
2466
+ const data = await res.json()
2467
+ setTestResult(data)
2468
+ } catch (err) {
2469
+ setTestResult({ ok: false, error: err.message })
2470
+ }
2471
+ setBusy(null)
2472
+ }
2473
+
2474
+ const openDirectoryBrowser = async (initialPath) => {
2475
+ setBrowserOpen(true)
2476
+ const target = initialPath && initialPath.startsWith('/') ? initialPath : '/'
2477
+ await fetchRemoteDir(target)
2478
+ }
2479
+
2480
+ const fetchRemoteDir = async (dirPath) => {
2481
+ setBrowserLoading(true)
2482
+ try {
2483
+ const res = await fetch('/dsh-remote-workspace/browse', {
2484
+ method: 'POST',
2485
+ headers: { 'Content-Type': 'application/json' },
2486
+ body: JSON.stringify({ profile: editing, remotePath: dirPath })
2487
+ })
2488
+ const data = await reportAction(res)
2489
+ if (data && data.ok) {
2490
+ setBrowserPath(data.currentPath || dirPath)
2491
+ setBrowserEntries(data.entries || [])
2492
+ }
2493
+ } catch (err) {
2494
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2495
+ }
2496
+ setBrowserLoading(false)
2497
+ }
2498
+
2499
+ const handleSync = async (direction) => {
2500
+ setBusy(`sync_${direction}`)
2501
+ setSyncMsg(null)
2502
+ try {
2503
+ const res = await fetch('/dsh-remote-workspace/sync', {
2504
+ method: 'POST',
2505
+ headers: { 'Content-Type': 'application/json' },
2506
+ body: JSON.stringify({ direction })
2507
+ })
2508
+ const data = await res.json()
2509
+ if (data.ok) {
2510
+ const count = (direction === 'push' ? data.pushed?.length : data.pulled?.length) || 0
2511
+ if (data.conflicts && data.conflicts.length > 0) {
2512
+ setSyncMsg({ ok: false, text: t('syncConflict', { count: data.conflicts.length }) })
2513
+ } else {
2514
+ setSyncMsg({ ok: true, text: t('syncOk', { count }) })
2515
+ }
2516
+ } else {
2517
+ setSyncMsg({ ok: false, text: t('syncErr', { err: data.error || 'Unknown error' }) })
2518
+ }
2519
+ } catch (err) {
2520
+ setSyncMsg({ ok: false, text: t('syncErr', { err: err.message }) })
2521
+ }
2522
+ setBusy(null)
2523
+ }
2524
+
2525
+ const handleStartTunnel = async () => {
2526
+ if (!activeId) return
2527
+ setBusy('tunnel_open')
2528
+ setTunnelMsg(null)
2529
+ try {
2530
+ const res = await fetch('/dsh-remote-workspace/tunnels/start', {
2531
+ method: 'POST',
2532
+ headers: { 'Content-Type': 'application/json' },
2533
+ body: JSON.stringify({
2534
+ profileId: activeId,
2535
+ localPort: newTunnelLocal,
2536
+ remotePort: newTunnelRemote
2537
+ })
2538
+ })
2539
+ const data = await res.json()
2540
+ if (data.ok) {
2541
+ setTunnelMsg({ ok: true, text: t('tOpenOk', { localPort: newTunnelLocal, remotePort: newTunnelRemote }) })
2542
+ await loadState()
2543
+ } else {
2544
+ setTunnelMsg({ ok: false, text: t('tOpenErr', { err: data.error || 'Failed' }) })
2545
+ }
2546
+ } catch (err) {
2547
+ setTunnelMsg({ ok: false, text: t('tOpenErr', { err: err.message }) })
2548
+ }
2549
+ setBusy(null)
2550
+ }
2551
+
2552
+ const handleStopTunnel = async (tunnelId) => {
2553
+ try {
2554
+ const res = await fetch('/dsh-remote-workspace/tunnels/stop', {
2555
+ method: 'POST',
2556
+ headers: { 'Content-Type': 'application/json' },
2557
+ body: JSON.stringify({ tunnelId })
2558
+ })
2559
+ const data = await reportAction(res)
2560
+ if (data) await loadState()
2561
+ } catch (err) {
2562
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2563
+ }
2564
+ }
2565
+
2566
+ const activeProfile = profiles.find((p) => p.id === activeId)
2567
+
2568
+ const handleImport = async () => {
2569
+ try {
2570
+ const res = await fetch('/dsh-remote-workspace/profiles/import-ssh-config', {
2571
+ method: 'POST',
2572
+ headers: { 'Content-Type': 'application/json' },
2573
+ body: '{}'
2574
+ })
2575
+ const data = await reportAction(res)
2576
+ if (data) {
2577
+ setImportReport(data)
2578
+ await loadState()
2579
+ }
2580
+ } catch (err) {
2581
+ setActionError(err && err.message ? err.message : t('actionFailed'))
2582
+ }
2583
+ }
2584
+
2585
+ const skipReason = (reason) => {
2586
+ if (reason === 'wildcard') return t('skipWildcard')
2587
+ if (reason === 'match') return t('skipMatch')
2588
+ if (reason === 'duplicate') return t('skipDuplicate')
2589
+ if (reason === 'missing-include') return t('skipMissing')
2590
+ return reason
2591
+ }
2592
+
2593
+ return React.createElement(
2594
+ 'div',
2595
+ { className: 'drw-page' },
2596
+
2597
+ // Header with Live Badges (ClineBot Style)
2598
+ React.createElement(
2599
+ 'div',
2600
+ { className: 'drw-header' },
2601
+ React.createElement(
2602
+ 'div',
2603
+ { className: 'drw-header-top' },
2604
+ React.createElement('div', { className: 'drw-page-title' }, '🌐 ' + t('title')),
2605
+ React.createElement(
2606
+ 'div',
2607
+ { className: 'drw-header-badges' },
2608
+ activeProfile
2609
+ ? React.createElement('span', { className: 'drw-badge drw-badge-ok' }, t('badgeOnline', { name: activeProfile.name || activeProfile.host, latency: testResult?.latencyMs ?? 15 }))
2610
+ : React.createElement('span', { className: 'drw-badge drw-badge-warn' }, t('badgeOffline')),
2611
+ activeProfile
2612
+ ? React.createElement('span', { className: 'drw-badge' }, activeProfile.authType === 'password' ? t('badgePassword') : t('badgeKey'))
2613
+ : null,
2614
+ React.createElement('span', { className: `drw-badge ${tunnels.length > 0 ? 'drw-badge-ok' : ''}` }, t('badgeTunnels', { count: tunnels.length }))
2615
+ )
2616
+ ),
2617
+ React.createElement('div', { className: 'drw-page-sub' }, t('subtitle')),
2618
+ actionError
2619
+ ? React.createElement('div', { className: 'drw-badge drw-badge-err', role: 'alert' }, actionError)
2620
+ : null
2621
+ ),
2622
+
2623
+
2624
+
2625
+ authPrompts.length
2626
+ ? React.createElement(
2627
+ 'div',
2628
+ { className: 'drw-card', role: 'dialog', 'aria-label': t('authPromptTitle') },
2629
+ React.createElement('div', { className: 'drw-page-title' }, t('authPromptTitle')),
2630
+ authPrompts.map((prompt) => React.createElement(
2631
+ 'div',
2632
+ { key: prompt.profileId, className: 'drw-field' },
2633
+ prompt.instructions
2634
+ ? React.createElement('span', { className: 'drw-hint' }, prompt.instructions)
2635
+ : null,
2636
+ (prompt.prompts || []).map((field, index) => React.createElement('input', {
2637
+ key: index,
2638
+ className: 'drw-input',
2639
+ type: field.echo ? 'text' : 'password',
2640
+ placeholder: field.prompt || t('authPromptCode'),
2641
+ value: authAnswers[prompt.profileId + ':' + index] || '',
2642
+ onChange: (e) => setAuthAnswers({ ...authAnswers, [prompt.profileId + ':' + index]: e.target.value })
2643
+ })),
2644
+ React.createElement('button', {
2645
+ type: 'button',
2646
+ className: 'drw-btn drw-btn-primary',
2647
+ onClick: () => submitAuth(prompt)
2648
+ }, t('authPromptSubmit'))
2649
+ ))
2650
+ )
2651
+ : null,
2652
+
2653
+
2654
+ React.createElement(
2655
+ 'div',
2656
+ { className: 'drw-card' },
2657
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: handleImport }, t('btnImportSsh')),
2658
+ importReport
2659
+ ? React.createElement(
2660
+ 'div',
2661
+ { className: 'drw-hint', role: 'status' },
2662
+ t('importAdded', { count: importReport.count || 0 }),
2663
+ (importReport.skipped || []).map((item, index) => React.createElement('div', { key: index }, (item.name || '') + ': ' + skipReason(item.reason)))
2664
+ )
2665
+ : null
2666
+ ),
2667
+
2668
+
2669
+ React.createElement('div', { className: 'drw-card' },
2670
+ React.createElement('label', { className: 'drw-label' }, t('fTerminalFont')),
2671
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
2672
+ React.createElement('input', {
2673
+ className: 'drw-input',
2674
+ type: 'text',
2675
+ value: terminalFont,
2676
+ placeholder: 'ui-monospace, Consolas, monospace',
2677
+ onChange: (e) => setTerminalFont(e.target.value)
2678
+ }),
2679
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: saveTerminalFont }, t('btnSave'))
2680
+ ),
2681
+ React.createElement('div', { className: 'drw-hint' }, t('fTerminalFontHint'))
2682
+ ),
2683
+
2684
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
2685
+ React.createElement('button', { type: 'button', className: activeTab === 'profiles' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('profiles') }, t('tabProfiles')),
2686
+ React.createElement('button', { type: 'button', className: activeTab === 'cluster' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('cluster') }, t('tabCluster'))
2687
+ , React.createElement('button', { type: 'button', className: activeTab === 'files' ? 'drw-btn drw-btn-primary' : 'drw-btn', onClick: () => setActiveTab('files') }, t('tabFiles'))
2688
+ ),
2689
+ activeTab === 'cluster'
2690
+ ? React.createElement(ClusterPanel, { t, profiles })
2691
+ : activeTab === 'files'
2692
+ ? React.createElement(ExplorerTab, { t, activeProfile })
2693
+ : React.createElement(ProfilesPanel, { t, editing, setEditing, setTestResult, profiles, activeId, busy, handleSave, testResult, handleTest, handleSetActive, handleDelete, browserOpen, setBrowserOpen, browserPath, browserEntries, browserLoading, openDirectoryBrowser, fetchRemoteDir, showPassword, setShowPassword }),
2694
+
2695
+
2696
+ // Section 2: Active Connection Diagnostics (ClineBot Card 4 style)
2697
+ activeProfile
2698
+ ? React.createElement(
2699
+ 'div',
2700
+ { className: 'drw-card' },
2701
+ React.createElement(
2702
+ 'div',
2703
+ { className: 'drw-card-title' },
2704
+ React.createElement('span', null, t('diagTitle')),
2705
+ React.createElement(
2706
+ 'button',
2707
+ {
2708
+ type: 'button',
2709
+ className: 'drw-btn',
2710
+ style: { fontSize: '11px', padding: '3px 9px' },
2711
+ disabled: busy === 'testing',
2712
+ onClick: () => handleTest(activeProfile)
2713
+ },
2714
+ busy === 'testing' ? t('testing') : t('diagProbeBtn')
2715
+ )
2716
+ ),
2717
+ React.createElement('div', { className: 'drw-card-desc' }, t('diagDesc')),
2718
+ React.createElement(
2719
+ 'div',
2720
+ { className: 'drw-grid-4' },
2721
+ React.createElement(
2722
+ 'div',
2723
+ { className: 'drw-stat-box' },
2724
+ React.createElement('div', { className: 'drw-stat-val' }, activeProfile.name || activeProfile.host),
2725
+ React.createElement('div', { className: 'drw-stat-lbl' }, `SSH: ${activeProfile.username}@${activeProfile.host}:${activeProfile.port || 22}`)
2726
+ ),
2727
+ React.createElement(
2728
+ 'div',
2729
+ { className: 'drw-stat-box' },
2730
+ React.createElement('div', { className: 'drw-stat-val' }, activeProfile.remoteWorkspace || '—'),
2731
+ React.createElement('div', { className: 'drw-stat-lbl' }, 'Remote Workspace')
2732
+ ),
2733
+ React.createElement(
2734
+ 'div',
2735
+ { className: 'drw-stat-box' },
2736
+ React.createElement('div', { className: 'drw-stat-val' }, activeProfile.localMirrorPath || '—'),
2737
+ React.createElement('div', { className: 'drw-stat-lbl' }, 'Local Mirror Path')
2738
+ ),
2739
+ React.createElement(
2740
+ 'div',
2741
+ { className: 'drw-stat-box' },
2742
+ React.createElement('div', { className: 'drw-stat-val', style: { color: 'var(--dsw-alias-state-success-primary)' } }, 'Ready ✓'),
2743
+ React.createElement('div', { className: 'drw-stat-lbl' }, activeProfile.authType === 'password' ? 'Password Auth' : 'SSH Key Auth')
2744
+ )
2745
+ )
2746
+ )
2747
+ : null,
2748
+
2749
+
2750
+ React.createElement(SyncPanel, { t, busy, activeProfile, handleSync, syncMsg, autoSync, setAutoSync, reportAction, setActionError }),
2751
+
2752
+ React.createElement(TunnelsPanel, { t, busy, activeProfile, newTunnelLocal, setNewTunnelLocal, newTunnelRemote, setNewTunnelRemote, handleStartTunnel, tunnelMsg, tunnels, handleStopTunnel }),
2753
+
2754
+ )
2755
+ }
2756
+
2757
+ // Accordion item for Settings -> Plugins section
2758
+
2759
+ function UpdaterSection(props) {
2760
+ const t = props.t || ((k) => k);
2761
+ const [updateStatus, setUpdateStatus] = React.useState(null);
2762
+ const [loading, setLoading] = React.useState(false);
2763
+ const [updating, setUpdating] = React.useState(false);
2764
+ const [msg, setMsg] = React.useState(null);
2765
+
2766
+ const checkUpdate = React.useCallback(async () => {
2767
+ setLoading(true);
2768
+ setMsg(null);
2769
+ try {
2770
+ const res = await fetch('/dsh-remote-workspace/update', {
2771
+ headers: { accept: 'application/json' },
2772
+ cache: 'no-store'
2773
+ });
2774
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2775
+ const data = await res.json();
2776
+ setUpdateStatus(data);
2777
+ } catch (e) {
2778
+ setMsg({ ok: false, text: (t('updateFailed') || 'Failed: ') + (e?.message || String(e)) });
2779
+ } finally {
2780
+ setLoading(false);
2781
+ }
2782
+ }, [t]);
2783
+
2784
+ const doUpdate = async () => {
2785
+ setUpdating(true);
2786
+ setMsg(null);
2787
+ try {
2788
+ const res = await fetch('/dsh-remote-workspace/update', {
2789
+ method: 'POST',
2790
+ headers: {
2791
+ 'x-dsh-plugin-update': '1',
2792
+ 'content-type': 'application/json'
2793
+ }
2794
+ });
2795
+ const data = await res.json();
2796
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
2797
+ setUpdateStatus(data);
2798
+ if (data.restartRequired) {
2799
+ setMsg({ ok: true, text: t('restartRequired') });
2800
+ }
2801
+ } catch (e) {
2802
+ setMsg({ ok: false, text: (t('updateFailed') || 'Update failed: ') + (e?.message || String(e)) });
2803
+ } finally {
2804
+ setUpdating(false);
2805
+ }
2806
+ };
2807
+
2808
+ React.useEffect(() => {
2809
+ checkUpdate();
2810
+ }, [checkUpdate]);
2811
+
2812
+ return React.createElement(
2813
+ 'div',
2814
+ {
2815
+ style: {
2816
+ display: 'flex',
2817
+ alignItems: 'center',
2818
+ justifyContent: 'space-between',
2819
+ padding: '10px 14px',
2820
+ marginBottom: '14px',
2821
+ borderRadius: '8px',
2822
+ background: 'var(--dsw-alias-bg-layer-2)',
2823
+ border: '1px solid var(--dsw-alias-border-l2)',
2824
+ fontSize: '13px'
2825
+ }
2826
+ },
2827
+ React.createElement(
2828
+ 'div',
2829
+ { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2830
+ React.createElement('span', null, '📦'),
2831
+ React.createElement(
2832
+ 'div',
2833
+ null,
2834
+ React.createElement('span', { style: { fontWeight: 500 } }, (updateStatus?.currentVersion ? 'v' + updateStatus.currentVersion : t('versionUnknown'))),
2835
+ updateStatus?.updateAvailable
2836
+ ? React.createElement('span', { style: { marginLeft: '8px', color: 'var(--dsw-alias-state-warning-text)' } }, '→ v' + updateStatus.latestVersion)
2837
+ : null,
2838
+ msg
2839
+ ? React.createElement('div', { style: { fontSize: '11px', marginTop: '3px', color: msg.ok ? 'var(--dsw-alias-state-success-text)' : 'var(--dsw-alias-state-error-text)' } }, msg.text)
2840
+ : null
2841
+ )
2842
+ ),
2843
+ React.createElement(
2844
+ 'div',
2845
+ { style: { display: 'flex', gap: '8px' } },
2846
+ updateStatus?.updateAvailable
2847
+ ? React.createElement(
2848
+ 'button',
2849
+ {
2850
+ type: 'button',
2851
+ className: 'drw-btn drw-btn-primary',
2852
+ disabled: updating,
2853
+ onClick: doUpdate,
2854
+ style: { fontSize: '12px', padding: '4px 10px' }
2855
+ },
2856
+ updating ? t('updating') : t('updateNow')
2857
+ )
2858
+ : React.createElement(
2859
+ 'button',
2860
+ {
2861
+ type: 'button',
2862
+ className: 'drw-btn',
2863
+ disabled: loading,
2864
+ onClick: checkUpdate,
2865
+ style: { fontSize: '12px', padding: '4px 10px' }
2866
+ },
2867
+ loading ? t('checking') : (updateStatus?.updateAvailable === false ? t('upToDate') : t('checkUpdate'))
2868
+ )
2869
+ )
2870
+ );
2871
+ }
2872
+
2873
+ function PluginCard(props) {
2874
+ const page = !!(props && props.view === 'page')
2875
+ const [open, setOpen] = React.useState(!!page)
2876
+ const t = props.t || (props.locale === 'zh' ? makeT(zh, en) : makeT(en, zh))
2877
+
2878
+ const [conn, setConn] = React.useState('loading')
2879
+
2880
+ React.useEffect(() => {
2881
+ ensureCss()
2882
+ }, [])
2883
+
2884
+ React.useEffect(() => {
2885
+ let cancelled = false
2886
+ fetch('/dsh-remote-workspace/state')
2887
+ .then((res) => {
2888
+ if (!res.ok) throw new Error('HTTP ' + res.status)
2889
+ return res.json()
2890
+ })
2891
+ .then((data) => {
2892
+ if (cancelled) return
2893
+ if (!data || data.ok === false) {
2894
+ setConn('error')
2895
+ return
2896
+ }
2897
+ const active = (data.profiles || []).find((item) => item.id === data.activeId)
2898
+ setConn(active ? 'ready' : 'empty')
2899
+ })
2900
+ .catch(() => {
2901
+ if (!cancelled) setConn('error')
2902
+ })
2903
+ return () => { cancelled = true }
2904
+ }, [])
2905
+
2906
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
2907
+ // padding, so the summary is a one-liner and the page drops our card chrome.
2908
+ if (props && props.view === 'summary') {
2909
+ return React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
2910
+ }
2911
+
2912
+ return React.createElement(
2913
+ page ? 'div' : 'li',
2914
+ { className: page ? 'drw-page' : 'drw-card', style: page ? undefined : { listStyle: 'none', marginBottom: '12px' } },
2915
+ React.createElement(
2916
+ 'button',
2917
+ {
2217
2918
  type: 'button',
2218
2919
  style: {
2219
2920
  background: 'none',
@@ -2235,11 +2936,22 @@ window.__ModuleLoader__.load({
2235
2936
  React.createElement('div', { style: { fontWeight: 600, fontSize: '15px', color: 'var(--dsw-alias-label-primary)' } }, t('title')),
2236
2937
  React.createElement('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('subtitle'))
2237
2938
  ),
2238
- React.createElement('span', { className: 'drw-badge drw-badge-ok', style: { marginRight: '14px' } }, t('status')),
2939
+ React.createElement('span', {
2940
+ className: 'drw-badge ' + (
2941
+ conn === 'ready' ? 'drw-badge-ok'
2942
+ : conn === 'error' ? 'drw-badge-err'
2943
+ : conn === 'empty' ? 'drw-badge-warn' : ''
2944
+ ),
2945
+ style: { marginRight: '14px' }
2946
+ }, t(
2947
+ conn === 'ready' ? 'statusReady'
2948
+ : conn === 'error' ? 'statusError'
2949
+ : conn === 'empty' ? 'statusEmpty' : 'statusLoading'
2950
+ )),
2239
2951
  React.createElement(
2240
2952
  'span',
2241
2953
  { style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' } },
2242
- React.createElement(FallbackChevron)
2954
+ React.createElement(ChevronIcon || FallbackChevron)
2243
2955
  )
2244
2956
  ),
2245
2957
  (page || open)
@@ -2290,7 +3002,232 @@ window.__ModuleLoader__.load({
2290
3002
  )
2291
3003
  }
2292
3004
 
3005
+ const DRW_ACTIVE = 'data-drw-active'
3006
+ const DRW_VIEW = 'data-drw-view'
3007
+ const DRW_ENTRY = 'data-drw-entry'
3008
+ const DRW_PANEL = 'remote-workspace'
3009
+ const DRW_ICON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"></rect><path d="M8 20h8"></path><path d="M12 16v4"></path></svg>'
3010
+
3011
+ function workspaceTranslator(ctx) {
3012
+ let lang = ''
3013
+ try {
3014
+ lang = ctx && ctx.locale && typeof ctx.locale.getLocale === 'function' ? ctx.locale.getLocale() : ''
3015
+ } catch (err) { lang = '' }
3016
+ if (typeof lang === 'string' && (lang === 'zh' || lang.indexOf('zh') === 0)) return makeT(zh, en)
3017
+ return makeT(en, zh)
3018
+ }
3019
+
3020
+ function WorkspaceScreen(props) {
3021
+ const t = workspaceTranslator(props.ctx)
3022
+ const [tab, setTab] = React.useState('hosts')
3023
+ const [profiles, setProfiles] = React.useState([])
3024
+ const [activeId, setActiveId] = React.useState('')
3025
+ const [tunnels, setTunnels] = React.useState([])
3026
+ const [localPort, setLocalPort] = React.useState('3000')
3027
+ const [remotePort, setRemotePort] = React.useState('3000')
3028
+ const [tunnelMsg, setTunnelMsg] = React.useState(null)
3029
+ const [busy, setBusy] = React.useState(null)
3030
+ const activeProfile = profiles.find((item) => item.id === activeId) || null
3031
+
3032
+ const loadState = async () => {
3033
+ try {
3034
+ const res = await fetch('/dsh-remote-workspace/state')
3035
+ const data = await res.json()
3036
+ if (data && data.ok) {
3037
+ setProfiles(data.profiles || [])
3038
+ setActiveId(data.activeId || '')
3039
+ setTunnels(data.tunnels || [])
3040
+ }
3041
+ } catch (err) { /* state refresh is best effort */ }
3042
+ }
3043
+
3044
+ React.useEffect(() => {
3045
+ ensureCss()
3046
+ loadState()
3047
+ }, [])
3048
+
3049
+ const startTunnel = async () => {
3050
+ if (!activeId) return
3051
+ setBusy('tunnel_open')
3052
+ try {
3053
+ const res = await fetch('/dsh-remote-workspace/tunnels/start', {
3054
+ method: 'POST',
3055
+ headers: { 'Content-Type': 'application/json' },
3056
+ body: JSON.stringify({ profileId: activeId, localPort, remotePort })
3057
+ })
3058
+ const data = await res.json()
3059
+ setTunnelMsg(data && data.ok
3060
+ ? { ok: true, text: t('tOpenOk', { localPort, remotePort }) }
3061
+ : { ok: false, text: t('tOpenErr', { err: (data && data.error) || 'Failed' }) })
3062
+ if (data && data.ok) await loadState()
3063
+ } catch (err) {
3064
+ setTunnelMsg({ ok: false, text: t('tOpenErr', { err: err.message }) })
3065
+ }
3066
+ setBusy(null)
3067
+ }
3068
+
3069
+ const stopTunnel = async (tunnelId) => {
3070
+ setBusy('tunnel_stop')
3071
+ try {
3072
+ await fetch('/dsh-remote-workspace/tunnels/stop', {
3073
+ method: 'POST',
3074
+ headers: { 'Content-Type': 'application/json' },
3075
+ body: JSON.stringify({ tunnelId })
3076
+ })
3077
+ await loadState()
3078
+ } catch (err) { /* stop is reported by the next state load */ }
3079
+ setBusy(null)
3080
+ }
3081
+
3082
+ const tabs = [
3083
+ ['hosts', 'wsHosts'],
3084
+ ['terminal', 'wsTerminal'],
3085
+ ['files', 'wsFiles'],
3086
+ ['docker', 'wsDocker'],
3087
+ ['tunnels', 'wsTunnels'],
3088
+ ['cluster', 'wsCluster']
3089
+ ]
3090
+
3091
+ return React.createElement('div', { className: 'drw-ws' },
3092
+ React.createElement('div', { className: 'drw-ws-bar' },
3093
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => props.onClose && props.onClose() }, t('wsBack')),
3094
+ tabs.map(([id, key]) => React.createElement('button', {
3095
+ key: id,
3096
+ type: 'button',
3097
+ className: tab === id ? 'drw-btn drw-btn-primary' : 'drw-btn',
3098
+ onClick: () => setTab(id)
3099
+ }, t(key)))
3100
+ ),
3101
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'hosts' }, React.createElement(SettingsView, props)),
3102
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'terminal' }, React.createElement(TerminalTab, { t, activeProfile })),
3103
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'files' }, React.createElement(ExplorerTab, { t, activeProfile })),
3104
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'docker' }, React.createElement(ContainersTab, { t, activeProfile })),
3105
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'tunnels' }, React.createElement(TunnelsPanel, {
3106
+ t, busy, activeProfile, tunnelMsg, tunnels,
3107
+ newTunnelLocal: localPort, setNewTunnelLocal: setLocalPort,
3108
+ newTunnelRemote: remotePort, setNewTunnelRemote: setRemotePort,
3109
+ handleStartTunnel: startTunnel, handleStopTunnel: stopTunnel
3110
+ })),
3111
+ React.createElement('div', { className: 'drw-ws-panel', hidden: tab !== 'cluster' }, React.createElement(ClusterPanel, { t, profiles }))
3112
+ )
3113
+ }
3114
+
3115
+ function createWorkspaceToggle() {
3116
+ let open = false
3117
+ const listeners = new Set()
3118
+ return {
3119
+ isOpen() { return open },
3120
+ set(next) {
3121
+ open = Boolean(next)
3122
+ listeners.forEach((fn) => fn(open))
3123
+ },
3124
+ toggle() { this.set(!open) },
3125
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) }
3126
+ }
3127
+ }
3128
+
3129
+ function nativeSidebarButton() {
3130
+ const nodes = document.querySelectorAll('button[class*="newSession"]')
3131
+ for (const node of nodes) {
3132
+ if (String(node.className).indexOf('newSessionLabel') === -1) return node
3133
+ }
3134
+ return null
3135
+ }
3136
+
3137
+ function mountRemoteWorkspace(ctx) {
3138
+ if (typeof document === 'undefined') return function () {}
3139
+ ensureCss()
3140
+ const toggle = createWorkspaceToggle()
3141
+ const t = workspaceTranslator(ctx)
3142
+ let entry = null
3143
+ let container = null
3144
+ let root = null
3145
+
3146
+ const placeEntry = () => {
3147
+ if (entry && entry.isConnected) return
3148
+ const target = nativeSidebarButton()
3149
+ if (!target || !target.parentElement) return
3150
+ if (target.parentElement.querySelector('[' + DRW_ENTRY + ']')) return
3151
+ entry = target.cloneNode(true)
3152
+ entry.className = String(target.className) + ' drw-entry-clone'
3153
+ entry.setAttribute(DRW_ENTRY, '')
3154
+ entry.setAttribute('data-dsh-plugin', 'dsh-remote-workspace')
3155
+ entry.setAttribute('aria-label', t('navLabel'))
3156
+ entry.removeAttribute('id')
3157
+ entry.innerHTML = ''
3158
+ const icon = document.createElement('span')
3159
+ icon.className = 'drw-entry-icon'
3160
+ icon.innerHTML = DRW_ICON
3161
+ const label = document.createElement('span')
3162
+ label.className = 'drw-entry-label'
3163
+ label.textContent = t('navLabel')
3164
+ entry.appendChild(icon)
3165
+ entry.appendChild(label)
3166
+ entry.addEventListener('click', (event) => {
3167
+ event.preventDefault()
3168
+ event.stopPropagation()
3169
+ toggle.toggle()
3170
+ })
3171
+ target.insertAdjacentElement('afterend', entry)
3172
+ }
3173
+
3174
+ const reactDom = () => {
3175
+ if (typeof window !== 'undefined' && window.ReactDOM) return window.ReactDOM
3176
+ try { return require('react-dom/client') } catch (err) { return null }
3177
+ }
3178
+
3179
+ const placeView = () => {
3180
+ const column = document.querySelector('[data-pane=conversation], [class*=centerCol]')
3181
+ if (!column) return
3182
+ if (!container) {
3183
+ container = document.createElement('div')
3184
+ container.setAttribute(DRW_VIEW, '')
3185
+ container.setAttribute('data-dsh-plugin', 'dsh-remote-workspace')
3186
+ column.appendChild(container)
3187
+ const ReactDOM = reactDom()
3188
+ if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
3189
+ root = ReactDOM.createRoot(container)
3190
+ root.render(React.createElement(WorkspaceScreen, { ctx, onClose: () => toggle.set(false) }))
3191
+ }
3192
+ return
3193
+ }
3194
+ if (container.parentElement !== column) column.appendChild(container)
3195
+ }
3196
+
3197
+ const applyOpen = () => {
3198
+ if (toggle.isOpen()) {
3199
+ document.documentElement.setAttribute(DRW_ACTIVE, '')
3200
+ document.dispatchEvent(new CustomEvent('dsh-panel-activate', { detail: DRW_PANEL }))
3201
+ if (entry) entry.dataset.active = 'true'
3202
+ } else {
3203
+ document.documentElement.removeAttribute(DRW_ACTIVE)
3204
+ if (entry) delete entry.dataset.active
3205
+ }
3206
+ }
3207
+
3208
+ const onOther = (event) => {
3209
+ if (event.detail !== DRW_PANEL && toggle.isOpen()) toggle.set(false)
3210
+ }
3211
+ const observer = new MutationObserver(() => { placeEntry(); placeView() })
3212
+ observer.observe(document.body, { childList: true, subtree: true })
3213
+ document.addEventListener('dsh-panel-activate', onOther)
3214
+ const unsubscribe = toggle.subscribe(applyOpen)
3215
+ placeEntry()
3216
+ placeView()
3217
+
3218
+ return () => {
3219
+ observer.disconnect()
3220
+ document.removeEventListener('dsh-panel-activate', onOther)
3221
+ unsubscribe()
3222
+ document.documentElement.removeAttribute(DRW_ACTIVE)
3223
+ if (root && root.unmount) root.unmount()
3224
+ if (container) container.remove()
3225
+ if (entry) entry.remove()
3226
+ }
3227
+ }
3228
+
2293
3229
  function apply(ctx) {
3230
+ try { mountRemoteWorkspace(ctx) } catch (err) { console.warn('[dsh-remote-workspace] workspace mount skipped', err && err.message) }
2294
3231
  if (ctx.locale && typeof ctx.locale.register === 'function') {
2295
3232
  if (typeof ctx.effect === 'function') {
2296
3233
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-remote-workspace: dictionaries');
@@ -2301,16 +3238,28 @@ window.__ModuleLoader__.load({
2301
3238
 
2302
3239
  if (ctx.slots && typeof ctx.slots.inject === 'function') {
2303
3240
  // Plugin-list seat (plugins.item) first: the seat the current core
2304
- // (0.1.6-alpha.2) renders as the plugin's own page with its configuration. The
2305
- // label is a static string on purpose — it is resolved while the page renders,
2306
- // and a locale lookup there would take the whole client batch down with it.
3241
+ // (0.1.6-alpha.2) renders as the plugin's own page with its configuration.
3242
+ // The label getter runs during page render. A locale-service lookup there
3243
+ // crashes the client batch, so the title is read from the dictionaries
3244
+ // already loaded in this bundle. English is the result when the active
3245
+ // language cannot be read.
2307
3246
  ctx.slots.inject('plugins.item', () =>
2308
3247
  ctx.slots.register(
2309
3248
  {
2310
3249
  name: 'plugins.item',
2311
3250
  id: ROW_ID,
2312
3251
  order: 60,
2313
- label: () => 'Remote Workspace',
3252
+ label: () => {
3253
+ try {
3254
+ const lang = ctx.locale && typeof ctx.locale.getLocale === 'function'
3255
+ ? ctx.locale.getLocale()
3256
+ : ''
3257
+ if (typeof lang === 'string' && (lang === 'zh' || lang.indexOf('zh') === 0)) return zh.title
3258
+ } catch (err) {
3259
+ return en.title
3260
+ }
3261
+ return en.title
3262
+ },
2314
3263
  locale: NS,
2315
3264
  inject: () => ({ ctx })
2316
3265
  },