@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,482 @@
1
+ /**
2
+ * The directory-flow occupant replacing the native-only "Add workspace…"
3
+ * interaction. When the operator clicks the native "Add workspace…" entry,
4
+ * this component opens a small chooser with two options:
5
+ *
6
+ * - "Local workspace": delegates to the host native directory chooser
7
+ * (`host.pickDirectory`) and hands the picked path back through the owner
8
+ * conversation — exactly the original behavior.
9
+ * - "SSH workspace": opens the SSH workspace creation dialog (pick a host,
10
+ * browse a remote directory, name it). On success it calls
11
+ * `onPicked(anchorPath)` so the host adopts the LOCAL ANCHOR directory as
12
+ * a normal workspace (the seams route that workspace remote by its anchor
13
+ * path). On cancel it calls `onCancel`.
14
+ *
15
+ * Occupancy: both directory-flow holes are `kind: 'single'`, so registering
16
+ * here replaces the native-only occupant. That is intended — we restore the
17
+ * native path ourselves via `pickDirectory`.
18
+ *
19
+ * @module dsh-hardssh/client/directory-flow
20
+ */
21
+
22
+ import { useEffect, useRef, useState, type ReactElement } from 'react'
23
+ import { createPortal } from 'react-dom'
24
+ // Type-only: pulls the SlotMap augmentation (the directory-flow holes) from
25
+ // the ui-workspace package, plus the DirectoryFlowOwnerProps contract.
26
+ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
27
+ import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
28
+ import type { SshWorkspaceRecord } from '../protocol.ts'
29
+ import { tt } from './text.ts'
30
+ import css from './workspace.module.css'
31
+ import { CloudIcon, ComputerIcon } from './icons.tsx'
32
+
33
+ /** The injected face: host native picker + our workspace manager API. */
34
+ export interface DirectoryFlowInjected {
35
+ /** Ask the local Host to open its native single-directory chooser. */
36
+ pickDirectory: () => Promise<string | null>
37
+ /** Create an SSH workspace (host API). */
38
+ createSshWorkspace: (input: { title: string; alias: string; remoteRoot: string }) => Promise<SshWorkspaceRecord>
39
+ /** List configured SSH hosts (host API). */
40
+ listHosts: () => Promise<Array<{ alias: string; host: string; port: number; user: string }>>
41
+ /** Create a new SSH host (dsh-ssh host store; auth is required). */
42
+ createHost: (input: {
43
+ alias: string
44
+ host: string
45
+ port?: number
46
+ user: string
47
+ auth: { kind: 'password'; password: string } | { kind: 'key'; keyPath: string; passphrase?: string }
48
+ }) => Promise<{ alias: string; host: string; port: number; user: string }>
49
+ /** Browse a remote directory (host API). */
50
+ listRemoteDir: (alias: string, path?: string) => Promise<{ path: string; entries: Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }> }>
51
+ /** Interactive SSH connection gate: host-key TOFU + session-password
52
+ * dialogs. Resolves true when the alias is connected (or the user
53
+ * cancelled the prompts). Used to retry browsing. */
54
+ ensureConnected: (alias: string) => Promise<boolean>
55
+ }
56
+
57
+ /** The two entry choices after "Add workspace…". */
58
+ type Choice = 'menu' | 'ssh' | 'browsing' | 'picking-local'
59
+
60
+ /** A resolved anchor rect for the dropdown. */
61
+ interface AnchorRect {
62
+ top: number
63
+ left: number
64
+ bottom: number
65
+ width: number
66
+ }
67
+
68
+ /** Labels the host "Add workspace…" entry can carry (zh / en). */
69
+ const ADD_WORKSPACE_LABELS = ['添加工作区', 'Add workspace']
70
+
71
+ /**
72
+ * Find the host "Add workspace…" trigger element. It is a button/menu item
73
+ * rendered by ui-workspace; match by visible text (case-insensitive, label
74
+ * may include "…"/suffix). Prefer the LEFT sidebar container
75
+ * (`[data-pane="sidebar"]` or a class mentioning the workspace browser) —
76
+ * the workspace list lives there — and fall back to a whole-document scan so
77
+ * the empty-state hero trigger works too. To avoid anchoring under a
78
+ * RIGHT-side surface (another plugin's panel), the document scan prefers a
79
+ * match whose horizontal center is in the LEFT half of the viewport.
80
+ */
81
+ function findAddWorkspaceTrigger(): AnchorRect | undefined {
82
+ const matchText = (el: HTMLElement): boolean => {
83
+ const text = el.textContent?.trim() ?? ''
84
+ return ADD_WORKSPACE_LABELS.some((label) => text.includes(label))
85
+ }
86
+ const rectOf = (el: HTMLElement): AnchorRect | undefined => {
87
+ const rect = el.getBoundingClientRect()
88
+ if (rect.width > 0 && rect.height > 0) {
89
+ return { top: rect.top, left: rect.left, bottom: rect.bottom, width: rect.width }
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ // 1) Left sidebar container preferred.
95
+ for (const root of document.querySelectorAll<HTMLElement>('[data-pane="sidebar"], [class*="workspaceBrowser"], [class*="sidebar"]')) {
96
+ for (const el of root.querySelectorAll<HTMLElement>('button, [role="menuitem"], [role="option"], li')) {
97
+ if (matchText(el)) {
98
+ const rect = rectOf(el)
99
+ if (rect !== undefined) return rect
100
+ }
101
+ }
102
+ }
103
+
104
+ // 2) Whole-document fallback; prefer a LEFT-half match.
105
+ let fallback: AnchorRect | undefined
106
+ for (const el of document.querySelectorAll<HTMLElement>('button, [role="menuitem"], [role="option"], li')) {
107
+ if (!matchText(el)) continue
108
+ const rect = rectOf(el)
109
+ if (rect === undefined) continue
110
+ const centerX = rect.left + rect.width / 2
111
+ if (centerX < window.innerWidth / 2) return rect
112
+ if (fallback === undefined) fallback = rect
113
+ }
114
+ return fallback
115
+ }
116
+
117
+ /**
118
+ * The flow occupant. Each rising `open` edge shows the menu; the ref arms
119
+ * once per open so re-renders (and an adoption keeping `open` true while
120
+ * `busy`) never launch a second flow.
121
+ */
122
+ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInjected): ReactElement | null {
123
+ const { open, busy, onPicked, onCancel, onError } = props
124
+ const outcome = useRef(props)
125
+ outcome.current = props
126
+ const [choice, setChoice] = useState<Choice>('menu')
127
+ const [host, setHost] = useState('')
128
+ const [dirPath, setDirPath] = useState('')
129
+ const [title, setTitle] = useState('')
130
+ const [hosts, setHosts] = useState<Array<{ alias: string; host: string; port: number; user: string }>>([])
131
+ const [dirEntries, setDirEntries] = useState<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>>([])
132
+ const [loading, setLoading] = useState(false)
133
+ const [creating, setCreating] = useState(false)
134
+ const [error, setError] = useState<string | null>(null)
135
+ const [anchor, setAnchor] = useState<AnchorRect | undefined>(undefined)
136
+ // "Add server…" mini-form inside the SSH branch.
137
+ const [addingHost, setAddingHost] = useState(false)
138
+ const [newHost, setNewHost] = useState({ alias: '', host: '', port: '22', user: '', password: '' })
139
+ const [savingHost, setSavingHost] = useState(false)
140
+
141
+ // Reset and re-locate on each open edge.
142
+ useEffect(() => {
143
+ if (!open) return
144
+ setChoice('menu')
145
+ setHost('')
146
+ setDirPath('')
147
+ setTitle('')
148
+ setDirEntries([])
149
+ setError(null)
150
+ setAddingHost(false)
151
+ setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
152
+ setAnchorRect()
153
+ // Lazy-load the host list for the SSH branch.
154
+ props.listHosts().then((list) => setHosts(list)).catch(() => setHosts([]))
155
+ }, [open])
156
+
157
+ /** Locate the trigger element that just opened the flow and anchor the menu
158
+ * directly under it. Priority: the focused element (the button the operator
159
+ * just clicked → document.activeElement), then a text-scan for the "Add
160
+ * workspace…" entry. Falls back to top-left of the viewport. */
161
+ const setAnchorRect = (): void => {
162
+ // 1) The element that had focus when `open` flipped true is almost
163
+ // always the trigger button (the owner sets open on click).
164
+ const active = document.activeElement as HTMLElement | null
165
+ if (active !== null && active !== document.body) {
166
+ const rect = active.getBoundingClientRect()
167
+ if (rect.width > 0 && rect.height > 0) {
168
+ setAnchor({ top: rect.top, left: rect.left, bottom: rect.bottom, width: rect.width })
169
+ return
170
+ }
171
+ }
172
+ // 2) Text-scan fallback.
173
+ const trigger = findAddWorkspaceTrigger()
174
+ if (trigger !== undefined) {
175
+ setAnchor(trigger)
176
+ return
177
+ }
178
+ // 3) Last resort: top-left of the viewport.
179
+ setAnchor({ top: 56, left: 16, bottom: 56, width: 200 })
180
+ }
181
+
182
+ // Close when a click lands OUTSIDE the dropdown (no full-screen capture
183
+ // layer, so the trigger button and the rest of the UI stay clickable).
184
+ const menuRef = useRef<HTMLDivElement | null>(null)
185
+ useEffect(() => {
186
+ if (!open) return
187
+ const onMouseDown = (event: MouseEvent): void => {
188
+ if (menuRef.current !== null && menuRef.current.contains(event.target as Node)) return
189
+ if (!busy) outcome.current.onCancel()
190
+ }
191
+ document.addEventListener('mousedown', onMouseDown)
192
+ return () => document.removeEventListener('mousedown', onMouseDown)
193
+ }, [open, busy])
194
+
195
+ if (!open) return null
196
+
197
+ /** Choose "Local workspace": drive the native chooser once. */
198
+ const pickLocal = (): void => {
199
+ setChoice('picking-local')
200
+ props.pickDirectory().then(
201
+ (path) => {
202
+ if (path === null) {
203
+ outcome.current.onCancel()
204
+ } else {
205
+ outcome.current.onPicked(path)
206
+ }
207
+ },
208
+ (reason) => outcome.current.onError(reason instanceof Error ? reason.message : String(reason)),
209
+ )
210
+ }
211
+
212
+ /** Browse a remote dir level for the SSH branch. When the connection needs
213
+ * a credential (host key untrusted / session password), run the interactive
214
+ * gate first, then retry — the operator never sees a raw "connect" error. */
215
+ const browse = async (alias: string, path?: string): Promise<void> => {
216
+ if (alias === '') {
217
+ setError(tt('create.needHost'))
218
+ return
219
+ }
220
+ for (let attempt = 0; attempt < 3; attempt++) {
221
+ setLoading(true)
222
+ setError(null)
223
+ try {
224
+ const result = await props.listRemoteDir(alias, path)
225
+ setDirPath(result.path)
226
+ setDirEntries(result.entries)
227
+ setChoice('browsing')
228
+ setLoading(false)
229
+ return
230
+ } catch (e: unknown) {
231
+ const code = (e as { code?: string })?.code
232
+ const interactive = code === 'NEEDS_PASSWORD' || code === 'HOST_KEY_UNKNOWN' || code === 'HOST_KEY_MISMATCH'
233
+ setLoading(false)
234
+ if (!interactive) {
235
+ setError(e instanceof Error ? e.message : String(e))
236
+ return
237
+ }
238
+ if (!await props.ensureConnected(alias)) return // user cancelled
239
+ }
240
+ }
241
+ setError(tt('create.needConnect'))
242
+ }
243
+
244
+ /** Create the SSH workspace; on success hand the anchor to the owner. */
245
+ const createSsh = async (): Promise<void> => {
246
+ if (host === '' || dirPath === '' || title.trim() === '') {
247
+ setError(host === '' ? tt('create.needHost') : dirPath === '' ? tt('create.needDir') : tt('create.needTitle'))
248
+ return
249
+ }
250
+ setCreating(true)
251
+ setError(null)
252
+ try {
253
+ const record = await props.createSshWorkspace({ title: title.trim(), alias: host, remoteRoot: dirPath })
254
+ // The anchor is a REAL local dir (host workspace registration happens on
255
+ // the server); adopt it so the host workspace list refreshes.
256
+ outcome.current.onPicked(record.anchorPath)
257
+ } catch (e: unknown) {
258
+ setError(e instanceof Error ? e.message : String(e))
259
+ } finally {
260
+ // Always reset: the owner may keep the flow open after onPicked (e.g.
261
+ // a host-side adoption error), and a stuck `creating=true` would lock
262
+ // the button forever.
263
+ setCreating(false)
264
+ }
265
+ }
266
+
267
+ /** Save a newly-added SSH host, refresh the list, select it, and close the
268
+ * add-server form. The password is OPTIONAL at creation — adding never
269
+ * connects; the credential is asked when browsing/connecting starts. */
270
+ const saveHost = async (): Promise<void> => {
271
+ if (newHost.alias.trim() === '' || newHost.host.trim() === '' || newHost.user.trim() === '') {
272
+ setError(tt('host.needFields'))
273
+ return
274
+ }
275
+ setSavingHost(true)
276
+ setError(null)
277
+ try {
278
+ const created = await props.createHost({
279
+ alias: newHost.alias.trim(),
280
+ host: newHost.host.trim(),
281
+ port: Number.parseInt(newHost.port, 10) || 22,
282
+ user: newHost.user.trim(),
283
+ auth: { kind: 'password', password: newHost.password },
284
+ })
285
+ const list = await props.listHosts()
286
+ setHosts(list)
287
+ setHost(created.alias)
288
+ setAddingHost(false)
289
+ setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
290
+ } catch (e: unknown) {
291
+ setError(e instanceof Error ? e.message : String(e))
292
+ } finally {
293
+ setSavingHost(false)
294
+ }
295
+ }
296
+
297
+ // Position the dropdown under the trigger, clamped inside the viewport.
298
+ // The "add server" mini-form is taller — give it room so nothing clips.
299
+ const menuWidth = choice === 'menu' ? 200 : 360
300
+ const menuHeight = addingHost ? Math.min(560, window.innerHeight - 16) : 320
301
+ const rawTop = anchor !== undefined ? anchor.bottom + 4 : 56
302
+ const rawLeft = anchor !== undefined ? anchor.left : Math.max(8, window.innerWidth - menuWidth - 8)
303
+ const top = Math.max(8, Math.min(rawTop, window.innerHeight - menuHeight - 8))
304
+ const left = Math.max(8, Math.min(rawLeft, window.innerWidth - menuWidth - 8))
305
+
306
+ return createPortal(
307
+ <div
308
+ ref={menuRef}
309
+ className={css.menuDropdown}
310
+ data-ssh-workspace-flow-menu=""
311
+ role="menu"
312
+ style={{
313
+ top,
314
+ left,
315
+ width: menuWidth,
316
+ maxHeight: menuHeight,
317
+ overflowY: 'auto',
318
+ }}
319
+ onClick={(event) => event.stopPropagation()}
320
+ >
321
+ {error !== null && <div className={css.dialogFail}>{error}</div>}
322
+
323
+ {choice === 'menu' && (
324
+ <>
325
+ <button
326
+ type="button"
327
+ className={css.menuItem}
328
+ role="menuitem"
329
+ disabled={busy}
330
+ onClick={pickLocal}
331
+ >
332
+ <span className={css.menuItemIcon}><ComputerIcon size={14} /></span>
333
+ <span>{tt('flow.local')}</span>
334
+ </button>
335
+ <button
336
+ type="button"
337
+ className={css.menuItem}
338
+ role="menuitem"
339
+ disabled={busy}
340
+ onClick={() => setChoice('ssh')}
341
+ >
342
+ <span className={css.menuItemIcon}><CloudIcon size={14} /></span>
343
+ <span>{tt('flow.ssh')}</span>
344
+ </button>
345
+ <div className={css.menuDivider} />
346
+ <button
347
+ type="button"
348
+ className={css.menuItem}
349
+ role="menuitem"
350
+ onClick={() => outcome.current.onCancel()}
351
+ >
352
+ <span>{tt('create.cancel')}</span>
353
+ </button>
354
+ </>
355
+ )}
356
+
357
+ {choice === 'ssh' && (
358
+ <div className={css.dialogGrid}>
359
+ {addingHost ? (
360
+ <>
361
+ <div style={{ gridColumn: '1 / -1', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
362
+ <label className={css.field}>
363
+ <span>{tt('dialog.alias')}</span>
364
+ <input value={newHost.alias} onChange={(e) => setNewHost((p) => ({ ...p, alias: e.target.value }))} placeholder="my-server" spellCheck={false} />
365
+ </label>
366
+ <label className={css.field}>
367
+ <span>{tt('dialog.host')}</span>
368
+ <input value={newHost.host} onChange={(e) => setNewHost((p) => ({ ...p, host: e.target.value }))} placeholder="1.2.3.4" spellCheck={false} />
369
+ </label>
370
+ <label className={css.field}>
371
+ <span>{tt('dialog.port')}</span>
372
+ <input value={newHost.port} onChange={(e) => setNewHost((p) => ({ ...p, port: e.target.value }))} inputMode="numeric" spellCheck={false} />
373
+ </label>
374
+ <label className={css.field}>
375
+ <span>{tt('dialog.user')}</span>
376
+ <input value={newHost.user} onChange={(e) => setNewHost((p) => ({ ...p, user: e.target.value }))} placeholder="root" spellCheck={false} />
377
+ </label>
378
+ <label className={css.field} style={{ gridColumn: '1 / -1' }}>
379
+ <span>{tt('dialog.password')}</span>
380
+ <input type="password" value={newHost.password} onChange={(e) => setNewHost((p) => ({ ...p, password: e.target.value }))} spellCheck={false} />
381
+ <span className={css.hostMeta}>{tt('dialog.passwordHint')}</span>
382
+ </label>
383
+ </div>
384
+ <div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
385
+ <button type="button" className={css.button} onClick={() => setAddingHost(false)} disabled={savingHost}>{tt('create.cancel')}</button>
386
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={() => void saveHost()} disabled={savingHost}>
387
+ {savingHost ? tt('create.connecting') : tt('host.save')}
388
+ </button>
389
+ </div>
390
+ </>
391
+ ) : (
392
+ <>
393
+ <label className={css.field} style={{ gridColumn: '1 / -1' }}>
394
+ <span>{tt('create.host')}</span>
395
+ <select value={host} onChange={(event) => setHost(event.target.value)}>
396
+ <option value="">{hosts.length === 0 ? tt('create.hostEmpty') : '—'}</option>
397
+ {hosts.map((item) => (
398
+ <option key={item.alias} value={item.alias}>{item.alias} ({item.user}@{item.host}:{item.port})</option>
399
+ ))}
400
+ </select>
401
+ </label>
402
+ <button
403
+ type="button"
404
+ className={css.menuItem}
405
+ style={{ gridColumn: '1 / -1', padding: '5px 8px' }}
406
+ onClick={() => setAddingHost(true)}
407
+ >
408
+ <span className={css.menuItemIcon}>+</span>
409
+ <span>{tt('host.add')}</span>
410
+ </button>
411
+ <label className={css.field} style={{ gridColumn: '1 / -1' }}>
412
+ <span>{tt('create.dir')}</span>
413
+ <input value={dirPath} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
414
+ </label>
415
+ <div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
416
+ <button type="button" className={css.button} disabled={busy || loading} onClick={() => void browse(host)}>
417
+ {loading ? tt('create.connecting') : tt('create.browse')}
418
+ </button>
419
+ </div>
420
+ <label className={css.field} style={{ gridColumn: '1 / -1' }}>
421
+ <span>{tt('create.titleLabel')}</span>
422
+ <input value={title} onChange={(event) => setTitle(event.target.value)} placeholder={tt('create.titlePlaceholder')} spellCheck={false} />
423
+ </label>
424
+ </>
425
+ )}
426
+ {!addingHost && (
427
+ <div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
428
+ <button type="button" className={css.button} onClick={() => setChoice('menu')} disabled={busy || creating}>{tt('create.cancel')}</button>
429
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || creating} onClick={() => void createSsh()}>
430
+ {creating ? tt('create.connecting') : tt('create.submit')}
431
+ </button>
432
+ </div>
433
+ )}
434
+ </div>
435
+ )}
436
+
437
+ {choice === 'browsing' && (
438
+ <div className={css.dialogGrid} style={{ gridTemplateColumns: '1fr' }}>
439
+ <div className={css.field} style={{ gridColumn: '1' }}>
440
+ <span>{tt('create.browse')} · {host}</span>
441
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
442
+ <button
443
+ type="button"
444
+ className={css.button}
445
+ disabled={loading || dirPath === '/'}
446
+ onClick={() => {
447
+ const parent = dirPath.split('/').slice(0, -1).join('/') || '/'
448
+ void browse(host, parent)
449
+ }}
450
+ >
451
+ {tt('create.up')}
452
+ </button>
453
+ <span className={css.hostMeta} style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{tt('create.current')}{dirPath}</span>
454
+ </div>
455
+ </div>
456
+ <div style={{ gridColumn: '1', maxHeight: 240, overflow: 'auto', border: '1px solid var(--aion-border, rgba(128,128,128,0.25))', borderRadius: 8, padding: 6 }}>
457
+ {loading && <div className={css.dialogInfo}>{tt('create.connecting')}</div>}
458
+ {dirEntries.filter((entry) => entry.type === 'dir').map((entry) => (
459
+ <button
460
+ key={entry.name}
461
+ type="button"
462
+ className={css.hostRow}
463
+ style={{ width: '100%', textAlign: 'left', display: 'flex', gap: 6, alignItems: 'center', border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px 6px' }}
464
+ onClick={() => { void browse(host, `${dirPath.replace(/\/+$/, '')}/${entry.name}`) }}
465
+ >
466
+ <span>📁</span>
467
+ <span className={css.hostAlias}>{entry.name}</span>
468
+ </button>
469
+ ))}
470
+ </div>
471
+ <div className={css.dialogActions} style={{ gridColumn: '1' }}>
472
+ <button type="button" className={css.button} onClick={() => setChoice('ssh')}>{tt('create.cancel')}</button>
473
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={() => setChoice('ssh')}>
474
+ {tt('flow.useThisDir')}
475
+ </button>
476
+ </div>
477
+ </div>
478
+ )}
479
+ </div>,
480
+ document.body,
481
+ )
482
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Workspace icons for the SSH workspace surfaces: a cloud with a return arrow
3
+ * (SSH/remote workspace) and a desktop monitor (local workspace). Inlined SVG
4
+ * (1024 viewBox, solid fill) — no icon library dependency. Both inherit the
5
+ * theme text color via `currentColor` so they stay legible in light/dark
6
+ * themes.
7
+ * @module dsh-hardssh/client/icons
8
+ */
9
+
10
+ import type { ReactElement } from 'react'
11
+
12
+ interface IconProps {
13
+ size?: number
14
+ className?: string
15
+ }
16
+
17
+ /** Desktop-monitor icon: a local workstation (monitor + stand). */
18
+ export function ComputerIcon({ size = 14, className }: IconProps): ReactElement {
19
+ return (
20
+ <svg
21
+ viewBox="0 0 1024 1024"
22
+ width={size}
23
+ height={size}
24
+ fill="currentColor"
25
+ aria-hidden="true"
26
+ className={className}
27
+ >
28
+ <path d="M873.6 788.48H150.4a91.52 91.52 0 0 1-92.16-91.52V256a91.52 91.52 0 0 1 92.16-90.88h723.2A91.52 91.52 0 0 1 965.76 256v440.32a91.52 91.52 0 0 1-92.16 92.16zM150.4 229.12a28.16 28.16 0 0 0-28.16 26.88v440.32a28.16 28.16 0 0 0 28.16 27.52h723.2a27.52 27.52 0 0 0 28.16-27.52V256a27.52 27.52 0 0 0-28.16-27.52z" />
29
+ <path d="M99.84 593.28h833.92v64H99.84zM762.24 931.84H261.12a32 32 0 0 1 0-64h501.12a32 32 0 0 1 32 32 32 32 0 0 1-32 32z" />
30
+ <path d="M383.36 771.2h64v110.08h-64zM588.16 771.2h64v110.08h-64z" />
31
+ </svg>
32
+ )
33
+ }
34
+
35
+ /** Cloud-with-return-arrow icon: a remote/SSH workspace. */
36
+ export function CloudIcon({ size = 14, className }: IconProps): ReactElement {
37
+ return (
38
+ <svg
39
+ viewBox="0 0 1024 1024"
40
+ width={size}
41
+ height={size}
42
+ fill="currentColor"
43
+ aria-hidden="true"
44
+ className={className}
45
+ >
46
+ <path d="M743.936 758.499556h-273.066667c-17.066667 0-34.133333-17.066667-34.133333-34.133334s17.066667-34.133333 34.133333-34.133333h273.066667c28.444444 0 153.6-5.688889 153.6-130.844445 0-142.222222-153.6-164.977778-159.288889-164.977777-17.066667 0-28.444444-11.377778-28.444444-22.755556-22.755556-85.333333-108.088889-142.222222-193.422223-130.844444-136.533333 0-176.355556 79.644444-199.111111 164.977777-5.688889 5.688889-22.755556 17.066667-39.822222 17.066667 0 0-153.6 5.688889-153.6 142.222222 0 119.466667 130.844444 125.155556 153.6 125.155556 17.066667 0 34.133333 17.066667 34.133333 34.133333s-17.066667 34.133333-34.133333 34.133334c-113.777778 0-227.555556-62.577778-227.555555-193.422223 0-142.222222 119.466667-204.8 199.111111-210.488889 22.755556-68.266667 79.644444-193.422222 261.688889-193.422222 113.777778-11.377778 216.177778 56.888889 256 164.977778 79.644444 17.066667 199.111111 79.644444 199.111111 233.244444 5.688889 136.533333-108.088889 199.111111-221.866667 199.111112z" />
47
+ <path d="M573.269333 866.588444c-11.377778 0-17.066667-5.688889-22.755555-11.377777l-108.088889-108.088889c-5.688889-5.688889-11.377778-17.066667-11.377778-22.755556s5.688889-17.066667 11.377778-22.755555l108.088889-108.088889c11.377778-11.377778 34.133333-11.377778 51.2 0 11.377778 11.377778 11.377778 34.133333 0 51.2l-79.644445 79.644444 79.644445 79.644445c11.377778 11.377778 11.377778 34.133333 0 51.2-5.688889 5.688889-17.066667 11.377778-28.444445 11.377777z" />
48
+ </svg>
49
+ )
50
+ }