@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +36 -11
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -99
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/client/api.ts CHANGED
@@ -5,25 +5,25 @@
5
5
  * origin 鈥?the only data path the panel components use.
6
6
  */
7
7
 
8
- import { HttpApiError, buildQuery, readJson } from '../client-http.ts'
8
+ import { HttpApiError, buildQuery, readJson, type HttpErrorBody } from '../client-http.ts'
9
9
  import {
10
10
  WORKSPACE_API,
11
- type DirListing,
12
- type FileRead,
13
- type FileWriteResult,
14
11
  type RemoteDirEntry,
15
- type SearchView,
16
12
  type SshWorkspaceRecord,
17
13
  } from '../protocol.ts'
18
14
 
19
- /** Error carrying the route's JSON error message and stable code/status. */
15
+ /**
16
+ * Workspace-route error: the shared transport error under this family's name.
17
+ * It adds no parsing — `readJson` in ../client-http.ts owns status/body
18
+ * decoding for both route families.
19
+ */
20
20
  export class WorkspaceApiError extends HttpApiError {
21
21
  constructor(
22
22
  message: string,
23
- code?: string,
24
23
  status?: number,
24
+ body?: HttpErrorBody,
25
25
  ) {
26
- super(message, code, status)
26
+ super(message, status, body)
27
27
  this.name = 'WorkspaceApiError'
28
28
  }
29
29
  }
@@ -80,34 +80,6 @@ export class WorkspaceApi {
80
80
  const body = await readJson<{ path: string; entries: RemoteDirEntry[] }>(response)
81
81
  return body
82
82
  }
83
-
84
- async list(root: string, path: string): Promise<DirListing> {
85
- const response = await fetch(WORKSPACE_API.tree + buildQuery({ root, path }))
86
- const body = await readJson<{ listing: DirListing }>(response)
87
- return body.listing
88
- }
89
-
90
- async read(root: string, path: string): Promise<FileRead> {
91
- const response = await fetch(WORKSPACE_API.file + buildQuery({ root, path }))
92
- const body = await readJson<{ file: FileRead }>(response)
93
- return body.file
94
- }
95
-
96
- async write(root: string, path: string, content: string, expectedMtime?: number): Promise<FileWriteResult> {
97
- const response = await fetch(WORKSPACE_API.file, {
98
- method: 'PUT',
99
- headers: { 'content-type': 'application/json' },
100
- body: JSON.stringify({ root, path, content, expectedMtime }),
101
- })
102
- const body = await readJson<{ result: FileWriteResult }>(response)
103
- return body.result
104
- }
105
-
106
- async search(root: string, queryText: string): Promise<SearchView> {
107
- const response = await fetch(WORKSPACE_API.search + buildQuery({ root, query: queryText }))
108
- const body = await readJson<{ search: SearchView }>(response)
109
- return body.search
110
- }
111
83
  }
112
84
 
113
85
  /** The two /api/dsh-ssh endpoints the config dialog needs (host create/test). */
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Probe-and-prompt for an SSH alias, with a browser-session pass cache.
3
+ *
4
+ * This is the interactive connection gate on its own — no row, no banner, no
5
+ * DOM decoration. Flows that surface their own errors (the workspace creation
6
+ * directory browser) call `connectHost` and get a plain boolean.
7
+ *
8
+ * The probe is the engine's `/test` route (a real, lightweight connect):
9
+ * - `HOST_KEY_UNKNOWN` / `HOST_KEY_MISMATCH` → the host fingerprint dialog
10
+ * (TOFU first encounter / key rotation);
11
+ * - `NEEDS_PASSWORD` / `NEEDS_PASSPHRASE` → the VSCode-style session password
12
+ * dialog (never persisted, session-only);
13
+ * - ok → the alias is marked passed for a TTL (trust is durable and the session
14
+ * password survives in the engine's session table until the process exits).
15
+ *
16
+ * @module dsh-hardssh/client/connect-host
17
+ */
18
+ import { createElement, type ReactElement } from 'react'
19
+ import { createRoot } from 'react-dom/client'
20
+ import type { SshApi } from './ssh/api.ts'
21
+ import { SshApiError } from './ssh/api.ts'
22
+ import { ConnectionErrorDialog } from './ssh/panel/ConnectionErrorDialog.tsx'
23
+ import { HostFingerprintDialog } from './ssh/panel/HostFingerprintDialog.tsx'
24
+ import { SessionSecretDialog } from './ssh/panel/SessionSecretDialog.tsx'
25
+ import { tt } from './text.ts'
26
+
27
+ /** A passed alias stays valid this long (trust is durable; the session password
28
+ * table lives in the host process for the session). */
29
+ const GATE_TTL_MS = 5 * 60 * 1000
30
+ /** Cap on interactive retry rounds (wrong password loops etc.). */
31
+ const MAX_ATTEMPTS = 3
32
+
33
+ /** alias → last successful probe timestamp (browser-session scope). */
34
+ const passedAt = new Map<string, number>()
35
+ /** alias → in-flight probe promise (no duplicate dialogs on multi-click). */
36
+ const pending = new Map<string, Promise<boolean>>()
37
+ /** alias → SSH login name (fetched once for the user@alias prompt). */
38
+ let hostsCache: Map<string, string> | null = null
39
+ /** At most one visible non-interactive failure dialog per alias. */
40
+ const failureDialogs = new Map<string, () => void>()
41
+
42
+ async function hostsUser(api: SshApi, alias: string): Promise<string | undefined> {
43
+ if (hostsCache === null) {
44
+ try {
45
+ const list = await api.listHosts()
46
+ hostsCache = new Map(list.map(host => [host.alias, host.user]))
47
+ } catch {
48
+ // Deliberately NOT cached: a transient list failure must not pin an empty
49
+ // map for the rest of the process (the secret prompt would then never
50
+ // show the login name again). The next call retries the list.
51
+ return undefined
52
+ }
53
+ }
54
+ return hostsCache.get(alias)
55
+ }
56
+
57
+ /** Whether the alias passed the gate within its TTL. */
58
+ function gateActive(alias: string, now: number): boolean {
59
+ const at = passedAt.get(alias)
60
+ return at !== undefined && now - at < GATE_TTL_MS
61
+ }
62
+
63
+ /**
64
+ * Mount a dialog component into a fresh root; returns its closer. The dialog's
65
+ * `onClose` prop is CHAINED: the caller's handler (resolving its promise) runs
66
+ * first, then the root is torn down. Without the chaining a cancel would
67
+ * unmount the dialog but never resolve the caller's promise, leaving the alias
68
+ * stuck "in flight" so later calls stop prompting.
69
+ */
70
+ function mountModal(node: ReactElement): () => void {
71
+ const host = document.createElement('div')
72
+ host.dataset.sshGateDialog = ''
73
+ document.body.appendChild(host)
74
+ const root = createRoot(host)
75
+ const close = (): void => {
76
+ root.unmount()
77
+ host.remove()
78
+ }
79
+ const callerOnClose = (node.props as { onClose?: () => void }).onClose
80
+ const chainedOnClose = callerOnClose !== undefined
81
+ ? () => { callerOnClose(); close() }
82
+ : close
83
+ root.render(createElement(node.type, { ...node.props, onClose: chainedOnClose }))
84
+ return close
85
+ }
86
+
87
+ /** Log and surface one concrete non-interactive connection failure. */
88
+ function reportConnectionFailure(alias: string, detail: string): false {
89
+ console.warn(`[dsh-hardssh] SSH probe failed for '${alias}': ${detail}`)
90
+ if (typeof document === 'undefined') return false
91
+ failureDialogs.get(alias)?.()
92
+ const close = mountModal(createElement(ConnectionErrorDialog, {
93
+ alias,
94
+ detail,
95
+ onClose: () => { failureDialogs.delete(alias) },
96
+ }))
97
+ failureDialogs.set(alias, close)
98
+ return false
99
+ }
100
+
101
+ /** Interactive prompts, resolving true when the user finished the action. */
102
+ function promptFingerprint(api: SshApi, alias: string, fingerprintSha256: string, mismatch: boolean): Promise<boolean> {
103
+ return new Promise((resolve) => {
104
+ const close = mountModal(createElement(HostFingerprintDialog, {
105
+ api,
106
+ alias,
107
+ fingerprintSha256,
108
+ mismatch,
109
+ onClose: () => { resolve(false) },
110
+ onTrusted: () => { close(); resolve(true) },
111
+ }))
112
+ })
113
+ }
114
+
115
+ function promptSecret(api: SshApi, alias: string, secret: 'password' | 'passphrase', reason?: string): Promise<boolean> {
116
+ return new Promise((resolve) => {
117
+ let closed = false
118
+ const finish = (ok: boolean): void => {
119
+ if (closed) return
120
+ closed = true
121
+ resolve(ok)
122
+ }
123
+ void hostsUser(api, alias).then((user) => {
124
+ const close = mountModal(createElement(SessionSecretDialog, {
125
+ api,
126
+ alias,
127
+ user,
128
+ secret,
129
+ reason,
130
+ onClose: () => { finish(false) },
131
+ onProvided: () => { close(); finish(true) },
132
+ }))
133
+ })
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Probe + prompt until connected. Resolves true when the alias may proceed;
139
+ * false when the user cancelled or the failure is not interactive. When a
140
+ * credential was already supplied and the retry still fails (wrong password,
141
+ * unreachable host, network drop, …), the password dialog re-opens WITH the
142
+ * failure reason shown, VSCode style — no silent "stuck" states.
143
+ *
144
+ * @param api - the SSH API client.
145
+ * @param alias - the host alias to gate.
146
+ */
147
+ export function connectHost(api: SshApi, alias: string): Promise<boolean> {
148
+ const now = Date.now()
149
+ if (gateActive(alias, now)) return Promise.resolve(true)
150
+ const inFlight = pending.get(alias)
151
+ if (inFlight !== undefined) return inFlight
152
+
153
+ const attempt = (async (): Promise<boolean> => {
154
+ let secretKind: 'password' | 'passphrase' | undefined
155
+ let lastFailure: string | null = null
156
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
157
+ try {
158
+ const result = await api.testHost(alias)
159
+ if (result.ok) {
160
+ passedAt.set(alias, Date.now())
161
+ return true
162
+ }
163
+ if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
164
+ secretKind = result.secret
165
+ if (await promptSecret(api, alias, result.secret, lastFailure ?? undefined)) {
166
+ lastFailure = null
167
+ continue
168
+ }
169
+ return false
170
+ }
171
+ lastFailure = result.error ?? 'connection failed'
172
+ return reportConnectionFailure(alias, lastFailure)
173
+ } catch (cause) {
174
+ if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
175
+ const fingerprint = cause.hostKeyFingerprint ?? ''
176
+ if (fingerprint !== '' && await promptFingerprint(api, alias, fingerprint, cause.code === 'HOST_KEY_MISMATCH')) continue
177
+ if (fingerprint === '') return reportConnectionFailure(alias, cause.message)
178
+ return false
179
+ }
180
+ lastFailure = cause instanceof Error ? cause.message : String(cause)
181
+ if (secretKind !== undefined) {
182
+ // Credential already entered but this attempt failed — re-prompt with
183
+ // the concrete reason instead of a bare failure.
184
+ if (await promptSecret(api, alias, secretKind, lastFailure)) {
185
+ lastFailure = null
186
+ continue
187
+ }
188
+ return false
189
+ }
190
+ return reportConnectionFailure(alias, lastFailure)
191
+ }
192
+ }
193
+ return reportConnectionFailure(alias, lastFailure ?? tt('gate.retryLimit'))
194
+ })()
195
+
196
+ pending.set(alias, attempt)
197
+ void attempt.finally(() => { pending.delete(alias) })
198
+ return attempt
199
+ }
@@ -137,8 +137,16 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
137
137
  const [addingHost, setAddingHost] = useState(false)
138
138
  const [newHost, setNewHost] = useState({ alias: '', host: '', port: '22', user: '', password: '' })
139
139
  const [savingHost, setSavingHost] = useState(false)
140
+ const openRef = useRef(open)
141
+ openRef.current = open
142
+ const localPickRequestSequence = useRef(0)
143
+ const hostsRequestSequence = useRef(0)
144
+ const directoryRequestSequence = useRef(0)
145
+ const createWorkspaceRequestSequence = useRef(0)
146
+ const createHostRequestSequence = useRef(0)
140
147
 
141
- // Reset and re-locate on each open edge.
148
+ // Reset and re-locate on each open edge. Every async data source owns a
149
+ // request sequence; cleanup invalidates outstanding work from this opening.
142
150
  useEffect(() => {
143
151
  if (!open) return
144
152
  setChoice('menu')
@@ -146,12 +154,31 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
146
154
  setDirPath('')
147
155
  setTitle('')
148
156
  setDirEntries([])
157
+ setLoading(false)
158
+ setCreating(false)
159
+ setSavingHost(false)
149
160
  setError(null)
150
161
  setAddingHost(false)
151
162
  setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
152
163
  setAnchorRect()
153
- // Lazy-load the host list for the SSH branch.
154
- props.listHosts().then((list) => setHosts(list)).catch(() => setHosts([]))
164
+ // Lazy-load the host list for the SSH branch. A later refresh (for
165
+ // example after adding a host) supersedes this request.
166
+ const request = ++hostsRequestSequence.current
167
+ void props.listHosts().then(
168
+ (list) => {
169
+ if (request === hostsRequestSequence.current && openRef.current) setHosts(list)
170
+ },
171
+ () => {
172
+ if (request === hostsRequestSequence.current && openRef.current) setHosts([])
173
+ },
174
+ )
175
+ return () => {
176
+ localPickRequestSequence.current += 1
177
+ hostsRequestSequence.current += 1
178
+ directoryRequestSequence.current += 1
179
+ createWorkspaceRequestSequence.current += 1
180
+ createHostRequestSequence.current += 1
181
+ }
155
182
  }, [open])
156
183
 
157
184
  /** Locate the trigger element that just opened the flow and anchor the menu
@@ -196,16 +223,22 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
196
223
 
197
224
  /** Choose "Local workspace": drive the native chooser once. */
198
225
  const pickLocal = (): void => {
226
+ const request = ++localPickRequestSequence.current
199
227
  setChoice('picking-local')
200
228
  props.pickDirectory().then(
201
229
  (path) => {
230
+ if (request !== localPickRequestSequence.current || !openRef.current) return
202
231
  if (path === null) {
203
232
  outcome.current.onCancel()
204
233
  } else {
205
234
  outcome.current.onPicked(path)
206
235
  }
207
236
  },
208
- (reason) => outcome.current.onError(reason instanceof Error ? reason.message : String(reason)),
237
+ (reason) => {
238
+ if (request === localPickRequestSequence.current && openRef.current) {
239
+ outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
240
+ }
241
+ },
209
242
  )
210
243
  }
211
244
 
@@ -213,32 +246,39 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
213
246
  * a credential (host key untrusted / session password), run the interactive
214
247
  * gate first, then retry — the operator never sees a raw "connect" error. */
215
248
  const browse = async (alias: string, path?: string): Promise<void> => {
249
+ const request = ++directoryRequestSequence.current
216
250
  if (alias === '') {
217
251
  setError(tt('create.needHost'))
218
252
  return
219
253
  }
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))
254
+ setLoading(true)
255
+ setError(null)
256
+ try {
257
+ for (let attempt = 0; attempt < 3; attempt++) {
258
+ try {
259
+ const result = await props.listRemoteDir(alias, path)
260
+ if (request !== directoryRequestSequence.current || !openRef.current) return
261
+ setDirPath(result.path)
262
+ setDirEntries(result.entries)
263
+ setChoice('browsing')
236
264
  return
265
+ } catch (e: unknown) {
266
+ if (request !== directoryRequestSequence.current || !openRef.current) return
267
+ const code = (e as { code?: string })?.code
268
+ const interactive = code === 'NEEDS_PASSWORD' || code === 'HOST_KEY_UNKNOWN' || code === 'HOST_KEY_MISMATCH'
269
+ if (!interactive) {
270
+ setError(e instanceof Error ? e.message : String(e))
271
+ return
272
+ }
273
+ const connected = await props.ensureConnected(alias)
274
+ if (request !== directoryRequestSequence.current || !openRef.current) return
275
+ if (!connected) return // user cancelled
237
276
  }
238
- if (!await props.ensureConnected(alias)) return // user cancelled
239
277
  }
278
+ if (request === directoryRequestSequence.current && openRef.current) setError(tt('create.needConnect'))
279
+ } finally {
280
+ if (request === directoryRequestSequence.current && openRef.current) setLoading(false)
240
281
  }
241
- setError(tt('create.needConnect'))
242
282
  }
243
283
 
244
284
  /** Create the SSH workspace; on success hand the anchor to the owner. */
@@ -247,20 +287,23 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
247
287
  setError(host === '' ? tt('create.needHost') : dirPath === '' ? tt('create.needDir') : tt('create.needTitle'))
248
288
  return
249
289
  }
290
+ const request = ++createWorkspaceRequestSequence.current
250
291
  setCreating(true)
251
292
  setError(null)
252
293
  try {
253
294
  const record = await props.createSshWorkspace({ title: title.trim(), alias: host, remoteRoot: dirPath })
295
+ if (request !== createWorkspaceRequestSequence.current || !openRef.current) return
254
296
  // The anchor is a REAL local dir (host workspace registration happens on
255
297
  // the server); adopt it so the host workspace list refreshes.
256
298
  outcome.current.onPicked(record.anchorPath)
257
299
  } catch (e: unknown) {
258
- setError(e instanceof Error ? e.message : String(e))
300
+ if (request === createWorkspaceRequestSequence.current && openRef.current) {
301
+ setError(e instanceof Error ? e.message : String(e))
302
+ }
259
303
  } 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)
304
+ // Always reset for the active opening: the owner may keep the flow open
305
+ // after onPicked (e.g. a host-side adoption error).
306
+ if (request === createWorkspaceRequestSequence.current && openRef.current) setCreating(false)
264
307
  }
265
308
  }
266
309
 
@@ -272,6 +315,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
272
315
  setError(tt('host.needFields'))
273
316
  return
274
317
  }
318
+ const request = ++createHostRequestSequence.current
275
319
  setSavingHost(true)
276
320
  setError(null)
277
321
  try {
@@ -282,15 +326,24 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
282
326
  user: newHost.user.trim(),
283
327
  auth: { kind: 'password', password: newHost.password },
284
328
  })
329
+ if (request !== createHostRequestSequence.current || !openRef.current) return
330
+ const hostsRequest = ++hostsRequestSequence.current
285
331
  const list = await props.listHosts()
332
+ if (
333
+ request !== createHostRequestSequence.current
334
+ || hostsRequest !== hostsRequestSequence.current
335
+ || !openRef.current
336
+ ) return
286
337
  setHosts(list)
287
338
  setHost(created.alias)
288
339
  setAddingHost(false)
289
340
  setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
290
341
  } catch (e: unknown) {
291
- setError(e instanceof Error ? e.message : String(e))
342
+ if (request === createHostRequestSequence.current && openRef.current) {
343
+ setError(e instanceof Error ? e.message : String(e))
344
+ }
292
345
  } finally {
293
- setSavingHost(false)
346
+ if (request === createHostRequestSequence.current && openRef.current) setSavingHost(false)
294
347
  }
295
348
  }
296
349
 
@@ -392,7 +445,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
392
445
  <>
393
446
  <label className={css.field} style={{ gridColumn: '1 / -1' }}>
394
447
  <span>{tt('create.host')}</span>
395
- <select value={host} onChange={(event) => setHost(event.target.value)}>
448
+ <select value={host} disabled={loading} onChange={(event) => setHost(event.target.value)}>
396
449
  <option value="">{hosts.length === 0 ? tt('create.hostEmpty') : '—'}</option>
397
450
  {hosts.map((item) => (
398
451
  <option key={item.alias} value={item.alias}>{item.alias} ({item.user}@{item.host}:{item.port})</option>
@@ -403,6 +456,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
403
456
  type="button"
404
457
  className={css.menuItem}
405
458
  style={{ gridColumn: '1 / -1', padding: '5px 8px' }}
459
+ disabled={loading}
406
460
  onClick={() => setAddingHost(true)}
407
461
  >
408
462
  <span className={css.menuItemIcon}>+</span>
@@ -410,7 +464,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
410
464
  </button>
411
465
  <label className={css.field} style={{ gridColumn: '1 / -1' }}>
412
466
  <span>{tt('create.dir')}</span>
413
- <input value={dirPath} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
467
+ <input value={dirPath} disabled={loading} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
414
468
  </label>
415
469
  <div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
416
470
  <button type="button" className={css.button} disabled={busy || loading} onClick={() => void browse(host)}>
@@ -425,8 +479,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
425
479
  )}
426
480
  {!addingHost && (
427
481
  <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()}>
482
+ <button type="button" className={css.button} onClick={() => setChoice('menu')} disabled={busy || creating || loading}>{tt('create.cancel')}</button>
483
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || creating || loading} onClick={() => void createSsh()}>
430
484
  {creating ? tt('create.connecting') : tt('create.submit')}
431
485
  </button>
432
486
  </div>
@@ -460,7 +514,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
460
514
  key={entry.name}
461
515
  type="button"
462
516
  className={css.hostRow}
463
- style={{ width: '100%', textAlign: 'left', display: 'flex', gap: 6, alignItems: 'center', border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px 6px' }}
517
+ disabled={loading}
518
+ style={{ width: '100%', textAlign: 'left', display: 'flex', gap: 6, alignItems: 'center', border: 'none', background: 'transparent', cursor: loading ? 'default' : 'pointer', padding: '4px 6px' }}
464
519
  onClick={() => { void browse(host, `${dirPath.replace(/\/+$/, '')}/${entry.name}`) }}
465
520
  >
466
521
  <span>📁</span>
@@ -469,8 +524,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
469
524
  ))}
470
525
  </div>
471
526
  <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')}>
527
+ <button type="button" className={css.button} disabled={loading} onClick={() => setChoice('ssh')}>{tt('create.cancel')}</button>
528
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={loading} onClick={() => setChoice('ssh')}>
474
529
  {tt('flow.useThisDir')}
475
530
  </button>
476
531
  </div>