@tiphareth/dsh-hardssh 0.1.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Browser-half entry for the dsh-hardssh plugin — runs inside the dsh
3
+ * web GUI. Registers a session-header utility (SSH workspace manager, left
4
+ * of the session log) that lists SSH-bound workspaces, creates new ones
5
+ * (host + remote directory picker) and deletes them. The execution-world
6
+ * routing itself is HOST-side: a session whose cwd is the anchor path of an
7
+ * SSH workspace routes its fs/subprocess calls remote; every other session
8
+ * stays local. There is no global local⇄remote toggle anymore.
9
+ * Failure policy: every DOM wiring problem is logged, never thrown — the
10
+ * web shell fails the whole boot when a plugin apply throws.
11
+ */
12
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
13
+ // Type-only: pulls the conversation SlotMap augmentation (the utilities slot).
14
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
15
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
16
+ // Type-only: pulls the SlotMap augmentation for the two directory-flow holes.
17
+ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
18
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
19
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
20
+ import { SshHostsApi, WorkspaceApi } from './api.ts'
21
+ import { SshApi } from './ssh/api.ts'
22
+ import { NS, dictionaries, type WorkspaceKey } from './locales.ts'
23
+ import { WorkspaceManager } from './state.ts'
24
+ import { setLanguage } from './text.ts'
25
+ import { WorkspaceManagerButton } from './manager-button.tsx'
26
+ import { migrateLegacySessionMemory } from './migrate.ts'
27
+ import { DirectoryFlow } from './directory-flow.tsx'
28
+ import { mountWorkspaceBadges } from './workspace-badges.ts'
29
+ import { connectHost, mountWorkspaceGates } from './workspace-gate.ts'
30
+ import { mountSshOperations } from './ssh/apply.ts'
31
+
32
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
+ interface LocaleNamespaceMap {
34
+ /** dsh-hardssh surface copy. */
35
+ 'dsh-hardssh': WorkspaceKey
36
+ }
37
+ }
38
+
39
+ // Type-only re-exports of the SSH operations surfaces (merge phase 2).
40
+ export type { PanelControllerSnapshot } from './ssh/panel/controller.ts'
41
+ export type { SshPanelProps } from './ssh/panel/SshPanel.tsx'
42
+ export type { HostsTabProps } from './ssh/panel/HostsTab.tsx'
43
+ export type { HostFormDialogProps } from './ssh/panel/HostFormDialog.tsx'
44
+ export type { TerminalTabProps } from './ssh/panel/TerminalTab.tsx'
45
+ export type { TransferTabProps } from './ssh/panel/TransferTab.tsx'
46
+ export type { TunnelsTabProps } from './ssh/panel/TunnelsTab.tsx'
47
+ export type { ClusterTabProps } from './ssh/panel/ClusterTab.tsx'
48
+ export type { SshKey } from './ssh/locales.ts'
49
+
50
+ /** Required services: slots for the header buttons + directory-flow holes,
51
+ * locale for the copy, workspaces for the native local-directory chooser. */
52
+ export const inject = ['slots', 'locale', 'workspaces']
53
+
54
+ /** Apply the browser half. */
55
+ export function apply(ctx: ClientContext): void {
56
+ // SSH operations surfaces (sidebar entry + operations panel), merged from
57
+ // the legacy dsh-ssh client. Mounted first; its failures degrade the SSH
58
+ // panel only, never the GUI.
59
+ mountSshOperations(ctx)
60
+
61
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-hardssh: dictionaries')
62
+
63
+ const api = new WorkspaceApi()
64
+ const hostsApi = new SshHostsApi()
65
+ const sshApi = new SshApi()
66
+ const manager = new WorkspaceManager(api)
67
+
68
+ const disposers: Array<() => void> = []
69
+ try {
70
+ ctx.slots.inject('conversation.session.header.utilities', () => {
71
+ return ctx.slots.register(
72
+ { name: 'conversation.session.header.utilities', id: 'ssh-workspace-manager', order: -10, inject: () => ({ manager, sshApi }) },
73
+ WorkspaceManagerButton,
74
+ )
75
+ })
76
+
77
+ // Replace the native-only directory-flow occupant (both holes) with the
78
+ // SSH/local chooser. `pickDirectory` restores the original native local
79
+ // picking for the "Local workspace" branch; the SSH branch drives our own
80
+ // creation dialog and hands the anchor dir to the owner.
81
+ //
82
+ // Mirror the native picker's registration shape exactly: nested
83
+ // `inject(hero) -> inject(sidebar) -> generator`, so the declarations are
84
+ // re-checked each activation and our occupant lands once ui-workspace is
85
+ // live. A negative priority beats the native occupant (priority 0) under
86
+ // the single-slot "lowest wins" rule.
87
+ const flowInject = (): Record<string, unknown> => ({
88
+ pickDirectory: () => ctx.workspaces.pickDirectory(),
89
+ createSshWorkspace: (input: { title: string; alias: string; remoteRoot: string }) => api.createWorkspace(input),
90
+ listHosts: () => api.listHosts(),
91
+ createHost: (input: {
92
+ alias: string
93
+ host: string
94
+ port?: number
95
+ user: string
96
+ auth: { kind: 'password'; password: string } | { kind: 'key'; keyPath: string; passphrase?: string }
97
+ }) => hostsApi.create(input),
98
+ listRemoteDir: (alias: string, path?: string) => api.listRemoteDir(alias, path),
99
+ ensureConnected: (alias: string) => connectHost(sshApi, alias),
100
+ })
101
+ ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
102
+ yield (ctx.slots.register as unknown as (options: {
103
+ name: 'conversation.hero.workspace.directoryFlow'
104
+ priority?: number
105
+ inject: () => Record<string, unknown>
106
+ }, component: unknown) => () => void)(
107
+ { name: 'conversation.hero.workspace.directoryFlow', priority: -1, inject: flowInject },
108
+ DirectoryFlow,
109
+ )
110
+ yield (ctx.slots.register as unknown as (options: {
111
+ name: 'sidebar.workspaces.directoryFlow'
112
+ priority?: number
113
+ inject: () => Record<string, unknown>
114
+ }, component: unknown) => () => void)(
115
+ { name: 'sidebar.workspaces.directoryFlow', priority: -1, inject: flowInject },
116
+ DirectoryFlow,
117
+ )
118
+ }))
119
+
120
+ manager.start()
121
+
122
+ // Decorate the sidebar workspace rows: append a remote badge to every
123
+ // host workspace whose title belongs to an SSH-bound workspace. The
124
+ // badge set is driven by the manager (the single owner of the workspace
125
+ // list): every successful poll / mutation emits, and the snapshot is the
126
+ // only data source. Best-effort: a DOM miss only logs.
127
+ let badgesDispose: (() => void) | undefined
128
+ // Live connection state (aliases with a pooled transport) → blue badge.
129
+ let connectedAliases = new Set<string>()
130
+ const refreshBadges = (): void => {
131
+ try {
132
+ const workspaces = manager.getSnapshot().workspaces
133
+ badgesDispose?.()
134
+ badgesDispose = mountWorkspaceBadges(workspaces.map((workspace) => ({
135
+ id: workspace.id,
136
+ title: workspace.title,
137
+ alias: workspace.alias,
138
+ remoteRoot: workspace.remoteRoot,
139
+ })), connectedAliases)
140
+ } catch (error) {
141
+ console.warn('[dsh-hardssh] badge refresh failed:', error)
142
+ }
143
+ }
144
+ disposers.push(() => { badgesDispose?.() })
145
+ // Same-source sync: no independent fetch or timer — the manager emits on
146
+ // its 3s poll and on create/remove/rename; the first pass lands when the
147
+ // manager's initial refresh resolves (start() runs before this subscribe
148
+ // is registered, and its first emit happens after the synchronous body).
149
+ disposers.push(manager.subscribe(() => { refreshBadges() }))
150
+
151
+ // Connection-state poll: keep badge colors current (same cadence as the
152
+ // manager's workspace poll). Failure keeps everything gray (safe).
153
+ const refreshConnections = async (): Promise<void> => {
154
+ try {
155
+ connectedAliases = new Set(await sshApi.connectedAliases())
156
+ } catch (error) {
157
+ console.warn('[dsh-hardssh] connection-state poll failed:', error)
158
+ connectedAliases = new Set()
159
+ }
160
+ refreshBadges()
161
+ }
162
+ const connTimer = window.setInterval(() => { void refreshConnections() }, 3000)
163
+ disposers.push(() => { window.clearInterval(connTimer) })
164
+ void refreshConnections()
165
+
166
+ // Click gate: probing a workspace row must first establish the SSH
167
+ // connection (host-key TOFU confirm + session password dialogs), so the
168
+ // shell never opens a workspace that silently fails underneath.
169
+ let gateDispose: (() => void) | undefined
170
+ const refreshGates = (): void => {
171
+ try {
172
+ const workspaces = manager.getSnapshot().workspaces
173
+ gateDispose?.()
174
+ gateDispose = mountWorkspaceGates(workspaces.map((workspace) => ({
175
+ id: workspace.id,
176
+ title: workspace.title,
177
+ alias: workspace.alias,
178
+ })), sshApi)
179
+ } catch (error) {
180
+ console.warn('[dsh-hardssh] gate refresh failed:', error)
181
+ }
182
+ }
183
+ disposers.push(() => { gateDispose?.() })
184
+ disposers.push(manager.subscribe(() => { refreshGates() }))
185
+ } catch (error) {
186
+ console.warn('[dsh-hardssh] mount failed:', error)
187
+ }
188
+
189
+ // One-time migration: the old build tracked a per-session GLOBAL local⇄remote
190
+ // mode in localStorage (`ssh-session-state:<id>`). Convert every remembered
191
+ // remote target into a real SSH-bound workspace record, then forget the
192
+ // legacy state. Runs async and best-effort; a failure only logs.
193
+ void migrateLegacySessionMemory(api).catch((error: unknown) => {
194
+ console.warn('[dsh-hardssh] legacy migration skipped:', error)
195
+ })
196
+
197
+ // Language mirroring (the shell owns <html lang>; the dictionary follows).
198
+ const syncLanguage = (): void => {
199
+ setLanguage(document.documentElement.lang?.startsWith('zh') ?? false)
200
+ }
201
+ const langObserver = new MutationObserver(syncLanguage)
202
+ langObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
203
+ syncLanguage()
204
+
205
+ ctx.effect(() => () => {
206
+ manager.stop()
207
+ langObserver.disconnect()
208
+ for (const dispose of disposers.splice(0)) dispose()
209
+ }, 'dsh-hardssh: wiring')
210
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Locale dictionaries for the dsh-hardssh surface (zh/en).
3
+ * The key union derives from the zh dictionary (mirrors the dsh-ssh locale
4
+ * pattern), so the LocaleNamespaceMap augmentation types the `t` seat and
5
+ * the register call against exactly the shipped keys.
6
+ */
7
+
8
+ /** Locale namespace this plugin owns. */
9
+ export const NS = 'dsh-hardssh'
10
+
11
+ /** The zh dictionary (the key union source). */
12
+ export const zh = {
13
+ 'manager.label': 'SSH 工作区',
14
+ 'manager.tooltip': '管理 SSH 工作区:绑定服务器目录,绑定后的会话在远程操作,其余会话在本机',
15
+ 'manager.title': 'SSH 工作区管理',
16
+ 'manager.empty': '还没有 SSH 工作区',
17
+ 'manager.delete': '删除',
18
+ 'manager.editServer': '编辑服务器',
19
+ 'manager.deleteServer': '删除服务器',
20
+ 'manager.newHost': '新建服务器',
21
+ 'manager.hostInUse': '该服务器仍有 %d 个工作区,请先删除工作区',
22
+ 'create.host': '服务器',
23
+ 'create.hostEmpty': '未配置 SSH 主机,请先在设置页配置',
24
+ 'create.dir': '远程目录',
25
+ 'create.dirPlaceholder': '点击「浏览」选择远程目录,或输入绝对路径(如 /data/home/user/project)',
26
+ 'create.browse': '浏览…',
27
+ 'create.up': '上级',
28
+ 'create.current': '当前:',
29
+ 'create.titleLabel': '工作区名称',
30
+ 'create.titlePlaceholder': '如 zebrafish、atac-pipeline',
31
+ 'create.submit': '创建',
32
+ 'create.cancel': '取消',
33
+ 'create.connecting': '正在连接…',
34
+ 'create.needHost': '请先选择服务器',
35
+ 'create.needDir': '请选择或输入远程目录',
36
+ 'create.needTitle': '请填写工作区名称',
37
+ 'create.needConnect': '多次交互后仍未连接成功,请检查服务器或在 SSH 面板中重试',
38
+ 'host.add': '添加服务器…',
39
+ 'host.save': '保存服务器',
40
+ 'host.needFields': '别名、主机、用户名为必填项',
41
+ 'host.needPassword': '密码为必填项(当前仅支持密码认证)',
42
+ 'dialog.alias': '别名',
43
+ 'dialog.host': '主机',
44
+ 'dialog.port': '端口',
45
+ 'dialog.user': '用户名',
46
+ 'dialog.password': '密码',
47
+ 'dialog.passwordHint': '可留空——首次连接或浏览目录时再输入',
48
+ 'flow.local': '本地工作区…',
49
+ 'flow.ssh': 'SSH 工作区…',
50
+ 'flow.useThisDir': '使用此目录',
51
+ 'panel.loading': '加载中…',
52
+ 'gate.failed': '连接 SSH 服务器 %s 失败',
53
+ 'gate.retryLimit': '多次交互后仍未连上,请稍后重试或在 SSH 面板检查服务器',
54
+ }
55
+
56
+ /** The key union (used by the LocaleNamespaceMap augmentation). */
57
+ export type WorkspaceKey = keyof typeof zh
58
+
59
+ /** The en dictionary (same key set). */
60
+ export const en: Record<WorkspaceKey, string> = {
61
+ 'manager.label': 'SSH workspaces',
62
+ 'manager.tooltip': 'Manage SSH workspaces: bind a server directory — sessions in a bound workspace operate remotely, all others stay local',
63
+ 'manager.title': 'SSH Workspaces',
64
+ 'manager.empty': 'No SSH workspaces yet',
65
+ 'manager.delete': 'Delete',
66
+ 'manager.editServer': 'Edit server',
67
+ 'manager.deleteServer': 'Delete server',
68
+ 'manager.newHost': 'New server',
69
+ 'manager.hostInUse': 'This server still backs %d workspace(s) — delete them first',
70
+ 'create.host': 'Server',
71
+ 'create.hostEmpty': 'No SSH host configured — add one in Settings first',
72
+ 'create.dir': 'Remote directory',
73
+ 'create.dirPlaceholder': 'Click Browse to pick a remote directory, or type an absolute path (e.g. /data/home/user/project)',
74
+ 'create.browse': 'Browse…',
75
+ 'create.up': 'Up',
76
+ 'create.current': 'Current: ',
77
+ 'create.titleLabel': 'Workspace name',
78
+ 'create.titlePlaceholder': 'e.g. zebrafish, atac-pipeline',
79
+ 'create.submit': 'Create',
80
+ 'create.cancel': 'Cancel',
81
+ 'create.connecting': 'Connecting…',
82
+ 'create.needHost': 'Pick a server first',
83
+ 'create.needDir': 'Pick or type a remote directory',
84
+ 'create.needTitle': 'Give the workspace a name',
85
+ 'create.needConnect': 'Still not connected after several attempts — check the server or retry in the SSH panel',
86
+ 'host.add': 'Add server…',
87
+ 'host.save': 'Save server',
88
+ 'host.needFields': 'Alias, host and user are required',
89
+ 'host.needPassword': 'Password is required (password auth only for now)',
90
+ 'dialog.alias': 'Alias',
91
+ 'dialog.host': 'Host',
92
+ 'dialog.port': 'Port',
93
+ 'dialog.user': 'User',
94
+ 'dialog.password': 'Password',
95
+ 'dialog.passwordHint': 'Optional — enter when first connecting or browsing',
96
+ 'flow.local': 'Local workspace…',
97
+ 'flow.ssh': 'SSH workspace…',
98
+ 'flow.useThisDir': 'Use this directory',
99
+ 'panel.loading': 'Loading…',
100
+ 'gate.failed': 'Failed to connect SSH server %s',
101
+ 'gate.retryLimit': 'Still not connected after several attempts — retry later or check the server in the SSH panel',
102
+ }
103
+
104
+ /** The dictionary pair registered into the locale service. */
105
+ export const dictionaries = { zh, en } as const
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The SSH workspace manager: a session-header button opening a dropdown that
3
+ * lists SSH-bound workspaces grouped by server (two-level: server → its
4
+ * workspaces) and lets the operator:
5
+ * - delete a workspace (existing),
6
+ * - edit / delete a SERVER (gear / minus on each server row; deleting a
7
+ * server is allowed only when it backs no workspace — the API also enforces
8
+ * this with a 409 HOST_IN_USE),
9
+ * - create a new server (bottom action), reusing the host form dialog.
10
+ * Rendering is a portal dropdown with its own React root (the shell exposes
11
+ * no modal slot for external plugins).
12
+ */
13
+ import { createRoot } from 'react-dom/client'
14
+ import { useEffect, useRef, useState, type ReactElement } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+ import type { WorkspaceManager } from './state.ts'
17
+ import type { SshWorkspaceRecord } from '../protocol.ts'
18
+ import { tt } from './text.ts'
19
+ import css from './workspace.module.css'
20
+ import { CloudIcon, ComputerIcon } from './icons.tsx'
21
+ import type { SshHostSummary } from '../ssh/protocol.ts'
22
+ import { HostFormDialog } from './ssh/panel/HostFormDialog.tsx'
23
+ import type { SshApi } from './ssh/api.ts'
24
+
25
+ /** The session-header button. Slot-injected props: manager + sshApi. */
26
+ export function WorkspaceManagerButton(props: {
27
+ manager: WorkspaceManager
28
+ /** Full SSH host API (list/create/update/delete) for server management. */
29
+ sshApi?: SshApi
30
+ sessionId?: unknown
31
+ useSession?: unknown
32
+ useSessions?: unknown
33
+ useWorkspaces?: unknown
34
+ useProjection?: unknown
35
+ useInput?: unknown
36
+ inputActions?: unknown
37
+ renderSlot?: unknown
38
+ renderSlotChain?: unknown
39
+ t?: unknown
40
+ }): ReactElement {
41
+ return (
42
+ <button
43
+ type="button"
44
+ className={css.managerButton}
45
+ data-ssh-workspace-manager=""
46
+ title={tt('manager.tooltip')}
47
+ onClick={(event) => openManager(props.manager, props.sshApi, event.currentTarget)}
48
+ >
49
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
50
+ <rect x="2" y="3" width="12" height="10" rx="2" />
51
+ <path d="M6 6.5l2.2 1.6L6 9.7" />
52
+ <path d="M9.5 9.5h2" />
53
+ </svg>
54
+ <span>{tt('manager.label')}</span>
55
+ </button>
56
+ )
57
+ }
58
+
59
+ /** Open the manager dropdown, anchored under the trigger button. */
60
+ export function openManager(manager: WorkspaceManager, sshApi?: SshApi, trigger?: HTMLElement): void {
61
+ const element = document.createElement('div')
62
+ element.dataset.sshWorkspaceManagerOverlay = ''
63
+ document.body.appendChild(element)
64
+ const root = createRoot(element)
65
+ const close = (): void => {
66
+ root.unmount()
67
+ element.remove()
68
+ }
69
+ let anchor: { bottom: number; left: number } | undefined
70
+ if (trigger !== undefined) {
71
+ const rect = trigger.getBoundingClientRect()
72
+ anchor = { bottom: rect.bottom, left: rect.left }
73
+ }
74
+ root.render(<ManagerOverlay manager={manager} sshApi={sshApi} onClose={close} anchor={anchor} />)
75
+ }
76
+
77
+ /** Group workspaces by server alias for the two-level listing. */
78
+ function groupByServer(workspaces: SshWorkspaceRecord[]): Array<{ alias: string; workspaces: SshWorkspaceRecord[] }> {
79
+ const map = new Map<string, SshWorkspaceRecord[]>()
80
+ for (const workspace of workspaces) {
81
+ const list = map.get(workspace.alias)
82
+ if (list === undefined) map.set(workspace.alias, [workspace])
83
+ else list.push(workspace)
84
+ }
85
+ return [...map.entries()].map(([alias, workspaces]) => ({ alias, workspaces }))
86
+ }
87
+
88
+ /** The host-form dialog invocation state. */
89
+ type HostDialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
90
+
91
+ /** The manager dropdown: server + workspace rows, with server CRUD. */
92
+ export function ManagerOverlay(props: {
93
+ manager: WorkspaceManager
94
+ sshApi?: SshApi
95
+ onClose: () => void
96
+ anchor?: { bottom: number; left: number }
97
+ }): ReactElement {
98
+ const [workspaces, setWorkspaces] = useState<SshWorkspaceRecord[] | null>(null)
99
+ const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
100
+ const [error, setError] = useState<string | null>(null)
101
+ const [deleting, setDeleting] = useState<string | null>(null)
102
+ const [deletingAlias, setDeletingAlias] = useState<string | null>(null)
103
+ const [hostDialog, setHostDialog] = useState<HostDialogState | null>(null)
104
+ const menuRef = useRef<HTMLDivElement | null>(null)
105
+
106
+ // Close when a click lands OUTSIDE the dropdown.
107
+ useEffect(() => {
108
+ const onMouseDown = (event: MouseEvent): void => {
109
+ if (menuRef.current !== null && menuRef.current.contains(event.target as Node)) return
110
+ props.onClose()
111
+ }
112
+ document.addEventListener('mousedown', onMouseDown)
113
+ return () => document.removeEventListener('mousedown', onMouseDown)
114
+ }, [props])
115
+
116
+ const load = (): void => {
117
+ setWorkspaces(props.manager.getSnapshot().workspaces)
118
+ }
119
+
120
+ const loadHosts = (): void => {
121
+ if (props.sshApi === undefined) return
122
+ void props.sshApi.listHosts().then(
123
+ (list) => { setHosts(list) },
124
+ (cause: unknown) => {
125
+ setError(cause instanceof Error ? cause.message : String(cause))
126
+ setHosts([])
127
+ },
128
+ )
129
+ }
130
+
131
+ useEffect(() => {
132
+ load()
133
+ void props.manager.refresh()
134
+ const unsubscribe = props.manager.subscribe(() => { load() })
135
+ loadHosts()
136
+ return () => { unsubscribe() }
137
+ // eslint-disable-next-line react-hooks/exhaustive-deps
138
+ }, [])
139
+
140
+ const remove = (id: string): void => {
141
+ setDeleting(id)
142
+ void props.manager.remove(id)
143
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
144
+ .finally(() => setDeleting(null))
145
+ }
146
+
147
+ const deleteHost = (alias: string): void => {
148
+ if (props.sshApi === undefined) return
149
+ setDeletingAlias(alias)
150
+ void props.sshApi.deleteHost(alias)
151
+ .then(() => { loadHosts() })
152
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
153
+ .finally(() => setDeletingAlias(null))
154
+ }
155
+
156
+ const savedHost = (): void => {
157
+ setHostDialog(null)
158
+ loadHosts()
159
+ void props.manager.refresh()
160
+ }
161
+
162
+ const anchorTop = props.anchor !== undefined ? props.anchor.bottom + 4 : 56
163
+ const anchorLeft = props.anchor !== undefined ? props.anchor.left : Math.max(8, window.innerWidth - 380)
164
+ const top = Math.max(8, Math.min(anchorTop, window.innerHeight - 340))
165
+ const left = Math.max(8, Math.min(anchorLeft, window.innerWidth - 380))
166
+
167
+
168
+ return createPortal(
169
+ <div
170
+ ref={menuRef}
171
+ className={css.menuDropdown}
172
+ data-ssh-workspace-manager-menu=""
173
+ style={{ top, left, width: 380 }}
174
+ onClick={(event) => event.stopPropagation()}
175
+ >
176
+ <p className={css.managerMenuHeader}>
177
+ {tt('manager.title')}
178
+ <button type="button" className={css.managerMenuClose} onClick={props.onClose} aria-label={tt('create.cancel')}>&times;</button>
179
+ </p>
180
+
181
+ {error !== null && <div className={css.dialogFail}>{error}</div>}
182
+
183
+ {workspaces === null && hosts === null && <div className={css.dialogInfo}>{tt('panel.loading')}</div>}
184
+
185
+ {workspaces !== null && workspaces.length === 0 && (
186
+ <div className={css.dialogHint}>{tt('manager.empty')}</div>
187
+ )}
188
+
189
+ {/* Server rows (edit + delete) with their workspaces underneath */}
190
+ {hosts !== null && hosts.map((host) => {
191
+ const hostWorkspaces = workspaces === null ? [] : workspaces.filter(w => w.alias === host.alias)
192
+ const inUse = hostWorkspaces.length > 0
193
+ return (
194
+ <div key={host.alias} style={{ marginBottom: 10 }}>
195
+ <div className={css.managerGroupHeader} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
196
+ <ComputerIcon size={13} />
197
+ <span style={{ fontWeight: 500, fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
198
+ {host.alias}
199
+ </span>
200
+ <span className={css.hostMeta} style={{ fontSize: 11 }}>{host.user}@{host.host}:{host.port}</span>
201
+ {/* Gear: edit server */}
202
+ <button
203
+ type="button"
204
+ className={css.menuItemIconButton}
205
+ title={tt('manager.editServer')}
206
+ aria-label={tt('manager.editServer')}
207
+ onClick={() => { setHostDialog({ mode: 'edit', host }) }}
208
+ >
209
+
210
+ </button>
211
+ {/* Minus: delete server (disabled while it backs workspaces) */}
212
+ <button
213
+ type="button"
214
+ className={css.menuItemIconButton}
215
+ title={inUse ? tt('manager.hostInUse', hostWorkspaces.length) : tt('manager.deleteServer')}
216
+ aria-label={tt('manager.deleteServer')}
217
+ disabled={inUse || deletingAlias === host.alias}
218
+ onClick={() => { if (!inUse) deleteHost(host.alias) }}
219
+ >
220
+
221
+ </button>
222
+ </div>
223
+ {hostWorkspaces.map((workspace) => (
224
+ <div key={workspace.id} className={css.hostRow} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px' }}>
225
+ <CloudIcon size={12} />
226
+ <span className={css.hostAlias} style={{ fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
227
+ {workspace.title}
228
+ </span>
229
+ <span className={css.hostMeta} style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 150 }}>
230
+ {workspace.remoteRoot}
231
+ </span>
232
+ <button
233
+ type="button"
234
+ className={`${css.button} ${css.danger}`}
235
+ style={{ flex: 'none', padding: '2px 8px', fontSize: 12 }}
236
+ disabled={deleting === workspace.id}
237
+ onClick={() => { if (!deleting) remove(workspace.id) }}
238
+ >
239
+ {deleting === workspace.id ? '…' : tt('manager.delete')}
240
+ </button>
241
+ </div>
242
+ ))}
243
+ </div>
244
+ )
245
+ })}
246
+
247
+ {/* Bottom: create a new server */}
248
+ {props.sshApi !== undefined && (
249
+ <div style={{ marginTop: 4, borderTop: `1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.1))`, paddingTop: 6 }}>
250
+ <button type="button" className={css.menuItem} onClick={() => { setHostDialog({ mode: 'create' }) }}>
251
+ <span className={css.menuItemIcon}>+</span>
252
+ <span>{tt('manager.newHost')}</span>
253
+ </button>
254
+ </div>
255
+ )}
256
+
257
+ {hostDialog !== null && props.sshApi !== undefined && (
258
+ <HostFormDialog
259
+ api={props.sshApi}
260
+ editing={hostDialog.mode === 'edit' ? hostDialog.host : null}
261
+ onClose={() => { setHostDialog(null) }}
262
+ onSaved={savedHost}
263
+ />
264
+ )}
265
+ </div>,
266
+ document.body,
267
+ )
268
+ }
269
+