@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/lib/client.js CHANGED
@@ -7,25 +7,59 @@ window.__ModuleLoader__.load({
7
7
  //#region \0rolldown/runtime.js
8
8
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
9
9
  //#endregion
10
- let react_dom_client = require("react-dom/client");
11
10
  let react = require("react");
12
- let react_dom = require("react-dom");
13
11
  let react_jsx_runtime = require("react/jsx-runtime");
12
+ let react_dom = require("react-dom");
13
+ let react_dom_client = require("react-dom/client");
14
14
  //#region src/client-http.ts
15
15
  /**
16
- * Browser-side HTTP helpers for the dsh-hardssh route clients.
17
- * Plain fetch / same-origin bundled inline into the client bundle.
16
+ * The single transport error: HTTP status plus the PARSED response body, so
17
+ * no caller re-parses a response and no field is flattened away. The SSH
18
+ * client re-exports it as `SshApiError`; the workspace client subclasses it as
19
+ * `WorkspaceApiError`. Both are this class at runtime, so the UI's
20
+ * `instanceof` / `.code` / `.hostKeyFingerprint` checks keep working.
18
21
  */
19
- /** Error carrying the route's JSON error message and stable code/status. */
20
22
  var HttpApiError = class extends Error {
21
- code;
22
23
  status;
23
- constructor(message, code, status) {
24
+ body;
25
+ constructor(message, status, body) {
24
26
  super(message);
25
- this.code = code;
26
27
  this.status = status;
28
+ this.body = body;
27
29
  this.name = "HttpApiError";
28
30
  }
31
+ /** Stable machine code from the route error body. */
32
+ get code() {
33
+ return typeof this.body?.code === "string" ? this.body.code : void 0;
34
+ }
35
+ /** Which secret a connection needs when {@link code} === 'NEEDS_PASSWORD'. */
36
+ get secret() {
37
+ const secret = this.body?.secret;
38
+ return secret === "password" || secret === "passphrase" ? secret : void 0;
39
+ }
40
+ /** The fingerprint when a host key was refused. */
41
+ get hostKeyFingerprint() {
42
+ return typeof this.body?.hostKeyFingerprint === "string" ? this.body.hostKeyFingerprint : void 0;
43
+ }
44
+ /** Mismatch detail (expected vs actual) when the host key changed. */
45
+ get hostKeyMismatch() {
46
+ const mismatch = this.body?.hostKeyMismatch;
47
+ if (typeof mismatch !== "object" || mismatch === null) return void 0;
48
+ const { expected, actual } = mismatch;
49
+ if (typeof expected !== "string" || typeof actual !== "string") return void 0;
50
+ return {
51
+ expected,
52
+ actual
53
+ };
54
+ }
55
+ /** Vault lockout: attempts left. */
56
+ get remaining() {
57
+ return typeof this.body?.remaining === "number" ? this.body.remaining : void 0;
58
+ }
59
+ /** Vault lockout: milliseconds until the next attempt is allowed. */
60
+ get retryAfterMs() {
61
+ return typeof this.body?.retryAfterMs === "number" ? this.body.retryAfterMs : void 0;
62
+ }
29
63
  };
30
64
  /** Query-string helper (skips undefined and empty values). */
31
65
  function buildQuery(params) {
@@ -34,27 +68,45 @@ window.__ModuleLoader__.load({
34
68
  const text = search.toString();
35
69
  return text === "" ? "" : "?" + text;
36
70
  }
37
- /** Parse a JSON response or throw an HttpApiError (code/status preserved). */
38
- async function readJson$1(response) {
39
- let body;
71
+ /** Parse a JSON response or throw {@link HttpApiError} (status + body kept). */
72
+ async function readJson(response) {
73
+ const body = await parseJson(response);
74
+ if (body === void 0) throw new HttpApiError(`HTTP ${response.status}: invalid JSON response`, response.status);
75
+ if (!response.ok) throw errorFor(response.status, body);
76
+ return body;
77
+ }
78
+ /**
79
+ * Throw {@link HttpApiError} for a response that does NOT carry a JSON
80
+ * success body (streamed upload/download): a JSON error body is still parsed
81
+ * and preserved, so the caller sees the route's code instead of raw text.
82
+ * @param response - the non-ok response (or a stream that could not start).
83
+ * @param fallback - message prefix used when the route sent no `error` field.
84
+ */
85
+ async function throwHttpError(response, fallback) {
86
+ const object = bodyObject(await parseJson(response));
87
+ throw new HttpApiError(typeof object?.error === "string" ? object.error : `${fallback}: HTTP ${response.status}`, response.status, object);
88
+ }
89
+ /** Parse a response body as JSON; undefined when absent or not JSON. */
90
+ async function parseJson(response) {
40
91
  try {
41
- body = await response.json();
92
+ return await response.json();
42
93
  } catch {
43
- throw new HttpApiError(`HTTP ${response.status}: invalid JSON response`, void 0, response.status);
94
+ return;
44
95
  }
45
- if (!response.ok) {
46
- const record = typeof body === "object" && body !== null ? body : void 0;
47
- throw new HttpApiError(typeof record?.error === "string" ? record.error : `HTTP ${response.status}`, typeof record?.code === "string" ? record.code : void 0, response.status);
48
- }
49
- return body;
96
+ }
97
+ /** The parsed body as an error-body record, or undefined for non-objects. */
98
+ function bodyObject(body) {
99
+ return typeof body === "object" && body !== null ? body : void 0;
100
+ }
101
+ /** Build the transport error for a non-ok response. */
102
+ function errorFor(status, body) {
103
+ const object = bodyObject(body);
104
+ return new HttpApiError(typeof object?.error === "string" ? object.error : `HTTP ${status}`, status, object);
50
105
  }
51
106
  //#endregion
52
107
  //#region src/protocol.ts
53
108
  const WORKSPACE_API = {
54
109
  state: "/api/dsh-hardssh/state",
55
- tree: "/api/dsh-hardssh/tree",
56
- file: "/api/dsh-hardssh/file",
57
- search: "/api/dsh-hardssh/search",
58
110
  sshWorkspaces: "/api/dsh-hardssh/ws",
59
111
  sshWorkspaceDir: "/api/dsh-hardssh/ws/dir"
60
112
  };
@@ -70,11 +122,11 @@ window.__ModuleLoader__.load({
70
122
  var WorkspaceApi = class {
71
123
  /** List the SSH-bound workspaces. */
72
124
  async listWorkspaces() {
73
- return (await readJson$1(await fetch(WORKSPACE_API.sshWorkspaces))).workspaces;
125
+ return (await readJson(await fetch(WORKSPACE_API.sshWorkspaces))).workspaces;
74
126
  }
75
127
  /** Create one SSH-bound workspace. */
76
128
  async createWorkspace(input) {
77
- return (await readJson$1(await fetch(WORKSPACE_API.sshWorkspaces, {
129
+ return (await readJson(await fetch(WORKSPACE_API.sshWorkspaces, {
78
130
  method: "POST",
79
131
  headers: { "content-type": "application/json" },
80
132
  body: JSON.stringify(input)
@@ -82,7 +134,7 @@ window.__ModuleLoader__.load({
82
134
  }
83
135
  /** Rename one SSH-bound workspace. */
84
136
  async renameWorkspace(id, title) {
85
- return (await readJson$1(await fetch(WORKSPACE_API.sshWorkspaces + "/item" + buildQuery({ id }), {
137
+ return (await readJson(await fetch(WORKSPACE_API.sshWorkspaces + "/item" + buildQuery({ id }), {
86
138
  method: "PATCH",
87
139
  headers: { "content-type": "application/json" },
88
140
  body: JSON.stringify({ title })
@@ -90,58 +142,28 @@ window.__ModuleLoader__.load({
90
142
  }
91
143
  /** Delete one SSH-bound workspace. */
92
144
  async deleteWorkspace(id) {
93
- await readJson$1(await fetch(WORKSPACE_API.sshWorkspaces + "/item" + buildQuery({ id }), { method: "DELETE" }));
145
+ await readJson(await fetch(WORKSPACE_API.sshWorkspaces + "/item" + buildQuery({ id }), { method: "DELETE" }));
94
146
  }
95
147
  /** List the configured SSH hosts (from the shared dsh-ssh store). */
96
148
  async listHosts() {
97
- return (await readJson$1(await fetch(WORKSPACE_API.sshWorkspaces + "/hosts"))).hosts;
149
+ return (await readJson(await fetch(WORKSPACE_API.sshWorkspaces + "/hosts"))).hosts;
98
150
  }
99
151
  /** Browse a remote directory (for the workspace picker). */
100
152
  async listRemoteDir(alias, path) {
101
- return await readJson$1(await fetch(WORKSPACE_API.sshWorkspaceDir + buildQuery({
153
+ return await readJson(await fetch(WORKSPACE_API.sshWorkspaceDir + buildQuery({
102
154
  alias,
103
155
  path
104
156
  })));
105
157
  }
106
- async list(root, path) {
107
- return (await readJson$1(await fetch(WORKSPACE_API.tree + buildQuery({
108
- root,
109
- path
110
- })))).listing;
111
- }
112
- async read(root, path) {
113
- return (await readJson$1(await fetch(WORKSPACE_API.file + buildQuery({
114
- root,
115
- path
116
- })))).file;
117
- }
118
- async write(root, path, content, expectedMtime) {
119
- return (await readJson$1(await fetch(WORKSPACE_API.file, {
120
- method: "PUT",
121
- headers: { "content-type": "application/json" },
122
- body: JSON.stringify({
123
- root,
124
- path,
125
- content,
126
- expectedMtime
127
- })
128
- }))).result;
129
- }
130
- async search(root, queryText) {
131
- return (await readJson$1(await fetch(WORKSPACE_API.search + buildQuery({
132
- root,
133
- query: queryText
134
- })))).search;
135
- }
136
158
  };
137
159
  const HOSTS_API = "/api/dsh-ssh/hosts";
138
160
  const TEST_API = "/api/dsh-ssh/test";
139
161
  var SshHostsApi = class {
140
162
  async list() {
141
- return (await readJson$1(await fetch(HOSTS_API))).hosts;
163
+ return (await readJson(await fetch(HOSTS_API))).hosts;
142
164
  }
143
165
  async create(payload) {
144
- return (await readJson$1(await fetch(HOSTS_API, {
166
+ return (await readJson(await fetch(HOSTS_API, {
145
167
  method: "POST",
146
168
  headers: { "content-type": "application/json" },
147
169
  body: JSON.stringify(payload)
@@ -151,7 +173,7 @@ window.__ModuleLoader__.load({
151
173
  * may carry only the fields being changed; omitted auth keeps the stored
152
174
  * secret. */
153
175
  async update(alias, payload) {
154
- return (await readJson$1(await fetch(`${HOSTS_API}?alias=${encodeURIComponent(alias)}`, {
176
+ return (await readJson(await fetch(`${HOSTS_API}?alias=${encodeURIComponent(alias)}`, {
155
177
  method: "PATCH",
156
178
  headers: { "content-type": "application/json" },
157
179
  body: JSON.stringify(payload)
@@ -159,10 +181,10 @@ window.__ModuleLoader__.load({
159
181
  }
160
182
  /** Delete a host (DELETE /api/dsh-ssh/hosts?alias=鈥?. */
161
183
  async remove(alias) {
162
- await readJson$1(await fetch(`${HOSTS_API}?alias=${encodeURIComponent(alias)}`, { method: "DELETE" }));
184
+ await readJson(await fetch(`${HOSTS_API}?alias=${encodeURIComponent(alias)}`, { method: "DELETE" }));
163
185
  }
164
186
  async test(alias) {
165
- return (await readJson$1(await fetch(TEST_API, {
187
+ return (await readJson(await fetch(TEST_API, {
166
188
  method: "POST",
167
189
  headers: { "content-type": "application/json" },
168
190
  body: JSON.stringify({ alias })
@@ -193,38 +215,25 @@ window.__ModuleLoader__.load({
193
215
  * Browser-side API client for the /api/dsh-ssh route family. The only data
194
216
  * access path the panel components use — plain fetch/WebSocket, same origin.
195
217
  */
196
- /** Error carrying the route's JSON error message. */
197
- var SshApiError = class extends Error {
198
- /** Stable machine code from the route error body (HOST_KEY_*, HOST_IN_USE, …). */
199
- code;
200
- hostKeyFingerprint;
201
- constructor(message, code, hostKeyFingerprint) {
202
- super(message);
203
- this.name = "SshApiError";
204
- this.code = code;
205
- this.hostKeyFingerprint = hostKeyFingerprint;
206
- }
207
- };
208
- /** Parse a JSON response or throw an SshApiError. */
209
- async function readJson(response) {
210
- let body;
211
- try {
212
- body = await response.json();
213
- } catch {
214
- throw new SshApiError(`HTTP ${response.status}: invalid JSON response`);
215
- }
216
- if (!response.ok) {
217
- const record = typeof body === "object" && body !== null ? body : {};
218
- throw new SshApiError(typeof record.error === "string" ? record.error : `HTTP ${response.status}`, typeof record.code === "string" ? record.code : void 0, typeof record.hostKeyFingerprint === "string" ? record.hostKeyFingerprint : void 0);
219
- }
220
- return body;
221
- }
222
- /** Query-string helper. */
223
- function query(params) {
224
- const search = new URLSearchParams();
225
- for (const [key, value] of Object.entries(params)) if (value !== void 0 && value !== "") search.set(key, String(value));
226
- const text = search.toString();
227
- return text === "" ? "" : "?" + text;
218
+ /** Query-string helper (shared transport helper, no local copy). */
219
+ const query = buildQuery;
220
+ /**
221
+ * Structured fields of one streamed (`application/x-ndjson`) failure frame.
222
+ * The upload route currently sends only `error`, so the interactive-gate
223
+ * fields are picked up when present and simply absent otherwise — the same
224
+ * shape the JSON routes produce, so callers handle one error type.
225
+ */
226
+ function frameFailure(frame) {
227
+ const record = frame;
228
+ const secret = record.secret;
229
+ return {
230
+ ...typeof record.error === "string" ? { error: record.error } : {},
231
+ ...typeof record.code === "string" ? { code: record.code } : {},
232
+ ...secret === "password" || secret === "passphrase" ? { secret } : {},
233
+ ...typeof record.hostKeyFingerprint === "string" ? { hostKeyFingerprint: record.hostKeyFingerprint } : {},
234
+ ...typeof record.remaining === "number" ? { remaining: record.remaining } : {},
235
+ ...typeof record.retryAfterMs === "number" ? { retryAfterMs: record.retryAfterMs } : {}
236
+ };
228
237
  }
229
238
  /** The browser half's only data entry point. */
230
239
  var SshApi = class {
@@ -323,45 +332,60 @@ window.__ModuleLoader__.load({
323
332
  * Upload one file (raw bytes) to a remote path. Progress arrives through
324
333
  * the NDJSON response stream; resolves when the result frame lands.
325
334
  */
326
- async uploadFile(file, alias, remotePath, onProgress) {
335
+ async uploadFile(file, alias, remotePath, onProgress, signal) {
327
336
  const response = await fetch(SSH_API.upload + query({
328
337
  alias,
329
338
  remotePath
330
339
  }), {
331
340
  method: "POST",
332
- body: file
341
+ body: file,
342
+ signal
333
343
  });
334
- if (!response.ok || response.body === null) throw new SshApiError(`upload failed: HTTP ${response.status}`);
344
+ if (!response.ok) await throwHttpError(response, "upload failed");
345
+ if (response.body === null) throw new HttpApiError("upload failed: the response carried no body stream", response.status);
335
346
  const reader = response.body.getReader();
336
347
  const decoder = new TextDecoder();
337
348
  let buffer = "";
338
- let finalError;
349
+ let failure;
339
350
  let sawResult = false;
351
+ let commitStarted = false;
352
+ let readerDone = false;
340
353
  let transferredBytes = 0;
341
- for (;;) {
342
- const { done, value } = await reader.read();
343
- if (done) break;
344
- buffer += decoder.decode(value, { stream: true });
345
- const lines = buffer.split("\n");
346
- buffer = lines.pop() ?? "";
347
- for (const line of lines) {
348
- if (line.trim() === "") continue;
349
- let parsed;
350
- try {
351
- parsed = JSON.parse(line);
352
- } catch {
353
- continue;
354
- }
355
- if (parsed.type === "progress") onProgress?.(parsed.progress);
356
- else if (parsed.type === "result") {
357
- sawResult = true;
358
- if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0;
359
- finalError = parsed.ok ? void 0 : parsed.error ?? "upload failed";
354
+ try {
355
+ for (;;) {
356
+ const { done, value } = await reader.read();
357
+ if (done) {
358
+ readerDone = true;
359
+ break;
360
+ }
361
+ buffer += decoder.decode(value, { stream: true });
362
+ const lines = buffer.split("\n");
363
+ buffer = lines.pop() ?? "";
364
+ for (const line of lines) {
365
+ if (line.trim() === "") continue;
366
+ let parsed;
367
+ try {
368
+ parsed = JSON.parse(line);
369
+ } catch {
370
+ continue;
371
+ }
372
+ if (parsed.type === "progress") onProgress?.(parsed.progress);
373
+ else if (parsed.type === "commit") commitStarted = true;
374
+ else if (parsed.type === "result") {
375
+ sawResult = true;
376
+ if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0;
377
+ else failure = frameFailure(parsed);
378
+ }
360
379
  }
361
380
  }
381
+ } catch (error) {
382
+ if (commitStarted) throw new HttpApiError("upload connection closed after commit began; the remote result is unknown", response.status, { code: "RESULT_UNKNOWN" });
383
+ throw error;
384
+ } finally {
385
+ if (!readerDone) await reader.cancel().catch(() => void 0);
362
386
  }
363
- if (finalError !== void 0) throw new SshApiError(finalError);
364
- if (!sawResult) throw new SshApiError("upload ended without a result frame — the transfer did not complete");
387
+ if (failure !== void 0) throw new HttpApiError(typeof failure.error === "string" ? failure.error : "upload failed", response.status, failure);
388
+ if (!sawResult) throw new HttpApiError(commitStarted ? "upload connection closed after commit began; the remote result is unknown" : "upload ended without a result frame — the remote destination was not published", response.status, commitStarted ? { code: "RESULT_UNKNOWN" } : { code: "ABORTED" });
365
389
  return { transferredBytes };
366
390
  }
367
391
  /**
@@ -369,15 +393,13 @@ window.__ModuleLoader__.load({
369
393
  * disk when the File System Access API is available (no full-file RAM
370
394
  * copy); otherwise falls back to an in-memory Blob.
371
395
  */
372
- async downloadFile(alias, remotePath, onProgress) {
396
+ async downloadFile(alias, remotePath, onProgress, signal) {
373
397
  const response = await fetch(SSH_API.download + query({
374
398
  alias,
375
399
  remotePath
376
- }));
377
- if (!response.ok || response.body === null) {
378
- const text = await response.text().catch(() => "");
379
- throw new SshApiError(text !== "" && text.startsWith("{") ? text : `download failed: HTTP ${response.status}`);
380
- }
400
+ }), { signal });
401
+ if (!response.ok) await throwHttpError(response, "download failed");
402
+ if (response.body === null) throw new HttpApiError("download failed: the response carried no body stream", response.status);
381
403
  const total = Number(response.headers.get("content-length") ?? "0");
382
404
  const disposition = response.headers.get("content-disposition") ?? "";
383
405
  const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? remotePath.split("/").pop() ?? "download";
@@ -387,6 +409,8 @@ window.__ModuleLoader__.load({
387
409
  let writable;
388
410
  const chunks = [];
389
411
  let received = 0;
412
+ let readerDone = false;
413
+ let writableClosed = false;
390
414
  const progress = () => {
391
415
  onProgress?.({
392
416
  phase: "transferring",
@@ -397,33 +421,52 @@ window.__ModuleLoader__.load({
397
421
  });
398
422
  };
399
423
  try {
400
- if (picker !== void 0) {
401
- writable = await (await picker.call(window, { suggestedName: filename })).createWritable();
402
- streamed = true;
424
+ try {
425
+ if (picker !== void 0) {
426
+ const handle = await picker.call(window, { suggestedName: filename });
427
+ signal?.throwIfAborted();
428
+ writable = await handle.createWritable();
429
+ streamed = true;
430
+ }
431
+ } catch (error) {
432
+ if (signal?.aborted === true) throw error;
403
433
  }
404
- } catch {}
405
- for (;;) {
406
- const { done, value } = await reader.read();
407
- if (done) break;
408
- if (writable !== void 0) await writable.write(value);
409
- else chunks.push(value);
410
- received += value.length;
411
- progress();
434
+ for (;;) {
435
+ signal?.throwIfAborted();
436
+ const { done, value } = await reader.read();
437
+ if (done) {
438
+ readerDone = true;
439
+ break;
440
+ }
441
+ if (writable !== void 0) await writable.write(value);
442
+ else chunks.push(value);
443
+ received += value.length;
444
+ progress();
445
+ }
446
+ signal?.throwIfAborted();
447
+ if (writable !== void 0) {
448
+ await writable.close();
449
+ writableClosed = true;
450
+ }
451
+ onProgress?.({
452
+ phase: "done",
453
+ file: remotePath,
454
+ transferred: received,
455
+ total: received > 0 ? received : total,
456
+ percent: 100
457
+ });
458
+ return {
459
+ blob: streamed ? void 0 : new Blob(chunks),
460
+ filename,
461
+ streamed,
462
+ bytes: received
463
+ };
464
+ } catch (error) {
465
+ chunks.length = 0;
466
+ if (!readerDone) await reader.cancel(error).catch(() => void 0);
467
+ if (writable !== void 0 && !writableClosed) await writable.abort?.(error).catch(() => void 0);
468
+ throw error;
412
469
  }
413
- if (writable !== void 0) await writable.close();
414
- onProgress?.({
415
- phase: "done",
416
- file: remotePath,
417
- transferred: received,
418
- total: received > 0 ? received : total,
419
- percent: 100
420
- });
421
- return {
422
- blob: streamed ? void 0 : new Blob(chunks),
423
- filename,
424
- streamed,
425
- bytes: received
426
- };
427
470
  }
428
471
  /** Open a WebSocket terminal session. */
429
472
  openTerminal(alias, cols, rows) {
@@ -433,22 +476,96 @@ window.__ModuleLoader__.load({
433
476
  rows
434
477
  });
435
478
  const socket = new WebSocket(url);
479
+ const MAX_PENDING_INPUT = 65536;
480
+ let ready = false;
481
+ let finalized = false;
482
+ let queuedInput = "";
483
+ let queuedResize;
484
+ let readyListener;
485
+ let readyDelivered = false;
486
+ let outputListener;
487
+ let exitListener;
488
+ let exitRecord;
489
+ let exitDelivered = false;
490
+ const deliverReady = () => {
491
+ if (!ready || readyDelivered || readyListener === void 0) return;
492
+ readyDelivered = true;
493
+ readyListener();
494
+ };
495
+ const deliverExit = () => {
496
+ if (exitRecord === void 0 || exitDelivered || exitListener === void 0) return;
497
+ exitDelivered = true;
498
+ exitListener(exitRecord.code, exitRecord.error);
499
+ };
500
+ const finalize = (code, error) => {
501
+ if (finalized) return;
502
+ finalized = true;
503
+ queuedInput = "";
504
+ queuedResize = void 0;
505
+ exitRecord = error === void 0 ? { code } : {
506
+ code,
507
+ error
508
+ };
509
+ deliverExit();
510
+ };
511
+ const sendFrame = (frame) => {
512
+ if (finalized || !ready || socket.readyState !== WebSocket.OPEN) return;
513
+ socket.send(JSON.stringify(frame));
514
+ };
436
515
  const connection = {
437
- onReady: void 0,
438
- onOutput: void 0,
439
- onExit: void 0,
516
+ get onReady() {
517
+ return readyListener;
518
+ },
519
+ set onReady(listener) {
520
+ readyListener = listener;
521
+ deliverReady();
522
+ },
523
+ get onOutput() {
524
+ return outputListener;
525
+ },
526
+ set onOutput(listener) {
527
+ outputListener = listener;
528
+ },
529
+ get onExit() {
530
+ return exitListener;
531
+ },
532
+ set onExit(listener) {
533
+ exitListener = listener;
534
+ deliverExit();
535
+ },
440
536
  send: (data) => {
441
- if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
537
+ if (finalized) return;
538
+ if (!ready || socket.readyState !== WebSocket.OPEN) {
539
+ if (queuedInput.length + data.length > MAX_PENDING_INPUT) {
540
+ finalize(null, "terminal input buffer exceeded before ready");
541
+ try {
542
+ socket.close(1008, "terminal input buffer exceeded");
543
+ } catch {}
544
+ return;
545
+ }
546
+ queuedInput += data;
547
+ return;
548
+ }
549
+ sendFrame({
442
550
  type: "input",
443
551
  data
444
- }));
552
+ });
445
553
  },
446
- resize: (cols, rows) => {
447
- if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
554
+ resize: (nextCols, nextRows) => {
555
+ if (finalized) return;
556
+ const frame = {
448
557
  type: "resize",
449
- cols,
450
- rows
451
- }));
558
+ cols: nextCols,
559
+ rows: nextRows
560
+ };
561
+ if (!ready || socket.readyState !== WebSocket.OPEN) {
562
+ queuedResize = {
563
+ cols: nextCols,
564
+ rows: nextRows
565
+ };
566
+ return;
567
+ }
568
+ sendFrame(frame);
452
569
  },
453
570
  close: () => {
454
571
  try {
@@ -457,21 +574,54 @@ window.__ModuleLoader__.load({
457
574
  }
458
575
  };
459
576
  socket.onmessage = (event) => {
460
- let frame;
461
577
  try {
462
- frame = JSON.parse(event.data);
578
+ const value = JSON.parse(event.data);
579
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("invalid terminal frame");
580
+ const frame = value;
581
+ if (frame.type === "ready" && typeof frame.alias === "string") {
582
+ if (ready || finalized) return;
583
+ ready = true;
584
+ deliverReady();
585
+ if (queuedResize !== void 0) {
586
+ sendFrame({
587
+ type: "resize",
588
+ ...queuedResize
589
+ });
590
+ queuedResize = void 0;
591
+ }
592
+ if (queuedInput !== "") {
593
+ sendFrame({
594
+ type: "input",
595
+ data: queuedInput
596
+ });
597
+ queuedInput = "";
598
+ }
599
+ return;
600
+ }
601
+ if (frame.type === "output" && typeof frame.data === "string") {
602
+ outputListener?.(frame.data);
603
+ return;
604
+ }
605
+ if (frame.type === "exit" && (typeof frame.code === "number" || frame.code === null) && (frame.error === void 0 || typeof frame.error === "string")) {
606
+ finalize(frame.code, frame.error);
607
+ try {
608
+ socket.close(1e3);
609
+ } catch {}
610
+ return;
611
+ }
612
+ throw new Error("invalid terminal frame");
463
613
  } catch {
464
- return;
614
+ finalize(null, "invalid terminal server frame");
615
+ try {
616
+ socket.close(1008, "invalid terminal server frame");
617
+ } catch {}
465
618
  }
466
- if (frame.type === "ready") connection.onReady?.();
467
- else if (frame.type === "output") connection.onOutput?.(frame.data);
468
- else if (frame.type === "exit") connection.onExit?.(frame.code, frame.error);
469
619
  };
470
620
  socket.onclose = () => {
471
- connection.onExit?.(null, "connection closed");
621
+ finalize(null, "connection closed");
472
622
  };
473
623
  socket.onerror = () => {
474
- connection.onExit?.(null, "connection error");
624
+ finalize(null, "connection error");
475
625
  };
476
626
  return connection;
477
627
  }
@@ -523,7 +673,14 @@ window.__ModuleLoader__.load({
523
673
  "manager.label": "SSH 工作区",
524
674
  "manager.tooltip": "管理 SSH 工作区:绑定服务器目录,绑定后的会话在远程操作,其余会话在本机",
525
675
  "manager.title": "SSH 工作区管理",
676
+ "panel.workspaces": "SSH 工作区",
677
+ "ops.tab": "SSH",
678
+ "ops.guide": "打开 SSH 运维面板:终端、传输、隧道、集群",
526
679
  "manager.empty": "还没有 SSH 工作区",
680
+ "manager.connected": "已连接",
681
+ "manager.disconnected": "未连接",
682
+ "manager.connectionUnknown": "状态未知",
683
+ "manager.connectionError": "无法读取服务器连接状态:%s",
527
684
  "manager.delete": "删除",
528
685
  "manager.editServer": "编辑服务器",
529
686
  "manager.deleteServer": "删除服务器",
@@ -567,7 +724,14 @@ window.__ModuleLoader__.load({
567
724
  "manager.label": "SSH workspaces",
568
725
  "manager.tooltip": "Manage SSH workspaces: bind a server directory — sessions in a bound workspace operate remotely, all others stay local",
569
726
  "manager.title": "SSH Workspaces",
727
+ "panel.workspaces": "SSH workspaces",
728
+ "ops.tab": "SSH",
729
+ "ops.guide": "Open the SSH operations panel: terminal, transfer, tunnels, cluster",
570
730
  "manager.empty": "No SSH workspaces yet",
731
+ "manager.connected": "Connected",
732
+ "manager.disconnected": "Disconnected",
733
+ "manager.connectionUnknown": "Status unknown",
734
+ "manager.connectionError": "Could not read server connection state: %s",
571
735
  "manager.delete": "Delete",
572
736
  "manager.editServer": "Edit server",
573
737
  "manager.deleteServer": "Delete server",
@@ -632,6 +796,8 @@ window.__ModuleLoader__.load({
632
796
  };
633
797
  listeners = /* @__PURE__ */ new Set();
634
798
  timer;
799
+ /** Monotonic list-workspaces request id; only the newest request may commit. */
800
+ refreshSequence = 0;
635
801
  constructor(api) {
636
802
  this.api = api;
637
803
  }
@@ -648,9 +814,12 @@ window.__ModuleLoader__.load({
648
814
  for (const listener of [...this.listeners]) listener();
649
815
  }
650
816
  async refresh() {
817
+ const request = ++this.refreshSequence;
651
818
  try {
819
+ const workspaces = await this.api.listWorkspaces();
820
+ if (request !== this.refreshSequence) return;
652
821
  const next = {
653
- workspaces: await this.api.listWorkspaces(),
822
+ workspaces,
654
823
  error: null
655
824
  };
656
825
  if (!sameLedger(this.state.workspaces, next.workspaces) || this.state.error !== null) {
@@ -658,6 +827,7 @@ window.__ModuleLoader__.load({
658
827
  this.emit();
659
828
  }
660
829
  } catch (error) {
830
+ if (request !== this.refreshSequence) return;
661
831
  const message = error instanceof Error ? error.message : String(error);
662
832
  if (this.state.error !== message) {
663
833
  this.state = {
@@ -673,6 +843,7 @@ window.__ModuleLoader__.load({
673
843
  this.timer = window.setInterval(() => void this.refresh(), POLL_MS);
674
844
  }
675
845
  stop() {
846
+ this.refreshSequence += 1;
676
847
  if (this.timer !== void 0) {
677
848
  window.clearInterval(this.timer);
678
849
  this.timer = void 0;
@@ -703,7 +874,7 @@ window.__ModuleLoader__.load({
703
874
  }
704
875
  //#endregion
705
876
  //#region \0dsh-css:C:\Users\Kether\.dsh\dsh-hardssh\packages\dsh-hardssh\src\client\workspace.module.css.mjs
706
- const css$1 = ".-bXcdG_dialogGrid{grid-template-columns:1fr;gap:10px;display:grid}.-bXcdG_field{flex-direction:column;gap:5px;display:flex}.-bXcdG_field>span{color:var(--dsw-alias-label-primary,#0f1115);font-size:13px;font-weight:500}.-bXcdG_field input,.-bXcdG_field select{box-sizing:border-box;min-height:36px;color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#00000024);border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}.-bXcdG_field select option{background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_field input::placeholder,.-bXcdG_field select::placeholder{color:var(--dsw-alias-label-tertiary,#81858c)}.-bXcdG_field input:focus,.-bXcdG_field select:focus{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);box-shadow:0 0 0 2px #4176e626}.-bXcdG_dialogFail{background:var(--dsw-alias-state-error-bg,#f53f3f1a);border:1px solid var(--dsw-alias-state-error-primary,#f53f3f66);color:var(--dsw-alias-state-error-primary,#d03050);border-radius:6px;margin-top:10px;padding:8px 12px;font-size:12px}.-bXcdG_dialogInfo{background:var(--dsw-alias-bg-base-2,#0000000a);border:1px solid var(--dsw-alias-border-l2,#0000001a);color:#000;border-radius:6px;margin-top:10px;padding:8px 12px;font-size:12px}.-bXcdG_dialogActions{justify-content:flex-end;gap:8px;margin-top:14px;display:flex}.-bXcdG_button{border:1px solid var(--dsw-alias-border-l2,#00000026);color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;background:0 0;border-radius:8px;padding:7px 15px;font-family:inherit;font-size:13px;transition:background .15s}.-bXcdG_button:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_button:disabled{opacity:.55;cursor:default}.-bXcdG_buttonPrimary{background:var(--dsw-alias-button-primary-fill,#0f1115);color:var(--dsw-alias-label-primary-foreground,#fff);border-color:#0000;font-weight:600}.-bXcdG_buttonPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#2b2f36);color:var(--dsw-alias-label-primary-foreground,#fff)}.-bXcdG_dialogHint{color:var(--dsw-alias-label-tertiary,#81858c);margin-top:10px;font-size:11.5px;line-height:1.45}.-bXcdG_hostRow{border:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-bg-base,#00000005);color:var(--dsw-alias-label-primary,#0f1115);border-radius:8px;align-items:center;gap:8px;padding:7px 10px;font-size:13px;display:flex}.-bXcdG_hostRow:hover{background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_hostAlias{color:var(--dsw-alias-label-primary,#0f1115);white-space:nowrap;font-size:13px;font-weight:500}.-bXcdG_hostMeta{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-tertiary,#81858c);flex:1;font-size:12px;overflow:hidden}.-bXcdG_danger{background:var(--dsw-alias-state-error-primary,#d03050);color:#fff;border-color:#0000}.-bXcdG_managerButton{border:1px solid var(--dsw-alias-border-l2,#0003);color:#000;min-width:111px;height:32px;font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);cursor:pointer;white-space:nowrap;background:0 0;border-radius:12px;justify-content:center;align-items:center;gap:4px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;transition:background .15s,border-color .15s;display:inline-flex}.-bXcdG_managerButton:hover{background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_managerButton svg{flex:none}.-bXcdG_managerMenuHeader{color:var(--dsw-alias-label-primary,#0f1115);margin:0 0 2px;font-size:13px;font-weight:600}.-bXcdG_managerMenuClose{float:right;color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;background:0 0;border:none;padding:0 2px;font-size:14px;line-height:18px}.-bXcdG_managerMenuClose:hover{color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_menuDropdown{z-index:99999;box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l1,#0000000a);min-width:230px;max-width:calc(100vw - 16px);max-height:min(600px,100vh - 64px);color:var(--dsw-alias-label-primary,#0f1115);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-caption,#80808080) transparent;border-radius:12px;padding:8px;font-size:13px;line-height:20px;position:fixed;overflow:hidden auto}.-bXcdG_menuItem{width:100%;color:var(--dsw-alias-label-primary,#0f1115);font:inherit;text-align:left;cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:9px;padding:8px 11px;transition:background .12s,color .12s;display:flex}.-bXcdG_menuItem:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_menuItem:disabled{opacity:.55;cursor:default}.-bXcdG_menuItemIcon{flex:none;font-size:14px;line-height:1}.-bXcdG_menuDivider{background:var(--dsw-alias-border-l2,#0000001a);height:1px;margin:5px 9px}.-bXcdG_managerGroupHeader{border-bottom:1px solid var(--dsw-alias-border-l2,#0000001a);margin-bottom:5px}.-bXcdG_menuItemIconButton{border:1px solid var(--dsw-alias-border-l2,#00000026);width:22px;height:22px;color:var(--dsw-alias-label-secondary,#4a5060);cursor:pointer;background:0 0;border-radius:6px;flex:none;justify-content:center;align-items:center;font-size:13px;line-height:1;transition:background .12s,color .12s,border-color .12s;display:inline-flex}.-bXcdG_menuItemIconButton:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000f);border-color:var(--dsw-alias-border-l3,#0000001f);color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_menuItemIconButton:disabled{opacity:.4;cursor:default}";
877
+ const css$1 = ".-bXcdG_dialogGrid{grid-template-columns:1fr;gap:10px;display:grid}.-bXcdG_field{flex-direction:column;gap:5px;display:flex}.-bXcdG_field>span{color:var(--dsw-alias-label-primary,#0f1115);font-size:13px;font-weight:500}.-bXcdG_field input,.-bXcdG_field select{box-sizing:border-box;min-height:36px;color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#00000024);border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}.-bXcdG_field select option{background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_field input::placeholder,.-bXcdG_field select::placeholder{color:var(--dsw-alias-label-tertiary,#81858c)}.-bXcdG_field input:focus,.-bXcdG_field select:focus{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);box-shadow:0 0 0 2px #4176e626}.-bXcdG_dialogFail{background:var(--dsw-alias-state-error-bg,#f53f3f1a);border:1px solid var(--dsw-alias-state-error-primary,#f53f3f66);color:var(--dsw-alias-state-error-primary,#d03050);border-radius:6px;margin-top:10px;padding:8px 12px;font-size:12px}.-bXcdG_dialogInfo{background:var(--dsw-alias-bg-base-2,#0000000a);border:1px solid var(--dsw-alias-border-l2,#0000001a);color:var(--dsw-alias-label-primary,#0f1115);border-radius:6px;margin-top:10px;padding:8px 12px;font-size:12px}.-bXcdG_dialogActions{justify-content:flex-end;gap:8px;margin-top:14px;display:flex}.-bXcdG_button{border:1px solid var(--dsw-alias-border-l2,#00000026);color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;background:0 0;border-radius:8px;padding:7px 15px;font-family:inherit;font-size:13px;transition:background .15s}.-bXcdG_button:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_button:disabled{opacity:.55;cursor:default}.-bXcdG_buttonPrimary{background:var(--dsw-alias-button-primary-fill,#0f1115);color:var(--dsw-alias-label-primary-foreground,#fff);border-color:#0000;font-weight:600}.-bXcdG_buttonPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#2b2f36);color:var(--dsw-alias-label-primary-foreground,#fff)}.-bXcdG_dialogHint{color:var(--dsw-alias-label-tertiary,#81858c);margin-top:10px;font-size:11.5px;line-height:1.45}.-bXcdG_hostRow{border:1px solid var(--dsw-alias-border-l2,#0000001a);background:var(--dsw-alias-bg-base,#00000005);color:var(--dsw-alias-label-primary,#0f1115);border-radius:8px;align-items:center;gap:8px;padding:7px 10px;font-size:13px;display:flex}.-bXcdG_hostRow:hover{background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_hostAlias{color:var(--dsw-alias-label-primary,#0f1115);white-space:nowrap;font-size:13px;font-weight:500}.-bXcdG_hostMeta{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-tertiary,#81858c);flex:1;font-size:12px;overflow:hidden}.-bXcdG_danger{background:var(--dsw-alias-state-error-primary,#d03050);color:#fff;border-color:#0000}.-bXcdG_managerButton{border:1px solid var(--dsw-alias-border-l2,#0003);min-width:111px;height:32px;color:var(--dsw-alias-label-primary,#0f1115);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);cursor:pointer;white-space:nowrap;background:0 0;border-radius:12px;justify-content:center;align-items:center;gap:4px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;transition:background .15s,border-color .15s;display:inline-flex}.-bXcdG_managerButton:hover{background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_managerButton svg{flex:none}.-bXcdG_managerMenuHeader{color:var(--dsw-alias-label-primary,#0f1115);margin:0 0 2px;font-size:13px;font-weight:600}.-bXcdG_managerMenuClose{float:right;color:var(--dsw-alias-label-secondary,#61666b);cursor:pointer;background:0 0;border:none;padding:0 2px;font-size:14px;line-height:18px}.-bXcdG_managerMenuClose:hover{color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_menuDropdown{z-index:99999;box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l1,#0000000a);min-width:230px;max-width:calc(100vw - 16px);max-height:min(600px,100vh - 64px);color:var(--dsw-alias-label-primary,#0f1115);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-caption,#80808080) transparent;border-radius:12px;padding:8px;font-size:13px;line-height:20px;position:fixed;overflow:hidden auto}.-bXcdG_menuItem{width:100%;color:var(--dsw-alias-label-primary,#0f1115);font:inherit;text-align:left;cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:9px;padding:8px 11px;transition:background .12s,color .12s;display:flex}.-bXcdG_menuItem:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.-bXcdG_menuItem:disabled{opacity:.55;cursor:default}.-bXcdG_menuItemIcon{flex:none;font-size:14px;line-height:1}.-bXcdG_menuDivider{background:var(--dsw-alias-border-l2,#0000001a);height:1px;margin:5px 9px}.-bXcdG_managerGroupHeader{border-bottom:1px solid var(--dsw-alias-border-l2,#0000001a);margin-bottom:5px}.-bXcdG_connectionBadge{color:var(--dsw-alias-label-secondary,#61666b);white-space:nowrap;border:1px solid var(--dsw-alias-border-l2,#0000001f);border-radius:999px;flex:none;align-items:center;gap:5px;padding:1px 7px;font-size:11px;line-height:18px;display:inline-flex}.-bXcdG_connectionDot{background:var(--dsw-alias-label-tertiary,#81858c);border-radius:50%;width:7px;height:7px}.-bXcdG_connectionBadge[data-state=connected]{color:var(--dsw-alias-state-success-primary,#18a058);border-color:color-mix(in srgb, var(--dsw-alias-state-success-primary,#18a058) 48%, transparent)}.-bXcdG_connectionBadge[data-state=connected] .-bXcdG_connectionDot{background:var(--dsw-alias-state-success-primary,#18a058)}.-bXcdG_connectionBadge[data-state=disconnected]{color:var(--dsw-alias-label-tertiary,#81858c)}.-bXcdG_menuItemIconButton{border:1px solid var(--dsw-alias-border-l2,#00000026);width:22px;height:22px;color:var(--dsw-alias-label-secondary,#4a5060);cursor:pointer;background:0 0;border-radius:6px;flex:none;justify-content:center;align-items:center;font-size:13px;line-height:1;transition:background .12s,color .12s,border-color .12s;display:inline-flex}.-bXcdG_menuItemIconButton:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000f);border-color:var(--dsw-alias-border-l3,#0000001f);color:var(--dsw-alias-label-primary,#0f1115)}.-bXcdG_menuItemIconButton:disabled{opacity:.4;cursor:default}.-bXcdG_managerPanel{box-sizing:border-box;width:100%;height:100%;min-height:0;color:var(--dsw-alias-label-primary,#0f1115);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-caption,#80808080) transparent;padding:16px 20px 24px;font-size:13px;line-height:20px;overflow-y:auto}.-bXcdG_managerPanelHeader{align-items:center;gap:10px;margin-bottom:12px;display:flex}.-bXcdG_managerPanelTitle{white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.-bXcdG_managerPanelGroup{margin-bottom:14px}.-bXcdG_managerPanelFooter{border-top:1px solid var(--dsw-alias-border-l2,#0000001a);margin-top:8px;padding-top:8px}";
707
878
  const tagId$1 = "@tiphareth/dsh-hardssh/workspace.module.css";
708
879
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
709
880
  const tag = document.createElement("style");
@@ -715,6 +886,8 @@ window.__ModuleLoader__.load({
715
886
  var workspace_module_css_default = {
716
887
  "button": "-bXcdG_button",
717
888
  "buttonPrimary": "-bXcdG_buttonPrimary",
889
+ "connectionBadge": "-bXcdG_connectionBadge",
890
+ "connectionDot": "-bXcdG_connectionDot",
718
891
  "danger": "-bXcdG_danger",
719
892
  "dialogActions": "-bXcdG_dialogActions",
720
893
  "dialogFail": "-bXcdG_dialogFail",
@@ -729,6 +902,11 @@ window.__ModuleLoader__.load({
729
902
  "managerGroupHeader": "-bXcdG_managerGroupHeader",
730
903
  "managerMenuClose": "-bXcdG_managerMenuClose",
731
904
  "managerMenuHeader": "-bXcdG_managerMenuHeader",
905
+ "managerPanel": "-bXcdG_managerPanel",
906
+ "managerPanelFooter": "-bXcdG_managerPanelFooter",
907
+ "managerPanelGroup": "-bXcdG_managerPanelGroup",
908
+ "managerPanelHeader": "-bXcdG_managerPanelHeader",
909
+ "managerPanelTitle": "-bXcdG_managerPanelTitle",
732
910
  "menuDivider": "-bXcdG_menuDivider",
733
911
  "menuDropdown": "-bXcdG_menuDropdown",
734
912
  "menuItem": "-bXcdG_menuItem",
@@ -779,6 +957,13 @@ window.__ModuleLoader__.load({
779
957
  "tab.transfer": "传输",
780
958
  "tab.tunnels": "隧道",
781
959
  "tab.cluster": "集群",
960
+ "session.server": "当前服务器",
961
+ "session.target": "跟随当前会话:{alias} · {remoteRoot}",
962
+ "session.localTitle": "当前会话位于本地工作区",
963
+ "session.localUnavailable": "SSH 操作台仅适用于 SSH 工作区会话。请切换到远程会话后使用。",
964
+ "connectionError.title": "无法连接服务器 {alias}",
965
+ "connectionError.intro": "SSH 连接未建立,请检查服务器地址、网络和认证配置。",
966
+ "connectionError.close": "知道了",
782
967
  "hosts.empty": "尚未配置主机。点击「新增主机」添加,或从 ~/.ssh/config 导入。",
783
968
  "hosts.add": "新增主机",
784
969
  "hosts.import": "导入 ~/.ssh/config",
@@ -833,7 +1018,7 @@ window.__ModuleLoader__.load({
833
1018
  "terminal.selectHost": "选择主机",
834
1019
  "terminal.connect": "连接",
835
1020
  "terminal.disconnect": "断开",
836
- "terminal.placeholder": "选择主机后点击「连接」打开远程终端。",
1021
+ "terminal.placeholder": "点击「连接」打开当前会话服务器的远程终端。",
837
1022
  "terminal.connecting": "正在连接…",
838
1023
  "terminal.ready": "终端已连接({alias})",
839
1024
  "terminal.exited": "终端已退出({alias})",
@@ -926,6 +1111,13 @@ window.__ModuleLoader__.load({
926
1111
  "tab.transfer": "Transfer",
927
1112
  "tab.tunnels": "Tunnels",
928
1113
  "tab.cluster": "Cluster",
1114
+ "session.server": "Current server",
1115
+ "session.target": "Following current session: {alias} · {remoteRoot}",
1116
+ "session.localTitle": "This session uses a local workspace",
1117
+ "session.localUnavailable": "The SSH console is available only for SSH workspace sessions. Switch to a remote session to use it.",
1118
+ "connectionError.title": "Unable to connect to {alias}",
1119
+ "connectionError.intro": "The SSH connection was not established. Check the server address, network, and authentication settings.",
1120
+ "connectionError.close": "OK",
929
1121
  "hosts.empty": "No hosts configured. Click \"Add host\" or import from ~/.ssh/config.",
930
1122
  "hosts.add": "Add host",
931
1123
  "hosts.import": "Import ~/.ssh/config",
@@ -980,7 +1172,7 @@ window.__ModuleLoader__.load({
980
1172
  "terminal.selectHost": "Select host",
981
1173
  "terminal.connect": "Connect",
982
1174
  "terminal.disconnect": "Disconnect",
983
- "terminal.placeholder": "Select a host and click \"Connect\" to open the remote terminal.",
1175
+ "terminal.placeholder": "Click \"Connect\" to open a terminal on the current session server.",
984
1176
  "terminal.connecting": "Connecting…",
985
1177
  "terminal.ready": "Terminal connected ({alias})",
986
1178
  "terminal.exited": "Terminal exited ({alias})",
@@ -1092,7 +1284,7 @@ window.__ModuleLoader__.load({
1092
1284
  }
1093
1285
  //#endregion
1094
1286
  //#region \0dsh-css:C:\Users\Kether\.dsh\dsh-hardssh\packages\dsh-hardssh\src\client\ssh\panel\panel.module.css.mjs
1095
- const css = "[data-pane=conversation]{position:relative}[data-dsh-ssh-view]{z-index:5;display:none;position:absolute;inset:0}html[data-dsh-ssh-active] [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active] [data-pane=conversation]>:not([data-dsh-ssh-view]){display:none}.GAuiuG_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}.GAuiuG_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}.GAuiuG_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}.GAuiuG_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}.GAuiuG_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] .GAuiuG_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] .GAuiuG_entryLabel{display:none}.GAuiuG_view{overflow:hidden}.GAuiuG_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}.GAuiuG_panelHeader{flex:none;align-items:center;gap:10px;display:flex}.GAuiuG_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.GAuiuG_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.GAuiuG_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.GAuiuG_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.GAuiuG_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.GAuiuG_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden auto}.GAuiuG_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.GAuiuG_toolbar,.GAuiuG_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.GAuiuG_controls .GAuiuG_input{flex:0 260px;min-width:150px}.GAuiuG_toolbarSpacer{flex:1}.GAuiuG_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.GAuiuG_search::placeholder{color:var(--dsw-alias-label-tertiary)}.GAuiuG_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.GAuiuG_table{border-collapse:collapse;width:100%;font-size:12.5px}.GAuiuG_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.GAuiuG_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.GAuiuG_table tbody tr:last-child td{border-bottom:none}.GAuiuG_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.GAuiuG_cellMuted{color:var(--dsw-alias-label-tertiary)}.GAuiuG_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.GAuiuG_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.GAuiuG_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.GAuiuG_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.GAuiuG_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.GAuiuG_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.GAuiuG_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.GAuiuG_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.GAuiuG_primaryButton:disabled{opacity:.5;cursor:default}.GAuiuG_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.GAuiuG_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_ghostButton:disabled{opacity:.45;cursor:default}.GAuiuG_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}.GAuiuG_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.GAuiuG_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.GAuiuG_linkButton:hover:not(:disabled){text-decoration:underline}.GAuiuG_linkButton:disabled{opacity:.45;cursor:default}.GAuiuG_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.GAuiuG_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite GAuiuG_dshSshSpin;display:inline-block}@keyframes GAuiuG_dshSshSpin{to{transform:rotate(360deg)}}.GAuiuG_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.GAuiuG_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.GAuiuG_empty,.GAuiuG_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.GAuiuG_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1,#00000073);justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}.GAuiuG_modal{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l1,#0000000a);color:#000;width:min(560px,100vw - 48px);min-width:0;min-height:0;max-height:calc(100vh - 48px);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);border-radius:12px;flex-direction:column;display:flex;position:relative;overflow:hidden}.GAuiuG_modalHeader{box-sizing:border-box;flex:none;align-items:center;gap:10px;min-width:0;padding:14px 18px 10px;display:flex}.GAuiuG_modalTitle{color:#000;flex:1;align-items:center;gap:8px;margin:0;font-size:15px;font-weight:600;display:flex}.GAuiuG_whaleMark{background:radial-gradient(circle at 32% 30%,#ffffffb3 0 12%,#0000 13%),radial-gradient(circle at 50% 60%,#4d6bfe 0 42%,#0000 43%);border-radius:50% 50% 38% 38%;flex:none;width:14px;height:14px;box-shadow:0 0 0 1px #4d6bfe59}.GAuiuG_modalBody{overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-caption,#80808080) transparent;flex-direction:column;flex:auto;gap:12px;min-width:0;min-height:0;padding:4px 18px 8px;display:flex;overflow:hidden auto}.GAuiuG_modalBody::-webkit-scrollbar{width:6px}.GAuiuG_modalBody::-webkit-scrollbar-thumb{background:var(--dsw-alias-label-caption,#80808080);border-radius:6px}.GAuiuG_modalBody::-webkit-scrollbar-track{background:0 0}.GAuiuG_modalFooter{box-sizing:border-box;border-top:1px solid var(--dsw-alias-border-l2,#00000014);flex:none;justify-content:flex-end;align-items:center;gap:10px;min-width:0;padding:12px 18px 14px;display:flex}.GAuiuG_modalPrimary{background:var(--dsw-alias-button-primary-fill,#0f1115);color:var(--dsw-alias-label-primary-foreground,#fff);cursor:pointer;border:none;border-radius:8px;padding:7px 18px;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s}.GAuiuG_modalPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#2b2f36)}.GAuiuG_modalPrimary:disabled{opacity:.5;cursor:default}.GAuiuG_modalGhost{border:1px solid var(--dsw-alias-border-l2,#00000026);color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;background:0 0;border-radius:8px;padding:7px 15px;font-family:inherit;font-size:13px;transition:background .15s}.GAuiuG_modalGhost:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.GAuiuG_modalGhost:disabled{opacity:.5;cursor:default}.GAuiuG_formError{background:var(--dsw-alias-state-error-bg,#f53f3f1a);border:1px solid var(--dsw-alias-state-error-primary,#f53f3f66);color:var(--dsw-alias-state-error-primary,#d03050);border-radius:6px;margin:0 18px 2px;padding:8px 12px;font-size:12px}.GAuiuG_hint{color:var(--dsw-alias-label-tertiary,#81858c);font-size:11.5px;line-height:1.45}.GAuiuG_field{flex-direction:column;gap:5px;min-width:0;display:flex}.GAuiuG_fieldLabel{color:var(--dsw-alias-label-primary,#0f1115);font-size:13px;font-weight:500}.GAuiuG_input{box-sizing:border-box;width:100%;min-width:0;min-height:36px;color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#00000024);border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}.GAuiuG_input:focus{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);box-shadow:0 0 0 2px #4176e626}.GAuiuG_input::placeholder{color:var(--dsw-alias-label-tertiary,#81858c)}.GAuiuG_input:disabled{opacity:.5}.GAuiuG_formRow{grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:12px;min-width:0;display:grid}.GAuiuG_radioRow{align-items:center;gap:18px;display:flex}.GAuiuG_radioLabel{color:#000;cursor:pointer;align-items:center;gap:7px;font-size:13px;display:inline-flex}.GAuiuG_radioLabel input[type=radio]{accent-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6)}.GAuiuG_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.GAuiuG_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.GAuiuG_termContainer{padding:8px 10px;position:absolute;inset:0}.GAuiuG_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.GAuiuG_hiddenFile{display:none}.GAuiuG_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.GAuiuG_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.GAuiuG_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.GAuiuG_browseList{max-height:240px;overflow:hidden auto}.GAuiuG_dirRow{text-align:left;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.GAuiuG_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_dirRow[data-type=dir] .GAuiuG_dirName{color:var(--dsw-alias-state-business-primary)}.GAuiuG_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.GAuiuG_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.GAuiuG_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.GAuiuG_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.GAuiuG_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.GAuiuG_progressMeta{min-width:0;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.GAuiuG_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.GAuiuG_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.GAuiuG_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.GAuiuG_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.GAuiuG_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.GAuiuG_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.GAuiuG_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.GAuiuG_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.GAuiuG_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.GAuiuG_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.GAuiuG_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}@media (max-width:420px){.GAuiuG_formRow{grid-template-columns:1fr}}.GAuiuG_fingerprintIntro{color:#000;margin:0 0 10px;font-size:13px;line-height:1.5}.GAuiuG_fingerprintCode{border:1px solid var(--dsw-alias-border-l2,#0000001f);background:var(--dsw-specific-input-major,#f2f3f5);color:#1f2229;word-break:break-all;-webkit-user-select:text;user-select:text;border-radius:6px;padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;display:block}.GAuiuG_fingerprintWarn{color:var(--dsw-alias-state-error-primary,#d03050);margin:10px 0 0;font-size:12px;line-height:1.5}";
1287
+ const css = ".GAuiuG_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex;position:relative}.GAuiuG_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.GAuiuG_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.GAuiuG_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.GAuiuG_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.GAuiuG_sessionTarget{color:var(--dsw-alias-label-secondary);text-overflow:ellipsis;white-space:nowrap;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;flex:none;padding:5px 9px;font-size:12px;line-height:18px;overflow:hidden}.GAuiuG_targetBadge{min-height:30px;color:var(--dsw-alias-state-business-primary);white-space:nowrap;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-state-business-primary);border-radius:8px;align-items:center;padding:4px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;display:inline-flex}.GAuiuG_disabledSurface{opacity:.4;filter:blur(3px);pointer-events:none;-webkit-user-select:none;user-select:none;flex-direction:column;flex:1;gap:10px;min-height:0;display:flex}.GAuiuG_disabledPlaceholder{background:linear-gradient(var(--dsw-alias-bg-layer-2), var(--dsw-alias-bg-layer-2)) 0 0 / 100% 34px no-repeat, repeating-linear-gradient(180deg, transparent 0 34px, var(--dsw-alias-separator-primary) 34px 35px);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:180px}.GAuiuG_localSessionMask{z-index:2;color:var(--dsw-alias-label-primary);text-align:center;background:color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:32px;display:flex;position:absolute;inset:0}.GAuiuG_localSessionMask strong{font-size:15px}.GAuiuG_localSessionMask span{max-width:340px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.55}.GAuiuG_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden auto}.GAuiuG_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.GAuiuG_toolbar,.GAuiuG_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.GAuiuG_controls .GAuiuG_input{flex:0 260px;min-width:150px}.GAuiuG_toolbarSpacer{flex:1}.GAuiuG_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.GAuiuG_search::placeholder{color:var(--dsw-alias-label-tertiary)}.GAuiuG_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.GAuiuG_table{border-collapse:collapse;width:100%;font-size:12.5px}.GAuiuG_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.GAuiuG_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.GAuiuG_table tbody tr:last-child td{border-bottom:none}.GAuiuG_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.GAuiuG_cellMuted{color:var(--dsw-alias-label-tertiary)}.GAuiuG_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.GAuiuG_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.GAuiuG_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.GAuiuG_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.GAuiuG_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.GAuiuG_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.GAuiuG_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.GAuiuG_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.GAuiuG_primaryButton:disabled{opacity:.5;cursor:default}.GAuiuG_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.GAuiuG_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_ghostButton:disabled{opacity:.45;cursor:default}.GAuiuG_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.GAuiuG_linkButton:hover:not(:disabled){text-decoration:underline}.GAuiuG_linkButton:disabled{opacity:.45;cursor:default}.GAuiuG_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.GAuiuG_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite GAuiuG_dshSshSpin;display:inline-block}@keyframes GAuiuG_dshSshSpin{to{transform:rotate(360deg)}}.GAuiuG_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.GAuiuG_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.GAuiuG_empty,.GAuiuG_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.GAuiuG_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1,#00000073);justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}.GAuiuG_modal{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l1,#0000000a);width:min(560px,100vw - 48px);min-width:0;min-height:0;max-height:calc(100vh - 48px);color:var(--dsw-alias-label-primary,#0f1115);font-family:var(--dsw-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, system-ui, \"PingFang SC\", \"Microsoft YaHei\", sans-serif);border-radius:12px;flex-direction:column;display:flex;position:relative;overflow:hidden}.GAuiuG_modalHeader{box-sizing:border-box;flex:none;align-items:center;gap:10px;min-width:0;padding:14px 18px 10px;display:flex}.GAuiuG_modalTitle{color:var(--dsw-alias-label-primary,#0f1115);flex:1;align-items:center;gap:8px;margin:0;font-size:15px;font-weight:600;display:flex}.GAuiuG_whaleMark{background:radial-gradient(circle at 32% 30%,#ffffffb3 0 12%,#0000 13%),radial-gradient(circle at 50% 60%,#4d6bfe 0 42%,#0000 43%);border-radius:50% 50% 38% 38%;flex:none;width:14px;height:14px;box-shadow:0 0 0 1px #4d6bfe59}.GAuiuG_modalBody{overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-caption,#80808080) transparent;flex-direction:column;flex:auto;gap:12px;min-width:0;min-height:0;padding:4px 18px 8px;display:flex;overflow:hidden auto}.GAuiuG_modalBody::-webkit-scrollbar{width:6px}.GAuiuG_modalBody::-webkit-scrollbar-thumb{background:var(--dsw-alias-label-caption,#80808080);border-radius:6px}.GAuiuG_modalBody::-webkit-scrollbar-track{background:0 0}.GAuiuG_modalFooter{box-sizing:border-box;border-top:1px solid var(--dsw-alias-border-l2,#00000014);flex:none;justify-content:flex-end;align-items:center;gap:10px;min-width:0;padding:12px 18px 14px;display:flex}.GAuiuG_modalPrimary{background:var(--dsw-alias-button-primary-fill,#0f1115);color:var(--dsw-alias-label-primary-foreground,#fff);cursor:pointer;border:none;border-radius:8px;padding:7px 18px;font-family:inherit;font-size:13px;font-weight:600;transition:background .15s}.GAuiuG_modalPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#2b2f36)}.GAuiuG_modalPrimary:disabled{opacity:.5;cursor:default}.GAuiuG_modalGhost{border:1px solid var(--dsw-alias-border-l2,#00000026);color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;background:0 0;border-radius:8px;padding:7px 15px;font-family:inherit;font-size:13px;transition:background .15s}.GAuiuG_modalGhost:hover:not(:disabled){background:var(--dsw-alias-bg-overlay,#0000000d)}.GAuiuG_modalGhost:disabled{opacity:.5;cursor:default}.GAuiuG_formError{background:var(--dsw-alias-state-error-bg,#f53f3f1a);border:1px solid var(--dsw-alias-state-error-primary,#f53f3f66);color:var(--dsw-alias-state-error-primary,#d03050);border-radius:6px;margin:0 18px 2px;padding:8px 12px;font-size:12px}.GAuiuG_hint{color:var(--dsw-alias-label-tertiary,#81858c);font-size:11.5px;line-height:1.45}.GAuiuG_field{flex-direction:column;gap:5px;min-width:0;display:flex}.GAuiuG_fieldLabel{color:var(--dsw-alias-label-primary,#0f1115);font-size:13px;font-weight:500}.GAuiuG_input{box-sizing:border-box;width:100%;min-width:0;min-height:36px;color:var(--dsw-alias-label-primary,#0f1115);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#00000024);border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}.GAuiuG_input:focus{border-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);box-shadow:0 0 0 2px #4176e626}.GAuiuG_input::placeholder{color:var(--dsw-alias-label-tertiary,#81858c)}.GAuiuG_input:disabled{opacity:.5}.GAuiuG_formRow{grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:12px;min-width:0;display:grid}.GAuiuG_radioRow{align-items:center;gap:18px;display:flex}.GAuiuG_radioLabel{color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;align-items:center;gap:7px;font-size:13px;display:inline-flex}.GAuiuG_radioLabel input[type=radio]{accent-color:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6)}.GAuiuG_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.GAuiuG_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.GAuiuG_termContainer{padding:8px 10px;position:absolute;inset:0}.GAuiuG_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.GAuiuG_hiddenFile{display:none}.GAuiuG_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.GAuiuG_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.GAuiuG_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.GAuiuG_browseList{max-height:240px;overflow:hidden auto}.GAuiuG_dirRow{text-align:left;width:100%;min-width:0;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.GAuiuG_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.GAuiuG_dirRow[data-type=dir] .GAuiuG_dirName{color:var(--dsw-alias-state-business-primary)}.GAuiuG_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.GAuiuG_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.GAuiuG_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.GAuiuG_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.GAuiuG_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.GAuiuG_progressMeta{min-width:0;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.GAuiuG_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.GAuiuG_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.GAuiuG_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.GAuiuG_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.GAuiuG_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.GAuiuG_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.GAuiuG_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.GAuiuG_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.GAuiuG_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.GAuiuG_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.GAuiuG_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.GAuiuG_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.GAuiuG_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.GAuiuG_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}@media (max-width:420px){.GAuiuG_formRow{grid-template-columns:1fr}}.GAuiuG_fingerprintIntro{color:var(--dsw-alias-label-primary,#0f1115);margin:0 0 10px;font-size:13px;line-height:1.5}.GAuiuG_fingerprintCode{border:1px solid var(--dsw-alias-border-l2,#0000001f);background:var(--dsw-specific-input-major,#f2f3f5);color:var(--dsw-alias-label-primary,#0f1115);word-break:break-all;-webkit-user-select:text;user-select:text;border-radius:6px;padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;display:block}.GAuiuG_fingerprintWarn{color:var(--dsw-alias-state-error-primary,#d03050);margin:10px 0 0;font-size:12px;line-height:1.5}";
1096
1288
  const tagId = "@tiphareth/dsh-hardssh/panel.module.css";
1097
1289
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1098
1290
  const tag = document.createElement("style");
@@ -1120,11 +1312,10 @@ window.__ModuleLoader__.load({
1120
1312
  "dirRow": "GAuiuG_dirRow",
1121
1313
  "dirSize": "GAuiuG_dirSize",
1122
1314
  "dirType": "GAuiuG_dirType",
1315
+ "disabledPlaceholder": "GAuiuG_disabledPlaceholder",
1316
+ "disabledSurface": "GAuiuG_disabledSurface",
1123
1317
  "dshSshSpin": "GAuiuG_dshSshSpin",
1124
1318
  "empty": "GAuiuG_empty",
1125
- "entry": "GAuiuG_entry",
1126
- "entryIcon": "GAuiuG_entryIcon",
1127
- "entryLabel": "GAuiuG_entryLabel",
1128
1319
  "field": "GAuiuG_field",
1129
1320
  "fieldLabel": "GAuiuG_fieldLabel",
1130
1321
  "fillBody": "GAuiuG_fillBody",
@@ -1137,11 +1328,11 @@ window.__ModuleLoader__.load({
1137
1328
  "ghostButton": "GAuiuG_ghostButton",
1138
1329
  "hiddenFile": "GAuiuG_hiddenFile",
1139
1330
  "hint": "GAuiuG_hint",
1140
- "iconButton": "GAuiuG_iconButton",
1141
1331
  "inlineTest": "GAuiuG_inlineTest",
1142
1332
  "input": "GAuiuG_input",
1143
1333
  "linkButton": "GAuiuG_linkButton",
1144
1334
  "loading": "GAuiuG_loading",
1335
+ "localSessionMask": "GAuiuG_localSessionMask",
1145
1336
  "modal": "GAuiuG_modal",
1146
1337
  "modalBackdrop": "GAuiuG_modalBackdrop",
1147
1338
  "modalBody": "GAuiuG_modalBody",
@@ -1153,8 +1344,6 @@ window.__ModuleLoader__.load({
1153
1344
  "mono": "GAuiuG_mono",
1154
1345
  "panel": "GAuiuG_panel",
1155
1346
  "panelContent": "GAuiuG_panelContent",
1156
- "panelHeader": "GAuiuG_panelHeader",
1157
- "panelTitle": "GAuiuG_panelTitle",
1158
1347
  "primaryButton": "GAuiuG_primaryButton",
1159
1348
  "progressBar": "GAuiuG_progressBar",
1160
1349
  "progressMeta": "GAuiuG_progressMeta",
@@ -1162,12 +1351,14 @@ window.__ModuleLoader__.load({
1162
1351
  "radioLabel": "GAuiuG_radioLabel",
1163
1352
  "radioRow": "GAuiuG_radioRow",
1164
1353
  "search": "GAuiuG_search",
1354
+ "sessionTarget": "GAuiuG_sessionTarget",
1165
1355
  "spinner": "GAuiuG_spinner",
1166
1356
  "tab": "GAuiuG_tab",
1167
1357
  "tabBar": "GAuiuG_tabBar",
1168
1358
  "tabBody": "GAuiuG_tabBody",
1169
1359
  "table": "GAuiuG_table",
1170
1360
  "tableWrap": "GAuiuG_tableWrap",
1361
+ "targetBadge": "GAuiuG_targetBadge",
1171
1362
  "termBody": "GAuiuG_termBody",
1172
1363
  "termContainer": "GAuiuG_termContainer",
1173
1364
  "termPlaceholder": "GAuiuG_termPlaceholder",
@@ -1178,7 +1369,6 @@ window.__ModuleLoader__.load({
1178
1369
  "tunnelLabel": "GAuiuG_tunnelLabel",
1179
1370
  "tunnelList": "GAuiuG_tunnelList",
1180
1371
  "tunnelRow": "GAuiuG_tunnelRow",
1181
- "view": "GAuiuG_view",
1182
1372
  "whaleMark": "GAuiuG_whaleMark"
1183
1373
  };
1184
1374
  //#endregion
@@ -1190,7 +1380,7 @@ window.__ModuleLoader__.load({
1190
1380
  * exposes the secret-free summary).
1191
1381
  */
1192
1382
  /** Split a comma-separated input into a trimmed, non-empty string list. */
1193
- function splitList$1(text) {
1383
+ function splitList(text) {
1194
1384
  return text.split(",").map((part) => part.trim()).filter((part) => part !== "");
1195
1385
  }
1196
1386
  /** Initial form state: the summary's public fields plus empty secrets. */
@@ -1257,10 +1447,10 @@ window.__ModuleLoader__.load({
1257
1447
  keyPath: form.keyPath.trim(),
1258
1448
  passphrase: form.passphrase === "" ? void 0 : form.passphrase
1259
1449
  },
1260
- proxyJump: splitList$1(form.proxyJump),
1450
+ proxyJump: splitList(form.proxyJump),
1261
1451
  description: form.description.trim() === "" ? void 0 : form.description.trim(),
1262
1452
  environment: form.environment.trim() === "" ? void 0 : form.environment.trim(),
1263
- tags: splitList$1(form.tags),
1453
+ tags: splitList(form.tags),
1264
1454
  location: form.location.trim() === "" ? void 0 : form.location.trim()
1265
1455
  };
1266
1456
  setSaving(true);
@@ -1576,99 +1766,36 @@ window.__ModuleLoader__.load({
1576
1766
  });
1577
1767
  }
1578
1768
  //#endregion
1579
- //#region src/client/manager-button.tsx
1769
+ //#region src/client/workspace-panel.tsx
1580
1770
  /**
1581
- * The SSH workspace manager: a session-header button opening a dropdown that
1582
- * lists SSH-bound workspaces grouped by server (two-level: server its
1583
- * workspaces) and lets the operator:
1584
- * - delete a workspace (existing),
1585
- * - edit / delete a SERVER (gear / minus on each server row; deleting a
1586
- * server is allowed only when it backs no workspace — the API also enforces
1587
- * this with a 409 HOST_IN_USE),
1588
- * - create a new server (bottom action), reusing the host form dialog.
1589
- * Rendering is a portal dropdown with its own React root (the shell exposes
1590
- * no modal slot for external plugins).
1771
+ * The SSH workspace manager as a CENTER panel (left sidebar global entry →
1772
+ * `main` slot). Content is unchanged from the old session-header dropdown:
1773
+ * server rows with their SSH-bound workspaces underneath, server edit/delete
1774
+ * (delete is refused while the server still backs a workspace), and a
1775
+ * "new server" action at the bottom.
1776
+ *
1777
+ * This is a plain panel body: no portal, no anchoring, no click-outside
1778
+ * dismissal. The left sidebar owns the entry row and the center column owns
1779
+ * the frame; closing the panel is the shell's business (select the
1780
+ * Conversation row back), not ours.
1591
1781
  */
1592
- /** The session-header button. Slot-injected props: manager + sshApi. */
1593
- function WorkspaceManagerButton(props) {
1594
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1595
- type: "button",
1596
- className: workspace_module_css_default.managerButton,
1597
- "data-ssh-workspace-manager": "",
1598
- title: tt$1("manager.tooltip"),
1599
- onClick: (event) => openManager(props.manager, props.sshApi, event.currentTarget),
1600
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1601
- viewBox: "0 0 16 16",
1602
- width: "12",
1603
- height: "12",
1604
- fill: "none",
1605
- stroke: "currentColor",
1606
- strokeWidth: "1.4",
1607
- strokeLinecap: "round",
1608
- strokeLinejoin: "round",
1609
- "aria-hidden": "true",
1610
- children: [
1611
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1612
- x: "2",
1613
- y: "3",
1614
- width: "12",
1615
- height: "10",
1616
- rx: "2"
1617
- }),
1618
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M6 6.5l2.2 1.6L6 9.7" }),
1619
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9.5 9.5h2" })
1620
- ]
1621
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tt$1("manager.label") })]
1622
- });
1623
- }
1624
- /** Open the manager dropdown, anchored under the trigger button. */
1625
- function openManager(manager, sshApi, trigger) {
1626
- const element = document.createElement("div");
1627
- element.dataset.sshWorkspaceManagerOverlay = "";
1628
- document.body.appendChild(element);
1629
- const root = (0, react_dom_client.createRoot)(element);
1630
- const close = () => {
1631
- root.unmount();
1632
- element.remove();
1633
- };
1634
- let anchor;
1635
- if (trigger !== void 0) {
1636
- const rect = trigger.getBoundingClientRect();
1637
- anchor = {
1638
- bottom: rect.bottom,
1639
- left: rect.left
1640
- };
1641
- }
1642
- root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ManagerOverlay, {
1643
- manager,
1644
- sshApi,
1645
- onClose: close,
1646
- anchor
1647
- }));
1648
- }
1649
- /** The manager dropdown: server + workspace rows, with server CRUD. */
1650
- function ManagerOverlay(props) {
1651
- const [workspaces, setWorkspaces] = (0, react.useState)(null);
1782
+ /** The workspace manager panel body. */
1783
+ function WorkspaceManagerPanel({ manager, sshApi }) {
1784
+ const [workspaces, setWorkspaces] = (0, react.useState)(() => manager.getSnapshot().workspaces);
1652
1785
  const [hosts, setHosts] = (0, react.useState)(null);
1786
+ const [connectedAliases, setConnectedAliases] = (0, react.useState)(null);
1787
+ /** Why connection state is unavailable — surfaced, never silently degraded. */
1788
+ const [connectionError, setConnectionError] = (0, react.useState)(null);
1653
1789
  const [error, setError] = (0, react.useState)(null);
1654
1790
  const [deleting, setDeleting] = (0, react.useState)(null);
1655
1791
  const [deletingAlias, setDeletingAlias] = (0, react.useState)(null);
1656
1792
  const [hostDialog, setHostDialog] = (0, react.useState)(null);
1657
- const menuRef = (0, react.useRef)(null);
1658
- (0, react.useEffect)(() => {
1659
- const onMouseDown = (event) => {
1660
- if (menuRef.current !== null && menuRef.current.contains(event.target)) return;
1661
- props.onClose();
1662
- };
1663
- document.addEventListener("mousedown", onMouseDown);
1664
- return () => document.removeEventListener("mousedown", onMouseDown);
1665
- }, [props]);
1666
- const load = () => {
1667
- setWorkspaces(props.manager.getSnapshot().workspaces);
1668
- };
1669
1793
  const loadHosts = () => {
1670
- if (props.sshApi === void 0) return;
1671
- props.sshApi.listHosts().then((list) => {
1794
+ if (sshApi === void 0) {
1795
+ setHosts([]);
1796
+ return;
1797
+ }
1798
+ sshApi.listHosts().then((list) => {
1672
1799
  setHosts(list);
1673
1800
  }, (cause) => {
1674
1801
  setError(cause instanceof Error ? cause.message : String(cause));
@@ -1676,74 +1803,89 @@ window.__ModuleLoader__.load({
1676
1803
  });
1677
1804
  };
1678
1805
  (0, react.useEffect)(() => {
1679
- load();
1680
- props.manager.refresh();
1681
- const unsubscribe = props.manager.subscribe(() => {
1682
- load();
1806
+ setWorkspaces(manager.getSnapshot().workspaces);
1807
+ manager.refresh();
1808
+ const unsubscribe = manager.subscribe(() => {
1809
+ setWorkspaces(manager.getSnapshot().workspaces);
1683
1810
  });
1684
1811
  loadHosts();
1812
+ let disposed = false;
1813
+ const refreshConnections = async () => {
1814
+ if (sshApi === void 0) {
1815
+ if (!disposed) setConnectedAliases(/* @__PURE__ */ new Set());
1816
+ return;
1817
+ }
1818
+ try {
1819
+ const aliases = await sshApi.connectedAliases();
1820
+ if (disposed) return;
1821
+ setConnectedAliases(new Set(aliases));
1822
+ setConnectionError(null);
1823
+ } catch (cause) {
1824
+ if (disposed) return;
1825
+ setConnectedAliases(null);
1826
+ setConnectionError(cause instanceof Error ? cause.message : String(cause));
1827
+ }
1828
+ };
1829
+ refreshConnections();
1830
+ const timer = window.setInterval(() => {
1831
+ refreshConnections();
1832
+ }, 3e3);
1685
1833
  return () => {
1834
+ disposed = true;
1835
+ window.clearInterval(timer);
1686
1836
  unsubscribe();
1687
1837
  };
1688
- }, []);
1838
+ }, [manager, sshApi]);
1689
1839
  const remove = (id) => {
1690
1840
  setDeleting(id);
1691
- props.manager.remove(id).catch((e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setDeleting(null));
1841
+ manager.remove(id).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))).finally(() => setDeleting(null));
1692
1842
  };
1693
1843
  const deleteHost = (alias) => {
1694
- if (props.sshApi === void 0) return;
1844
+ if (sshApi === void 0) return;
1695
1845
  setDeletingAlias(alias);
1696
- props.sshApi.deleteHost(alias).then(() => {
1846
+ sshApi.deleteHost(alias).then(() => {
1697
1847
  loadHosts();
1698
- }).catch((e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setDeletingAlias(null));
1848
+ }).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))).finally(() => setDeletingAlias(null));
1699
1849
  };
1700
1850
  const savedHost = () => {
1701
1851
  setHostDialog(null);
1702
1852
  loadHosts();
1703
- props.manager.refresh();
1853
+ manager.refresh();
1704
1854
  };
1705
- const anchorTop = props.anchor !== void 0 ? props.anchor.bottom + 4 : 56;
1706
- const anchorLeft = props.anchor !== void 0 ? props.anchor.left : Math.max(8, window.innerWidth - 380);
1707
- const top = Math.max(8, Math.min(anchorTop, window.innerHeight - 340));
1708
- const left = Math.max(8, Math.min(anchorLeft, window.innerWidth - 380));
1709
- return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1710
- ref: menuRef,
1711
- className: workspace_module_css_default.menuDropdown,
1712
- "data-ssh-workspace-manager-menu": "",
1713
- style: {
1714
- top,
1715
- left,
1716
- width: 380
1717
- },
1718
- onClick: (event) => event.stopPropagation(),
1855
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1856
+ className: workspace_module_css_default.managerPanel,
1719
1857
  children: [
1720
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1721
- className: workspace_module_css_default.managerMenuHeader,
1722
- children: [tt$1("manager.title"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1723
- type: "button",
1724
- className: workspace_module_css_default.managerMenuClose,
1725
- onClick: props.onClose,
1726
- "aria-label": tt$1("create.cancel"),
1727
- children: "×"
1728
- })]
1858
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1859
+ className: workspace_module_css_default.managerPanelHeader,
1860
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1861
+ className: workspace_module_css_default.managerPanelTitle,
1862
+ children: tt$1("manager.title")
1863
+ })
1729
1864
  }),
1730
1865
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1731
1866
  className: workspace_module_css_default.dialogFail,
1732
1867
  children: error
1733
1868
  }),
1734
- workspaces === null && hosts === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1869
+ connectionError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1870
+ className: workspace_module_css_default.dialogFail,
1871
+ "data-test": "connection-state-error",
1872
+ children: tt$1("manager.connectionError", connectionError)
1873
+ }),
1874
+ hosts === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1735
1875
  className: workspace_module_css_default.dialogInfo,
1736
1876
  children: tt$1("panel.loading")
1737
1877
  }),
1738
- workspaces !== null && workspaces.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1878
+ workspaces.length === 0 && hosts !== null && hosts.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1739
1879
  className: workspace_module_css_default.dialogHint,
1740
1880
  children: tt$1("manager.empty")
1741
1881
  }),
1742
1882
  hosts !== null && hosts.map((host) => {
1743
- const hostWorkspaces = workspaces === null ? [] : workspaces.filter((w) => w.alias === host.alias);
1883
+ const hostWorkspaces = workspaces.filter((workspace) => workspace.alias === host.alias);
1744
1884
  const inUse = hostWorkspaces.length > 0;
1885
+ const connectionState = connectedAliases === null ? "unknown" : connectedAliases.has(host.alias) ? "connected" : "disconnected";
1886
+ const connectionLabel = connectionState === "connected" ? tt$1("manager.connected") : connectionState === "disconnected" ? tt$1("manager.disconnected") : tt$1("manager.connectionUnknown");
1745
1887
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1746
- style: { marginBottom: 10 },
1888
+ className: workspace_module_css_default.managerPanelGroup,
1747
1889
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1748
1890
  className: workspace_module_css_default.managerGroupHeader,
1749
1891
  style: {
@@ -1766,6 +1908,15 @@ window.__ModuleLoader__.load({
1766
1908
  },
1767
1909
  children: host.alias
1768
1910
  }),
1911
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1912
+ className: workspace_module_css_default.connectionBadge,
1913
+ "data-state": connectionState,
1914
+ title: connectionLabel,
1915
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1916
+ className: workspace_module_css_default.connectionDot,
1917
+ "aria-hidden": "true"
1918
+ }), connectionLabel]
1919
+ }),
1769
1920
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1770
1921
  className: workspace_module_css_default.hostMeta,
1771
1922
  style: { fontSize: 11 },
@@ -1831,7 +1982,7 @@ window.__ModuleLoader__.load({
1831
1982
  overflow: "hidden",
1832
1983
  textOverflow: "ellipsis",
1833
1984
  whiteSpace: "nowrap",
1834
- maxWidth: 150
1985
+ maxWidth: 220
1835
1986
  },
1836
1987
  children: workspace.remoteRoot
1837
1988
  }),
@@ -1845,7 +1996,7 @@ window.__ModuleLoader__.load({
1845
1996
  },
1846
1997
  disabled: deleting === workspace.id,
1847
1998
  onClick: () => {
1848
- if (!deleting) remove(workspace.id);
1999
+ if (deleting === null) remove(workspace.id);
1849
2000
  },
1850
2001
  children: deleting === workspace.id ? "…" : tt$1("manager.delete")
1851
2002
  })
@@ -1853,12 +2004,8 @@ window.__ModuleLoader__.load({
1853
2004
  }, workspace.id))]
1854
2005
  }, host.alias);
1855
2006
  }),
1856
- props.sshApi !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1857
- style: {
1858
- marginTop: 4,
1859
- borderTop: `1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.1))`,
1860
- paddingTop: 6
1861
- },
2007
+ sshApi !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2008
+ className: workspace_module_css_default.managerPanelFooter,
1862
2009
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1863
2010
  type: "button",
1864
2011
  className: workspace_module_css_default.menuItem,
@@ -1871,8 +2018,8 @@ window.__ModuleLoader__.load({
1871
2018
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tt$1("manager.newHost") })]
1872
2019
  })
1873
2020
  }),
1874
- hostDialog !== null && props.sshApi !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFormDialog, {
1875
- api: props.sshApi,
2021
+ hostDialog !== null && sshApi !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFormDialog, {
2022
+ api: sshApi,
1876
2023
  editing: hostDialog.mode === "edit" ? hostDialog.host : null,
1877
2024
  onClose: () => {
1878
2025
  setHostDialog(null);
@@ -1880,7 +2027,60 @@ window.__ModuleLoader__.load({
1880
2027
  onSaved: savedHost
1881
2028
  })
1882
2029
  ]
1883
- }), document.body);
2030
+ });
2031
+ }
2032
+ //#endregion
2033
+ //#region src/client/workspace-panel-entry.tsx
2034
+ /** The main-panel key AND the sidebar row id: one identity addresses both. */
2035
+ const WORKSPACE_PANEL_ID = "dsh-hardssh-workspaces";
2036
+ /** Order among the global panel rows; the shipped rows sit around 0. */
2037
+ const WORKSPACE_PANEL_ORDER = 100;
2038
+ /** The cloud/terminal glyph the workspace row shows in both the wide and rail sidebar. */
2039
+ function WorkspacePanelIcon({ size }) {
2040
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2041
+ viewBox: "0 0 16 16",
2042
+ width: size,
2043
+ height: size,
2044
+ fill: "none",
2045
+ stroke: "currentColor",
2046
+ strokeWidth: "1.3",
2047
+ strokeLinecap: "round",
2048
+ strokeLinejoin: "round",
2049
+ "aria-hidden": "true",
2050
+ children: [
2051
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
2052
+ x: "2",
2053
+ y: "2.5",
2054
+ width: "12",
2055
+ height: "11",
2056
+ rx: "1.5"
2057
+ }),
2058
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4.5 5.5l2.5 2.5-2.5 2.5" }),
2059
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8.5 10.5h3" })
2060
+ ]
2061
+ });
2062
+ }
2063
+ /**
2064
+ * Register the left-sidebar row and the center panel body.
2065
+ * @param ctx - client root context carrying the slot registry.
2066
+ * @param deps - the workspace manager and the host API the panel uses.
2067
+ */
2068
+ function registerWorkspacePanel(ctx, deps) {
2069
+ const inject = () => ({
2070
+ manager: deps.manager,
2071
+ sshApi: deps.sshApi
2072
+ });
2073
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
2074
+ name: "sidebar.panellist",
2075
+ id: WORKSPACE_PANEL_ID,
2076
+ order: WORKSPACE_PANEL_ORDER,
2077
+ label: () => tt$1("panel.workspaces")
2078
+ }, WorkspacePanelIcon));
2079
+ ctx.slots.inject("main", () => ctx.slots.register({
2080
+ name: "main",
2081
+ key: WORKSPACE_PANEL_ID,
2082
+ inject
2083
+ }, WorkspaceManagerPanel));
1884
2084
  }
1885
2085
  //#endregion
1886
2086
  //#region src/client/migrate.ts
@@ -2043,6 +2243,13 @@ window.__ModuleLoader__.load({
2043
2243
  password: ""
2044
2244
  });
2045
2245
  const [savingHost, setSavingHost] = (0, react.useState)(false);
2246
+ const openRef = (0, react.useRef)(open);
2247
+ openRef.current = open;
2248
+ const localPickRequestSequence = (0, react.useRef)(0);
2249
+ const hostsRequestSequence = (0, react.useRef)(0);
2250
+ const directoryRequestSequence = (0, react.useRef)(0);
2251
+ const createWorkspaceRequestSequence = (0, react.useRef)(0);
2252
+ const createHostRequestSequence = (0, react.useRef)(0);
2046
2253
  (0, react.useEffect)(() => {
2047
2254
  if (!open) return;
2048
2255
  setChoice("menu");
@@ -2050,6 +2257,9 @@ window.__ModuleLoader__.load({
2050
2257
  setDirPath("");
2051
2258
  setTitle("");
2052
2259
  setDirEntries([]);
2260
+ setLoading(false);
2261
+ setCreating(false);
2262
+ setSavingHost(false);
2053
2263
  setError(null);
2054
2264
  setAddingHost(false);
2055
2265
  setNewHost({
@@ -2060,7 +2270,19 @@ window.__ModuleLoader__.load({
2060
2270
  password: ""
2061
2271
  });
2062
2272
  setAnchorRect();
2063
- props.listHosts().then((list) => setHosts(list)).catch(() => setHosts([]));
2273
+ const request = ++hostsRequestSequence.current;
2274
+ props.listHosts().then((list) => {
2275
+ if (request === hostsRequestSequence.current && openRef.current) setHosts(list);
2276
+ }, () => {
2277
+ if (request === hostsRequestSequence.current && openRef.current) setHosts([]);
2278
+ });
2279
+ return () => {
2280
+ localPickRequestSequence.current += 1;
2281
+ hostsRequestSequence.current += 1;
2282
+ directoryRequestSequence.current += 1;
2283
+ createWorkspaceRequestSequence.current += 1;
2284
+ createHostRequestSequence.current += 1;
2285
+ };
2064
2286
  }, [open]);
2065
2287
  /** Locate the trigger element that just opened the flow and anchor the menu
2066
2288
  * directly under it. Priority: the focused element (the button the operator
@@ -2105,42 +2327,50 @@ window.__ModuleLoader__.load({
2105
2327
  if (!open) return null;
2106
2328
  /** Choose "Local workspace": drive the native chooser once. */
2107
2329
  const pickLocal = () => {
2330
+ const request = ++localPickRequestSequence.current;
2108
2331
  setChoice("picking-local");
2109
2332
  props.pickDirectory().then((path) => {
2333
+ if (request !== localPickRequestSequence.current || !openRef.current) return;
2110
2334
  if (path === null) outcome.current.onCancel();
2111
2335
  else outcome.current.onPicked(path);
2112
- }, (reason) => outcome.current.onError(reason instanceof Error ? reason.message : String(reason)));
2336
+ }, (reason) => {
2337
+ if (request === localPickRequestSequence.current && openRef.current) outcome.current.onError(reason instanceof Error ? reason.message : String(reason));
2338
+ });
2113
2339
  };
2114
2340
  /** Browse a remote dir level for the SSH branch. When the connection needs
2115
2341
  * a credential (host key untrusted / session password), run the interactive
2116
2342
  * gate first, then retry — the operator never sees a raw "connect" error. */
2117
2343
  const browse = async (alias, path) => {
2344
+ const request = ++directoryRequestSequence.current;
2118
2345
  if (alias === "") {
2119
2346
  setError(tt$1("create.needHost"));
2120
2347
  return;
2121
2348
  }
2122
- for (let attempt = 0; attempt < 3; attempt++) {
2123
- setLoading(true);
2124
- setError(null);
2125
- try {
2349
+ setLoading(true);
2350
+ setError(null);
2351
+ try {
2352
+ for (let attempt = 0; attempt < 3; attempt++) try {
2126
2353
  const result = await props.listRemoteDir(alias, path);
2354
+ if (request !== directoryRequestSequence.current || !openRef.current) return;
2127
2355
  setDirPath(result.path);
2128
2356
  setDirEntries(result.entries);
2129
2357
  setChoice("browsing");
2130
- setLoading(false);
2131
2358
  return;
2132
2359
  } catch (e) {
2360
+ if (request !== directoryRequestSequence.current || !openRef.current) return;
2133
2361
  const code = e?.code;
2134
- const interactive = code === "NEEDS_PASSWORD" || code === "HOST_KEY_UNKNOWN" || code === "HOST_KEY_MISMATCH";
2135
- setLoading(false);
2136
- if (!interactive) {
2362
+ if (!(code === "NEEDS_PASSWORD" || code === "HOST_KEY_UNKNOWN" || code === "HOST_KEY_MISMATCH")) {
2137
2363
  setError(e instanceof Error ? e.message : String(e));
2138
2364
  return;
2139
2365
  }
2140
- if (!await props.ensureConnected(alias)) return;
2366
+ const connected = await props.ensureConnected(alias);
2367
+ if (request !== directoryRequestSequence.current || !openRef.current) return;
2368
+ if (!connected) return;
2141
2369
  }
2370
+ if (request === directoryRequestSequence.current && openRef.current) setError(tt$1("create.needConnect"));
2371
+ } finally {
2372
+ if (request === directoryRequestSequence.current && openRef.current) setLoading(false);
2142
2373
  }
2143
- setError(tt$1("create.needConnect"));
2144
2374
  };
2145
2375
  /** Create the SSH workspace; on success hand the anchor to the owner. */
2146
2376
  const createSsh = async () => {
@@ -2148,6 +2378,7 @@ window.__ModuleLoader__.load({
2148
2378
  setError(host === "" ? tt$1("create.needHost") : dirPath === "" ? tt$1("create.needDir") : tt$1("create.needTitle"));
2149
2379
  return;
2150
2380
  }
2381
+ const request = ++createWorkspaceRequestSequence.current;
2151
2382
  setCreating(true);
2152
2383
  setError(null);
2153
2384
  try {
@@ -2156,11 +2387,12 @@ window.__ModuleLoader__.load({
2156
2387
  alias: host,
2157
2388
  remoteRoot: dirPath
2158
2389
  });
2390
+ if (request !== createWorkspaceRequestSequence.current || !openRef.current) return;
2159
2391
  outcome.current.onPicked(record.anchorPath);
2160
2392
  } catch (e) {
2161
- setError(e instanceof Error ? e.message : String(e));
2393
+ if (request === createWorkspaceRequestSequence.current && openRef.current) setError(e instanceof Error ? e.message : String(e));
2162
2394
  } finally {
2163
- setCreating(false);
2395
+ if (request === createWorkspaceRequestSequence.current && openRef.current) setCreating(false);
2164
2396
  }
2165
2397
  };
2166
2398
  /** Save a newly-added SSH host, refresh the list, select it, and close the
@@ -2171,6 +2403,7 @@ window.__ModuleLoader__.load({
2171
2403
  setError(tt$1("host.needFields"));
2172
2404
  return;
2173
2405
  }
2406
+ const request = ++createHostRequestSequence.current;
2174
2407
  setSavingHost(true);
2175
2408
  setError(null);
2176
2409
  try {
@@ -2184,7 +2417,10 @@ window.__ModuleLoader__.load({
2184
2417
  password: newHost.password
2185
2418
  }
2186
2419
  });
2420
+ if (request !== createHostRequestSequence.current || !openRef.current) return;
2421
+ const hostsRequest = ++hostsRequestSequence.current;
2187
2422
  const list = await props.listHosts();
2423
+ if (request !== createHostRequestSequence.current || hostsRequest !== hostsRequestSequence.current || !openRef.current) return;
2188
2424
  setHosts(list);
2189
2425
  setHost(created.alias);
2190
2426
  setAddingHost(false);
@@ -2196,9 +2432,9 @@ window.__ModuleLoader__.load({
2196
2432
  password: ""
2197
2433
  });
2198
2434
  } catch (e) {
2199
- setError(e instanceof Error ? e.message : String(e));
2435
+ if (request === createHostRequestSequence.current && openRef.current) setError(e instanceof Error ? e.message : String(e));
2200
2436
  } finally {
2201
- setSavingHost(false);
2437
+ if (request === createHostRequestSequence.current && openRef.current) setSavingHost(false);
2202
2438
  }
2203
2439
  };
2204
2440
  const menuWidth = choice === "menu" ? 200 : 360;
@@ -2358,6 +2594,7 @@ window.__ModuleLoader__.load({
2358
2594
  style: { gridColumn: "1 / -1" },
2359
2595
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tt$1("create.host") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2360
2596
  value: host,
2597
+ disabled: loading,
2361
2598
  onChange: (event) => setHost(event.target.value),
2362
2599
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2363
2600
  value: "",
@@ -2384,6 +2621,7 @@ window.__ModuleLoader__.load({
2384
2621
  gridColumn: "1 / -1",
2385
2622
  padding: "5px 8px"
2386
2623
  },
2624
+ disabled: loading,
2387
2625
  onClick: () => setAddingHost(true),
2388
2626
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2389
2627
  className: workspace_module_css_default.menuItemIcon,
@@ -2395,6 +2633,7 @@ window.__ModuleLoader__.load({
2395
2633
  style: { gridColumn: "1 / -1" },
2396
2634
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tt$1("create.dir") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2397
2635
  value: dirPath,
2636
+ disabled: loading,
2398
2637
  onChange: (event) => setDirPath(event.target.value),
2399
2638
  placeholder: tt$1("create.dirPlaceholder"),
2400
2639
  spellCheck: false
@@ -2428,12 +2667,12 @@ window.__ModuleLoader__.load({
2428
2667
  type: "button",
2429
2668
  className: workspace_module_css_default.button,
2430
2669
  onClick: () => setChoice("menu"),
2431
- disabled: busy || creating,
2670
+ disabled: busy || creating || loading,
2432
2671
  children: tt$1("create.cancel")
2433
2672
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2434
2673
  type: "button",
2435
2674
  className: `${workspace_module_css_default.button} ${workspace_module_css_default.buttonPrimary}`,
2436
- disabled: busy || creating,
2675
+ disabled: busy || creating || loading,
2437
2676
  onClick: () => void createSsh(),
2438
2677
  children: creating ? tt$1("create.connecting") : tt$1("create.submit")
2439
2678
  })]
@@ -2491,6 +2730,7 @@ window.__ModuleLoader__.load({
2491
2730
  }), dirEntries.filter((entry) => entry.type === "dir").map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2492
2731
  type: "button",
2493
2732
  className: workspace_module_css_default.hostRow,
2733
+ disabled: loading,
2494
2734
  style: {
2495
2735
  width: "100%",
2496
2736
  textAlign: "left",
@@ -2499,7 +2739,7 @@ window.__ModuleLoader__.load({
2499
2739
  alignItems: "center",
2500
2740
  border: "none",
2501
2741
  background: "transparent",
2502
- cursor: "pointer",
2742
+ cursor: loading ? "default" : "pointer",
2503
2743
  padding: "4px 6px"
2504
2744
  },
2505
2745
  onClick: () => {
@@ -2517,11 +2757,13 @@ window.__ModuleLoader__.load({
2517
2757
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2518
2758
  type: "button",
2519
2759
  className: workspace_module_css_default.button,
2760
+ disabled: loading,
2520
2761
  onClick: () => setChoice("ssh"),
2521
2762
  children: tt$1("create.cancel")
2522
2763
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2523
2764
  type: "button",
2524
2765
  className: `${workspace_module_css_default.button} ${workspace_module_css_default.buttonPrimary}`,
2766
+ disabled: loading,
2525
2767
  onClick: () => setChoice("ssh"),
2526
2768
  children: tt$1("flow.useThisDir")
2527
2769
  })]
@@ -2532,98 +2774,58 @@ window.__ModuleLoader__.load({
2532
2774
  }), document.body);
2533
2775
  }
2534
2776
  //#endregion
2535
- //#region src/client/workspace-badges.ts
2777
+ //#region src/client/ssh/panel/ConnectionErrorDialog.tsx
2778
+ /** User-visible terminal connection failure surfaced by the Session connect gate. */
2779
+ /** A small acknowledgement dialog for non-interactive SSH connection failures. */
2780
+ function ConnectionErrorDialog({ alias, detail, onClose }) {
2781
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2782
+ className: panel_module_css_default.modalBackdrop,
2783
+ onClick: onClose,
2784
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2785
+ className: panel_module_css_default.modal,
2786
+ role: "alertdialog",
2787
+ "aria-modal": "true",
2788
+ "aria-labelledby": "dsh-ssh-connection-error-title",
2789
+ onClick: (event) => {
2790
+ event.stopPropagation();
2791
+ },
2792
+ children: [
2793
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2794
+ className: panel_module_css_default.modalHeader,
2795
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2796
+ id: "dsh-ssh-connection-error-title",
2797
+ className: panel_module_css_default.modalTitle,
2798
+ children: tt("connectionError.title", { alias })
2799
+ })
2800
+ }),
2801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2802
+ className: panel_module_css_default.modalBody,
2803
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tt("connectionError.intro") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2804
+ className: panel_module_css_default.formError,
2805
+ children: detail
2806
+ })]
2807
+ }),
2808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2809
+ className: panel_module_css_default.modalFooter,
2810
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2811
+ type: "button",
2812
+ className: panel_module_css_default.modalPrimary,
2813
+ autoFocus: true,
2814
+ onClick: onClose,
2815
+ children: tt("connectionError.close")
2816
+ })
2817
+ })
2818
+ ]
2819
+ })
2820
+ });
2821
+ }
2822
+ //#endregion
2823
+ //#region src/client/ssh/panel/HostFingerprintDialog.tsx
2536
2824
  /**
2537
- * Sidebar workspace-row augmentation: labels every host workspace row whose
2538
- * title matches an SSH-bound workspace with a compact remote badge
2539
- * (`alias @ remoteRoot`), so a glance tells a local workspace from a remote
2540
- * one. Pure DOM-level extension (the sidebar shell exposes no slot for row
2541
- * decoration), following the dsh-ssh sidebar-entry precedent: a
2542
- * MutationObserver self-heals when React re-renders displace the injected
2543
- * badge.
2544
- *
2545
- * The row title alone matches: SSH-bound workspace titles are unique enough
2546
- * in practice (the ledger title is what the row shows). A title match against
2547
- * `~/.dsh/ssh-workspaces/<id>` anchors also works when the host picks that
2548
- * default title.
2549
- *
2550
- * @module dsh-hardssh/client/workspace-badges
2551
- */
2552
- const PROJECT_ROW_SELECTOR$1 = "[class*=\"projectRow\"]";
2553
- const TITLE_SELECTOR$1 = "[class*=\"title\"]";
2554
- /** Render a small remote badge element. The cloud icon marks "remote";
2555
- * connected servers render in DeepSeek blue, disconnected stay gray. The
2556
- * tooltip shows the REMOTE directory plus the server, e.g.
2557
- * `/data/app (prod-01)`. */
2558
- function makeBadge(alias, remoteRoot, connected) {
2559
- const badge = document.createElement("span");
2560
- badge.dataset.sshBadge = connected ? "connected" : "disconnected";
2561
- badge.title = `${remoteRoot}(${alias})`;
2562
- badge.setAttribute("style", connected ? "display:inline-flex;align-items:center;gap:3px;margin-left:6px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:#1476E6;border:1px solid #1476E6;border-radius:999px;padding:0 6px;background:#D4E0F7;flex:none" : "display:inline-flex;align-items:center;gap:3px;margin-left:6px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(128,128,128,.75));border:1px solid var(--dsw-alias-line-secondary,rgba(128,128,128,.28));border-radius:999px;padding:0 6px;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.10));flex:none");
2563
- const icon = document.createElement("span");
2564
- icon.setAttribute("style", "display:inline-flex;flex:none;line-height:1");
2565
- icon.innerHTML = "<svg viewBox=\"0 0 1024 1024\" width=\"11\" height=\"11\" fill=\"currentColor\" aria-hidden=\"true\"><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\"/><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\"/></svg>";
2566
- const label = document.createElement("span");
2567
- label.textContent = alias;
2568
- label.setAttribute("style", "font-weight:400");
2569
- badge.append(icon, label);
2570
- return badge;
2571
- }
2572
- /**
2573
- * Mount the row-decoration pass. Queries the sidebar for project rows and
2574
- * appends a remote badge when the row's title belongs to an SSH workspace.
2575
- * Badges of aliases in `connected` render blue, others gray. Self-heals via
2576
- * MutationObserver on the sidebar container (rows re-render on
2577
- * session/workspace changes).
2578
- * @param workspaces - the SSH workspace records (id/title/alias/remoteRoot).
2579
- * @param connected - aliases with a live pooled connection (badge coloring).
2580
- * @returns disposer.
2581
- */
2582
- function mountWorkspaceBadges(workspaces, connected) {
2583
- const byTitle = /* @__PURE__ */ new Map();
2584
- for (const workspace of workspaces) byTitle.set(workspace.title, workspace);
2585
- /** Decorate one project row: ALWAYS keep the row tooltip = the REMOTE
2586
- * directory (server), never the local anchor path the shell would show;
2587
- * mount the badge once (idempotent via the guard). Re-runs on every scan,
2588
- * so a shell re-render that resets <title> gets corrected. */
2589
- const decorate = (row) => {
2590
- const titleEl = row.querySelector(TITLE_SELECTOR$1);
2591
- if (titleEl === null || titleEl === void 0) return;
2592
- const title = titleEl.textContent?.trim() ?? "";
2593
- const target = byTitle.get(title);
2594
- if (target === void 0) return;
2595
- const desiredTitle = `${target.remoteRoot}(${target.alias})`;
2596
- if (row.title !== desiredTitle) row.title = desiredTitle;
2597
- if (row.querySelector("[data-ssh-badge]") !== null) return;
2598
- const isConnected = connected?.has(target.alias) ?? false;
2599
- titleEl.appendChild(makeBadge(target.alias, target.remoteRoot, isConnected));
2600
- };
2601
- /** Scan the sidebar column for project rows and decorate each. */
2602
- const scan = () => {
2603
- for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR$1)) decorate(row);
2604
- };
2605
- const observer = new MutationObserver(() => {
2606
- scan();
2607
- });
2608
- observer.observe(document.body, {
2609
- childList: true,
2610
- subtree: true,
2611
- attributes: true,
2612
- attributeFilter: ["title"]
2613
- });
2614
- scan();
2615
- return () => {
2616
- observer.disconnect();
2617
- for (const badge of document.querySelectorAll("[data-ssh-badge]")) badge.remove();
2618
- };
2619
- }
2620
- //#endregion
2621
- //#region src/client/ssh/panel/HostFingerprintDialog.tsx
2622
- /**
2623
- * Host fingerprint confirm dialog: shown when a connection is refused because
2624
- * the server's host key is not trusted yet (TOFU first encounter) or changed
2625
- * (possible MITM / key rotation). Displays the canonical SHA256 fingerprint
2626
- * and lets the operator confirm (trust) or reset (forget) the record.
2825
+ * Host fingerprint confirm dialog: shown when a connection is refused because
2826
+ * the server's host key is not trusted yet (TOFU first encounter) or changed
2827
+ * (possible MITM / key rotation). Displays the canonical SHA256 fingerprint
2828
+ * and lets the operator confirm (trust) or reset (forget) the record.
2627
2829
  */
2628
2830
  /** Confirm (or reset) one host key. */
2629
2831
  function HostFingerprintDialog({ api, alias, fingerprintSha256, mismatch, onClose, onTrusted }) {
@@ -2735,7 +2937,7 @@ window.__ModuleLoader__.load({
2735
2937
  * process exit. Nothing is written to disk.
2736
2938
  */
2737
2939
  /** Prompt for a session-only credential. */
2738
- function SessionSecretDialog({ api, alias, user, secret, onClose, onProvided }) {
2940
+ function SessionSecretDialog({ api, alias, user, secret, reason, onClose, onProvided }) {
2739
2941
  const [value, setValue] = (0, react.useState)("");
2740
2942
  const [busy, setBusy] = (0, react.useState)(false);
2741
2943
  const [error, setError] = (0, react.useState)(null);
@@ -2776,22 +2978,29 @@ window.__ModuleLoader__.load({
2776
2978
  }),
2777
2979
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2778
2980
  className: panel_module_css_default.modalBody,
2779
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2780
- className: panel_module_css_default.input,
2781
- type: "password",
2782
- value,
2783
- autoFocus: true,
2784
- placeholder: secret === "password" ? tt("sessionSecret.placeholder") : tt("sessionSecret.placeholderPassphrase"),
2785
- onChange: (event) => {
2786
- setValue(event.target.value);
2787
- },
2788
- onKeyDown: (event) => {
2789
- if (event.key === "Enter") submit();
2790
- }
2791
- }), error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2792
- className: panel_module_css_default.formError,
2793
- children: error
2794
- })]
2981
+ children: [
2982
+ reason !== void 0 && reason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2983
+ className: panel_module_css_default.formError,
2984
+ children: reason
2985
+ }),
2986
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2987
+ className: panel_module_css_default.input,
2988
+ type: "password",
2989
+ value,
2990
+ autoFocus: true,
2991
+ placeholder: secret === "password" ? tt("sessionSecret.placeholder") : tt("sessionSecret.placeholderPassphrase"),
2992
+ onChange: (event) => {
2993
+ setValue(event.target.value);
2994
+ },
2995
+ onKeyDown: (event) => {
2996
+ if (event.key === "Enter") submit();
2997
+ }
2998
+ }),
2999
+ error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3000
+ className: panel_module_css_default.formError,
3001
+ children: error
3002
+ })
3003
+ ]
2795
3004
  }),
2796
3005
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2797
3006
  className: panel_module_css_default.modalFooter,
@@ -2816,61 +3025,58 @@ window.__ModuleLoader__.load({
2816
3025
  });
2817
3026
  }
2818
3027
  //#endregion
2819
- //#region src/client/workspace-gate.ts
3028
+ //#region src/client/connect-host.ts
2820
3029
  /**
2821
- * Sidebar workspace-row click gate: when the operator clicks an SSH-bound
2822
- * workspace row in the session sidebar, ensure the server connection can
2823
- * actually be established BEFORE the shell proceeds with the native open —
2824
- * otherwise the shell surfaces raw SSH errors (host key untrusted, session
2825
- * password missing) and the workspace appears "dead" with no prompt.
3030
+ * Probe-and-prompt for an SSH alias, with a browser-session pass cache.
2826
3031
  *
2827
- * The gate runs the engine's /test probe (a real, lightweight connect):
2828
- * - HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH the host fingerprint confirm
2829
- * dialog (HostFingerprintDialog; TOFU first encounter / key rotation);
2830
- * - NEEDS_PASSWORD / NEEDS_PASSPHRASE → the VSCode-style session password
2831
- * dialog (SessionSecretDialog; never persisted, session-only);
2832
- * - ok → mark the alias as gated (5-minute TTL — trust is durable and the
2833
- * session password survives in the engine table) and re-dispatch the click.
3032
+ * This is the interactive connection gate on its own no row, no banner, no
3033
+ * DOM decoration. Flows that surface their own errors (the workspace creation
3034
+ * directory browser) call `connectHost` and get a plain boolean.
2834
3035
  *
2835
- * Pure DOM-level extension following the workspace-badges precedent: the
2836
- * sidebar shell exposes no row slot, so a MutationObserver self-heals when
2837
- * React re-renders displace the injected listener. A capture-phase listener
2838
- * on the row blocks the shell's click while a dialog is needed, then lets
2839
- * clicks through once the alias has passed the gate.
3036
+ * The probe is the engine's `/test` route (a real, lightweight connect):
3037
+ * - `HOST_KEY_UNKNOWN` / `HOST_KEY_MISMATCH` the host fingerprint dialog
3038
+ * (TOFU first encounter / key rotation);
3039
+ * - `NEEDS_PASSWORD` / `NEEDS_PASSPHRASE` the VSCode-style session password
3040
+ * dialog (never persisted, session-only);
3041
+ * - ok → the alias is marked passed for a TTL (trust is durable and the session
3042
+ * password survives in the engine's session table until the process exits).
2840
3043
  *
2841
- * @module dsh-hardssh/client/workspace-gate
3044
+ * @module dsh-hardssh/client/connect-host
2842
3045
  */
2843
- const PROJECT_ROW_SELECTOR = "[class*=\"projectRow\"]";
2844
- const TITLE_SELECTOR = "[class*=\"title\"]";
2845
- /** A passed gate stays valid this long (trust is durable; the session
2846
- * password table lives in the host process for the session). */
3046
+ /** A passed alias stays valid this long (trust is durable; the session password
3047
+ * table lives in the host process for the session). */
2847
3048
  const GATE_TTL_MS = 3e5;
2848
3049
  /** Cap on interactive retry rounds (wrong password loops etc.). */
2849
3050
  const MAX_ATTEMPTS = 3;
2850
- /** alias → last successful gate timestamp (browser-session scope). */
2851
- const gatePassedAt = /* @__PURE__ */ new Map();
2852
- /** alias → in-flight gate promise (no duplicate dialogs on multi-click). */
3051
+ /** alias → last successful probe timestamp (browser-session scope). */
3052
+ const passedAt = /* @__PURE__ */ new Map();
3053
+ /** alias → in-flight probe promise (no duplicate dialogs on multi-click). */
2853
3054
  const pending = /* @__PURE__ */ new Map();
2854
3055
  /** alias → SSH login name (fetched once for the user@alias prompt). */
2855
3056
  let hostsCache = null;
3057
+ /** At most one visible non-interactive failure dialog per alias. */
3058
+ const failureDialogs = /* @__PURE__ */ new Map();
2856
3059
  async function hostsUser(api, alias) {
2857
3060
  if (hostsCache === null) try {
2858
3061
  const list = await api.listHosts();
2859
3062
  hostsCache = new Map(list.map((host) => [host.alias, host.user]));
2860
3063
  } catch {
2861
- hostsCache = /* @__PURE__ */ new Map();
3064
+ return;
2862
3065
  }
2863
3066
  return hostsCache.get(alias);
2864
3067
  }
3068
+ /** Whether the alias passed the gate within its TTL. */
2865
3069
  function gateActive(alias, now) {
2866
- const at = gatePassedAt.get(alias);
3070
+ const at = passedAt.get(alias);
2867
3071
  return at !== void 0 && now - at < GATE_TTL_MS;
2868
3072
  }
2869
- /** Mount a dialog component into a fresh portal root; returns its closer.
2870
- * The dialog's onClose prop is CHAINED: the caller's handler (resolving its
2871
- * promise) runs first, then the portal is torn down. Without the chaining a
2872
- * cancel would unmount the dialog but never resolve the caller's promise,
2873
- * leaving the alias stuck "in flight" so later clicks stop prompting. */
3073
+ /**
3074
+ * Mount a dialog component into a fresh root; returns its closer. The dialog's
3075
+ * `onClose` prop is CHAINED: the caller's handler (resolving its promise) runs
3076
+ * first, then the root is torn down. Without the chaining a cancel would
3077
+ * unmount the dialog but never resolve the caller's promise, leaving the alias
3078
+ * stuck "in flight" so later calls stop prompting.
3079
+ */
2874
3080
  function mountModal(node) {
2875
3081
  const host = document.createElement("div");
2876
3082
  host.dataset.sshGateDialog = "";
@@ -2891,18 +3097,20 @@ window.__ModuleLoader__.load({
2891
3097
  }));
2892
3098
  return close;
2893
3099
  }
2894
- /** Transient inline error under the clicked row (removed after 8 s).
2895
- * A null row means the caller owns error surfacing — stay silent. */
2896
- function showBanner(row, alias, message) {
2897
- if (row === null) return;
2898
- const banner = document.createElement("div");
2899
- banner.dataset.sshGateError = "";
2900
- banner.textContent = `${tt$1("gate.failed", alias)}: ${message}`;
2901
- banner.setAttribute("style", "margin:2px 4px;padding:6px 8px;font-size:12px;color:#b00020;background:rgba(176,0,32,.08);border-radius:4px;white-space:normal;line-height:1.5");
2902
- row.insertAdjacentElement("afterend", banner);
2903
- setTimeout(() => {
2904
- banner.remove();
2905
- }, 8e3);
3100
+ /** Log and surface one concrete non-interactive connection failure. */
3101
+ function reportConnectionFailure(alias, detail) {
3102
+ console.warn(`[dsh-hardssh] SSH probe failed for '${alias}': ${detail}`);
3103
+ if (typeof document === "undefined") return false;
3104
+ failureDialogs.get(alias)?.();
3105
+ const close = mountModal((0, react.createElement)(ConnectionErrorDialog, {
3106
+ alias,
3107
+ detail,
3108
+ onClose: () => {
3109
+ failureDialogs.delete(alias);
3110
+ }
3111
+ }));
3112
+ failureDialogs.set(alias, close);
3113
+ return false;
2906
3114
  }
2907
3115
  /** Interactive prompts, resolving true when the user finished the action. */
2908
3116
  function promptFingerprint(api, alias, fingerprintSha256, mismatch) {
@@ -2922,7 +3130,7 @@ window.__ModuleLoader__.load({
2922
3130
  }));
2923
3131
  });
2924
3132
  }
2925
- function promptSecret(api, alias, secret) {
3133
+ function promptSecret(api, alias, secret, reason) {
2926
3134
  return new Promise((resolve) => {
2927
3135
  let closed = false;
2928
3136
  const finish = (ok) => {
@@ -2936,6 +3144,7 @@ window.__ModuleLoader__.load({
2936
3144
  alias,
2937
3145
  user,
2938
3146
  secret,
3147
+ reason,
2939
3148
  onClose: () => {
2940
3149
  finish(false);
2941
3150
  },
@@ -2948,39 +3157,56 @@ window.__ModuleLoader__.load({
2948
3157
  });
2949
3158
  }
2950
3159
  /**
2951
- * Probe + prompt until connected. Resolves true when the alias may proceed
2952
- * (the gate passed); false when the user cancelled or the failure is not
2953
- * interactive (banner left behind).
3160
+ * Probe + prompt until connected. Resolves true when the alias may proceed;
3161
+ * false when the user cancelled or the failure is not interactive. When a
3162
+ * credential was already supplied and the retry still fails (wrong password,
3163
+ * unreachable host, network drop, …), the password dialog re-opens WITH the
3164
+ * failure reason shown, VSCode style — no silent "stuck" states.
3165
+ *
3166
+ * @param api - the SSH API client.
3167
+ * @param alias - the host alias to gate.
2954
3168
  */
2955
- function ensureConnected(api, alias, row) {
3169
+ function connectHost(api, alias) {
2956
3170
  if (gateActive(alias, Date.now())) return Promise.resolve(true);
2957
3171
  const inFlight = pending.get(alias);
2958
3172
  if (inFlight !== void 0) return inFlight;
2959
3173
  const attempt = (async () => {
3174
+ let secretKind;
3175
+ let lastFailure = null;
2960
3176
  for (let i = 0; i < MAX_ATTEMPTS; i++) try {
2961
3177
  const result = await api.testHost(alias);
2962
3178
  if (result.ok) {
2963
- gatePassedAt.set(alias, Date.now());
3179
+ passedAt.set(alias, Date.now());
2964
3180
  return true;
2965
3181
  }
2966
3182
  if (result.code === "NEEDS_PASSWORD" && (result.secret === "password" || result.secret === "passphrase")) {
2967
- if (await promptSecret(api, alias, result.secret)) continue;
3183
+ secretKind = result.secret;
3184
+ if (await promptSecret(api, alias, result.secret, lastFailure ?? void 0)) {
3185
+ lastFailure = null;
3186
+ continue;
3187
+ }
2968
3188
  return false;
2969
3189
  }
2970
- showBanner(row, alias, result.error ?? "connection failed");
2971
- return false;
3190
+ lastFailure = result.error ?? "connection failed";
3191
+ return reportConnectionFailure(alias, lastFailure);
2972
3192
  } catch (cause) {
2973
- if (cause instanceof SshApiError && (cause.code === "HOST_KEY_UNKNOWN" || cause.code === "HOST_KEY_MISMATCH")) {
3193
+ if (cause instanceof HttpApiError && (cause.code === "HOST_KEY_UNKNOWN" || cause.code === "HOST_KEY_MISMATCH")) {
2974
3194
  const fingerprint = cause.hostKeyFingerprint ?? "";
2975
3195
  if (fingerprint !== "" && await promptFingerprint(api, alias, fingerprint, cause.code === "HOST_KEY_MISMATCH")) continue;
2976
- if (fingerprint === "") showBanner(row, alias, cause.message);
3196
+ if (fingerprint === "") return reportConnectionFailure(alias, cause.message);
3197
+ return false;
3198
+ }
3199
+ lastFailure = cause instanceof Error ? cause.message : String(cause);
3200
+ if (secretKind !== void 0) {
3201
+ if (await promptSecret(api, alias, secretKind, lastFailure)) {
3202
+ lastFailure = null;
3203
+ continue;
3204
+ }
2977
3205
  return false;
2978
3206
  }
2979
- showBanner(row, alias, cause instanceof Error ? cause.message : String(cause));
2980
- return false;
3207
+ return reportConnectionFailure(alias, lastFailure);
2981
3208
  }
2982
- showBanner(row, alias, tt$1("gate.retryLimit"));
2983
- return false;
3209
+ return reportConnectionFailure(alias, lastFailure ?? tt$1("gate.retryLimit"));
2984
3210
  })();
2985
3211
  pending.set(alias, attempt);
2986
3212
  attempt.finally(() => {
@@ -2988,68 +3214,199 @@ window.__ModuleLoader__.load({
2988
3214
  });
2989
3215
  return attempt;
2990
3216
  }
2991
- /** Silent prompt-only connection gate (no row / banner) for flows that
2992
- * surface their own errors (e.g. the directory browser). */
2993
- function connectHost(api, alias) {
2994
- return ensureConnected(api, alias, null);
3217
+ //#endregion
3218
+ //#region src/client/workspace-badges.ts
3219
+ /**
3220
+ * Sidebar workspace-row augmentation: labels every host workspace row whose
3221
+ * title matches an SSH-bound workspace with a compact remote badge
3222
+ * (`alias @ remoteRoot`), so a glance tells a local workspace from a remote
3223
+ * one. Pure DOM-level extension (the sidebar shell exposes no slot for row
3224
+ * decoration), following the dsh-ssh sidebar-entry precedent: a
3225
+ * MutationObserver self-heals when React re-renders displace the injected
3226
+ * badge.
3227
+ *
3228
+ * The row title alone matches: SSH-bound workspace titles are unique enough
3229
+ * in practice (the ledger title is what the row shows). A title match against
3230
+ * `~/.dsh/ssh-workspaces/<id>` anchors also works when the host picks that
3231
+ * default title.
3232
+ *
3233
+ * @module dsh-hardssh/client/workspace-badges
3234
+ */
3235
+ const PROJECT_ROW_SELECTOR = "[class*=\"projectRow\"]";
3236
+ const TITLE_SELECTOR = "[class*=\"title\"]";
3237
+ /** Render a small remote badge element. The cloud icon marks "remote";
3238
+ * connected servers render in DeepSeek blue, disconnected stay gray. The
3239
+ * tooltip shows the REMOTE directory plus the server, e.g.
3240
+ * `/data/app (prod-01)`. */
3241
+ function makeBadge(alias, remoteRoot, connected) {
3242
+ const badge = document.createElement("span");
3243
+ badge.dataset.sshBadge = connected ? "connected" : "disconnected";
3244
+ badge.title = `${remoteRoot}(${alias})`;
3245
+ badge.setAttribute("style", connected ? "display:inline-flex;align-items:center;gap:3px;margin-left:6px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:#1476E6;border:1px solid #1476E6;border-radius:999px;padding:0 6px;background:#D4E0F7;flex:none" : "display:inline-flex;align-items:center;gap:3px;margin-left:6px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(128,128,128,.75));border:1px solid var(--dsw-alias-line-secondary,rgba(128,128,128,.28));border-radius:999px;padding:0 6px;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.10));flex:none");
3246
+ const icon = document.createElement("span");
3247
+ icon.setAttribute("style", "display:inline-flex;flex:none;line-height:1");
3248
+ icon.innerHTML = "<svg viewBox=\"0 0 1024 1024\" width=\"11\" height=\"11\" fill=\"currentColor\" aria-hidden=\"true\"><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\"/><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\"/></svg>";
3249
+ const label = document.createElement("span");
3250
+ label.textContent = alias;
3251
+ label.setAttribute("style", "font-weight:400");
3252
+ badge.append(icon, label);
3253
+ return badge;
2995
3254
  }
2996
3255
  /**
2997
- * Mount the click gate over every sidebar workspace row whose title belongs
2998
- * to an SSH-bound workspace. Self-heals via MutationObserver. Returns a
2999
- * disposer that removes listeners and injected state.
3256
+ * Mount the row-decoration pass. Queries the sidebar for project rows and
3257
+ * appends a remote badge when the row's title belongs to an SSH workspace.
3258
+ * Badges of aliases in `connected` render blue, others gray. Self-heals via
3259
+ * MutationObserver on the sidebar container (rows re-render on
3260
+ * session/workspace changes).
3261
+ * @param workspaces - the SSH workspace records (id/title/alias/remoteRoot).
3262
+ * @param connected - aliases with a live pooled connection (badge coloring).
3263
+ * @returns disposer.
3000
3264
  */
3001
- function mountWorkspaceGates(workspaces, api) {
3265
+ function mountWorkspaceBadges(workspaces, connected) {
3002
3266
  const byTitle = /* @__PURE__ */ new Map();
3003
- for (const workspace of workspaces) byTitle.set(workspace.title, workspace.alias);
3004
- const attach = (row) => {
3005
- if (row.dataset.sshGate !== void 0) return;
3267
+ for (const workspace of workspaces) byTitle.set(workspace.title, workspace);
3268
+ /** Decorate one project row: ALWAYS keep the row tooltip = the REMOTE
3269
+ * directory (server), never the local anchor path the shell would show;
3270
+ * mount the badge once (idempotent via the guard). Re-runs on every scan,
3271
+ * so a shell re-render that resets <title> gets corrected. */
3272
+ const decorate = (row) => {
3006
3273
  const titleEl = row.querySelector(TITLE_SELECTOR);
3007
3274
  if (titleEl === null || titleEl === void 0) return;
3008
- const alias = byTitle.get(titleEl.textContent?.trim() ?? "");
3009
- if (alias === void 0) return;
3010
- row.dataset.sshGate = "1";
3011
- row.addEventListener("click", (event) => {
3012
- if (gateActive(alias, Date.now())) return;
3013
- event.preventDefault();
3014
- event.stopPropagation();
3015
- ensureConnected(api, alias, row).then((ok) => {
3016
- if (ok) row.click();
3017
- });
3018
- }, true);
3275
+ const title = titleEl.textContent?.trim() ?? "";
3276
+ const target = byTitle.get(title);
3277
+ if (target === void 0) return;
3278
+ const desiredTitle = `${target.remoteRoot}(${target.alias})`;
3279
+ if (row.title !== desiredTitle) row.title = desiredTitle;
3280
+ if (row.querySelector("[data-ssh-badge]") !== null) return;
3281
+ const isConnected = connected?.has(target.alias) ?? false;
3282
+ titleEl.appendChild(makeBadge(target.alias, target.remoteRoot, isConnected));
3019
3283
  };
3284
+ /** Scan the sidebar column for project rows and decorate each. */
3020
3285
  const scan = () => {
3021
- for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR)) attach(row);
3286
+ for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR)) decorate(row);
3022
3287
  };
3023
3288
  const observer = new MutationObserver(() => {
3024
3289
  scan();
3025
3290
  });
3026
3291
  observer.observe(document.body, {
3027
3292
  childList: true,
3028
- subtree: true
3293
+ subtree: true,
3294
+ attributes: true,
3295
+ attributeFilter: ["title"]
3029
3296
  });
3030
3297
  scan();
3031
3298
  return () => {
3032
3299
  observer.disconnect();
3033
- for (const row of document.querySelectorAll("[data-ssh-gate]")) delete row.dataset.sshGate;
3300
+ for (const badge of document.querySelectorAll("[data-ssh-badge]")) badge.remove();
3301
+ };
3302
+ }
3303
+ //#endregion
3304
+ //#region src/client/session-connect-gate.ts
3305
+ /** Normalize one path for anchor comparison: unified separators, no trailing
3306
+ * separator, and case-folded for Windows-shaped paths (Windows anchors are
3307
+ * case-insensitive; POSIX anchors are not).
3308
+ *
3309
+ * Deliberately a SECOND implementation of `base/ledger.ts`'s
3310
+ * `normalizeAnchorPath`: the base module imports `node:fs` for its own
3311
+ * persistence and therefore cannot be pulled into the browser bundle. Keep the
3312
+ * two in step — the host-side duplicates were consolidated for exactly this
3313
+ * reason, and this one is the documented exception. */
3314
+ function normalizeAnchor(path) {
3315
+ const unified = path.replace(/\\/gu, "/").replace(/\/+$/u, "");
3316
+ if (/^[a-zA-Z]:\//u.test(unified) || unified.startsWith("//")) return unified.toLowerCase();
3317
+ return unified;
3318
+ }
3319
+ /** True when `cwd` is the anchor itself or sits inside it. */
3320
+ function isUnderAnchor(anchor, cwd) {
3321
+ return cwd === anchor || cwd.startsWith(`${anchor}/`);
3322
+ }
3323
+ /**
3324
+ * Build the cwd → workspace resolver over a live workspace list. The LONGEST
3325
+ * matching anchor wins, so a workspace nested under another one still routes to
3326
+ * its own server.
3327
+ * @param workspaces - live records carrying an anchor path.
3328
+ * @returns resolver returning the owning record, or undefined when unbound.
3329
+ */
3330
+ function makeAnchorWorkspaceResolver(workspaces) {
3331
+ return (cwd) => {
3332
+ if (cwd === void 0 || cwd === "") return void 0;
3333
+ const target = normalizeAnchor(cwd);
3334
+ let best;
3335
+ let bestLength = -1;
3336
+ for (const workspace of workspaces()) {
3337
+ const anchor = normalizeAnchor(workspace.anchorPath);
3338
+ if (anchor === "" || !isUnderAnchor(anchor, target)) continue;
3339
+ if (anchor.length > bestLength) {
3340
+ bestLength = anchor.length;
3341
+ best = workspace;
3342
+ }
3343
+ }
3344
+ return best;
3345
+ };
3346
+ }
3347
+ /** Alias-only compatibility face used by the connection gate. */
3348
+ function makeAnchorAliasResolver(workspaces) {
3349
+ const resolve = makeAnchorWorkspaceResolver(workspaces);
3350
+ return (cwd) => resolve(cwd)?.alias;
3351
+ }
3352
+ /**
3353
+ * Watch the session list and probe the server behind every SSH-bound session
3354
+ * the operator opens or creates.
3355
+ *
3356
+ * The first pass ADOPTS the existing list without probing: a GUI restore must
3357
+ * not fire one connection dialog per remembered session. Only the session the
3358
+ * shell actually selects (including the restored current one) and sessions
3359
+ * created after that pass trigger a probe.
3360
+ *
3361
+ * @param deps - session list view, anchor resolver and probe.
3362
+ * @returns disposer removing the subscription.
3363
+ */
3364
+ function mountSessionConnectGate(deps) {
3365
+ const adopted = /* @__PURE__ */ new Set();
3366
+ let primed = false;
3367
+ let lastCurrent;
3368
+ /** Probe the server owning one session's workspace (best-effort). */
3369
+ const probe = (sessionId) => {
3370
+ if (sessionId === void 0) return;
3371
+ const alias = deps.aliasForCwd(deps.sessions.sessionCwd(sessionId));
3372
+ if (alias === void 0) return;
3373
+ deps.ensureConnected(alias).catch((error) => {
3374
+ console.warn("[dsh-hardssh] session connect gate failed:", error);
3375
+ });
3376
+ };
3377
+ const pass = () => {
3378
+ if (!primed && deps.sessions.phase() !== "ready") return;
3379
+ const ids = deps.sessions.sessionIds();
3380
+ const probed = /* @__PURE__ */ new Set();
3381
+ if (!primed) {
3382
+ for (const id of ids) adopted.add(id);
3383
+ primed = true;
3384
+ } else for (const id of ids) {
3385
+ if (adopted.has(id)) continue;
3386
+ adopted.add(id);
3387
+ probed.add(id);
3388
+ probe(id);
3389
+ }
3390
+ const current = deps.sessions.currentSessionId();
3391
+ if (current !== lastCurrent) {
3392
+ lastCurrent = current;
3393
+ if (current !== void 0 && !probed.has(current)) probe(current);
3394
+ }
3034
3395
  };
3396
+ const dispose = deps.sessions.subscribe(pass);
3397
+ pass();
3398
+ return dispose;
3035
3399
  }
3036
3400
  //#endregion
3037
3401
  //#region src/client/ssh/panel/ClusterTab.tsx
3038
3402
  /**
3039
- * Cluster tab: run one command across many hosts (aliases / environment /
3040
- * tags filters) and inspect per-host results. Stdout/stderr render in
3403
+ * Cluster tab: run one command against the selected Session's fixed server and
3404
+ * inspect its result. Stdout/stderr render in
3041
3405
  * collapsed <details> blocks; status renders as a colored badge.
3042
3406
  */
3043
- /** Split a comma-separated input into a trimmed, non-empty string list. */
3044
- function splitList(text) {
3045
- return text.split(",").map((part) => part.trim()).filter((part) => part !== "");
3046
- }
3047
3407
  /** The cluster execution tab. */
3048
- function ClusterTab({ api }) {
3408
+ function ClusterTab({ api, alias }) {
3049
3409
  const [command, setCommand] = (0, react.useState)("");
3050
- const [aliases, setAliases] = (0, react.useState)("");
3051
- const [environment, setEnvironment] = (0, react.useState)("");
3052
- const [tags, setTags] = (0, react.useState)("");
3053
3410
  const [running, setRunning] = (0, react.useState)(false);
3054
3411
  const [results, setResults] = (0, react.useState)(null);
3055
3412
  const [error, setError] = (0, react.useState)(null);
@@ -3059,13 +3416,9 @@ window.__ModuleLoader__.load({
3059
3416
  setRunning(true);
3060
3417
  setError(null);
3061
3418
  try {
3062
- const aliasList = splitList(aliases);
3063
- const tagList = splitList(tags);
3064
3419
  const outcome = await api.cluster({
3065
3420
  command: command.trim(),
3066
- aliases: aliasList.length > 0 ? aliasList : void 0,
3067
- environment: environment.trim() === "" ? void 0 : environment.trim(),
3068
- tags: tagList.length > 0 ? tagList : void 0
3421
+ aliases: [alias]
3069
3422
  });
3070
3423
  setResults(outcome);
3071
3424
  } catch (cause) {
@@ -3094,421 +3447,93 @@ window.__ModuleLoader__.load({
3094
3447
  })]
3095
3448
  }),
3096
3449
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3097
- className: panel_module_css_default.clusterFilters,
3098
- children: [
3099
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3100
- className: panel_module_css_default.input,
3101
- placeholder: tt("cluster.aliases"),
3102
- value: aliases,
3103
- onChange: (event) => {
3104
- setAliases(event.target.value);
3105
- }
3106
- }),
3107
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3108
- className: panel_module_css_default.input,
3109
- placeholder: tt("cluster.environment"),
3110
- value: environment,
3111
- onChange: (event) => {
3112
- setEnvironment(event.target.value);
3113
- }
3114
- }),
3115
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3116
- className: panel_module_css_default.input,
3117
- placeholder: tt("cluster.tags"),
3118
- value: tags,
3119
- onChange: (event) => {
3120
- setTags(event.target.value);
3121
- }
3122
- })
3123
- ]
3124
- }),
3125
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3126
- type: "button",
3127
- className: panel_module_css_default.primaryButton,
3128
- disabled: running || command.trim() === "",
3129
- onClick: () => {
3130
- run();
3131
- },
3132
- children: tt("cluster.run")
3133
- }) })
3134
- ]
3135
- }),
3136
- error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3137
- className: panel_module_css_default.banner,
3138
- "data-kind": "error",
3139
- children: tt("common.error", { error })
3140
- }),
3141
- results === null && error === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3142
- className: panel_module_css_default.empty,
3143
- children: tt("cluster.empty")
3144
- }),
3145
- results !== null && results.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3146
- className: panel_module_css_default.empty,
3147
- children: tt("cluster.noMatch")
3148
- }),
3149
- results !== null && results.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3150
- className: panel_module_css_default.tableWrap,
3151
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
3152
- className: panel_module_css_default.table,
3153
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3154
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.alias") }),
3155
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.status") }),
3156
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.exit") }),
3157
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stdout") }),
3158
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stderr") }),
3159
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.error") }),
3160
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.duration") })
3161
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: results.map((result) => {
3162
- const status = result.ok ? "ok" : result.timedOut === true ? "timeout" : "fail";
3163
- const label = result.ok ? tt("cluster.ok") : result.timedOut === true ? tt("cluster.timeout") : tt("cluster.fail");
3164
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3165
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3166
- className: panel_module_css_default.mono,
3167
- children: result.alias
3168
- }),
3169
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3170
- className: panel_module_css_default.badge,
3171
- "data-status": status,
3172
- children: label
3173
- }) }),
3174
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3175
- className: panel_module_css_default.mono,
3176
- children: result.exitCode ?? "-"
3177
- }),
3178
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stdout !== void 0 && result.stdout !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3179
- className: panel_module_css_default.cellDetails,
3180
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stdout") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3181
- className: panel_module_css_default.cellPre,
3182
- children: result.stdout
3183
- })]
3184
- }) }),
3185
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stderr !== void 0 && result.stderr !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3186
- className: panel_module_css_default.cellDetails,
3187
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stderr") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3188
- className: panel_module_css_default.cellPre,
3189
- children: result.stderr
3190
- })]
3191
- }) }),
3192
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3193
- className: panel_module_css_default.cellMuted,
3194
- children: result.error ?? ""
3195
- }),
3196
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3197
- className: panel_module_css_default.mono,
3198
- children: result.durationMs !== void 0 ? result.durationMs + " ms" : "-"
3199
- })
3200
- ] }, result.alias);
3201
- }) })]
3202
- })
3203
- })
3204
- ]
3205
- });
3206
- }
3207
- //#endregion
3208
- //#region src/client/ssh/panel/HostsTab.tsx
3209
- /**
3210
- * Hosts tab: the host table with search (debounced through listHosts),
3211
- * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
3212
- * that hands the alias to the terminal tab via onConnect.
3213
- */
3214
- /** The hosts table plus its toolbar and dialogs. */
3215
- function HostsTab({ api, onConnect }) {
3216
- const [hosts, setHosts] = (0, react.useState)(null);
3217
- const [error, setError] = (0, react.useState)(null);
3218
- const [search, setSearch] = (0, react.useState)("");
3219
- const [testingAlias, setTestingAlias] = (0, react.useState)(null);
3220
- const [testResults, setTestResults] = (0, react.useState)({});
3221
- const [importing, setImporting] = (0, react.useState)(false);
3222
- const [notice, setNotice] = (0, react.useState)(null);
3223
- const [dialog, setDialog] = (0, react.useState)(null);
3224
- const [hostKeyConfirm, setHostKeyConfirm] = (0, react.useState)(null);
3225
- const [secretConfirm, setSecretConfirm] = (0, react.useState)(null);
3226
- const seqRef = (0, react.useRef)(0);
3227
- const load = (0, react.useCallback)(async (query) => {
3228
- const seq = ++seqRef.current;
3229
- try {
3230
- const list = await api.listHosts(query);
3231
- if (seq !== seqRef.current) return;
3232
- setHosts(list);
3233
- setError(null);
3234
- } catch (cause) {
3235
- if (seq !== seqRef.current) return;
3236
- setError(errorMessage(cause));
3237
- }
3238
- }, [api]);
3239
- (0, react.useEffect)(() => {
3240
- load();
3241
- }, [load]);
3242
- (0, react.useEffect)(() => {
3243
- const timer = setTimeout(() => {
3244
- const query = search.trim();
3245
- load(query === "" ? void 0 : query);
3246
- }, 300);
3247
- return () => {
3248
- clearTimeout(timer);
3249
- };
3250
- }, [search, load]);
3251
- const runTest = async (alias) => {
3252
- setTestingAlias(alias);
3253
- try {
3254
- const result = await api.testHost(alias);
3255
- if (result.code === "NEEDS_PASSWORD" && (result.secret === "password" || result.secret === "passphrase")) {
3256
- setSecretConfirm({
3257
- alias,
3258
- secret: result.secret,
3259
- user: hosts?.find((host) => host.alias === alias)?.user
3260
- });
3261
- setTestingAlias(null);
3262
- return;
3263
- }
3264
- setTestResults((prev) => ({
3265
- ...prev,
3266
- [alias]: result
3267
- }));
3268
- } catch (cause) {
3269
- if (cause instanceof SshApiError && (cause.code === "HOST_KEY_UNKNOWN" || cause.code === "HOST_KEY_MISMATCH")) {
3270
- setHostKeyConfirm({
3271
- alias,
3272
- fingerprintSha256: cause.hostKeyFingerprint ?? "",
3273
- mismatch: cause.code === "HOST_KEY_MISMATCH"
3274
- });
3275
- setTestingAlias(null);
3276
- return;
3277
- }
3278
- setTestResults((prev) => ({
3279
- ...prev,
3280
- [alias]: {
3281
- ok: false,
3282
- error: errorMessage(cause)
3283
- }
3284
- }));
3285
- } finally {
3286
- setTestingAlias(null);
3287
- }
3288
- };
3289
- /** After the fingerprint is confirmed (or reset), retry the test. */
3290
- const retryAfterFingerprint = async (alias) => {
3291
- setHostKeyConfirm(null);
3292
- await runTest(alias);
3293
- };
3294
- /** After the session secret is provided, retry the test. */
3295
- const retryAfterSecret = async (alias) => {
3296
- setSecretConfirm(null);
3297
- await runTest(alias);
3298
- };
3299
- const deleteHost = async (alias) => {
3300
- if (!window.confirm(tt("hosts.deleteConfirm", { alias }))) return;
3301
- try {
3302
- await api.deleteHost(alias);
3303
- load();
3304
- } catch (cause) {
3305
- setError(errorMessage(cause));
3306
- }
3307
- };
3308
- const importConfig = async () => {
3309
- setImporting(true);
3310
- try {
3311
- const result = await api.importSshConfig();
3312
- setNotice(tt("hosts.imported", {
3313
- parsed: result.parsed,
3314
- added: result.added,
3315
- skipped: result.skipped
3316
- }));
3317
- load();
3318
- } catch (cause) {
3319
- setError(errorMessage(cause));
3320
- } finally {
3321
- setImporting(false);
3322
- }
3323
- };
3324
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3325
- className: panel_module_css_default.fillBody,
3326
- children: [
3327
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3328
- className: panel_module_css_default.toolbar,
3329
- children: [
3330
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3331
- className: panel_module_css_default.search,
3332
- type: "search",
3333
- placeholder: tt("hosts.search"),
3334
- value: search,
3335
- onChange: (event) => {
3336
- setSearch(event.target.value);
3337
- }
3338
- }),
3339
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: panel_module_css_default.toolbarSpacer }),
3340
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3341
- type: "button",
3342
- className: panel_module_css_default.primaryButton,
3343
- onClick: () => {
3344
- setDialog({ mode: "create" });
3345
- },
3346
- children: tt("hosts.add")
3450
+ className: panel_module_css_default.controls,
3451
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3452
+ className: panel_module_css_default.fieldLabel,
3453
+ children: tt("session.server")
3454
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3455
+ className: panel_module_css_default.targetBadge,
3456
+ children: alias
3457
+ })]
3347
3458
  }),
3348
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3459
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3349
3460
  type: "button",
3350
- className: panel_module_css_default.ghostButton,
3351
- disabled: importing,
3461
+ className: panel_module_css_default.primaryButton,
3462
+ disabled: running || command.trim() === "",
3352
3463
  onClick: () => {
3353
- importConfig();
3464
+ run();
3354
3465
  },
3355
- children: importing ? tt("common.loading") : tt("hosts.import")
3356
- })
3466
+ children: tt("cluster.run")
3467
+ }) })
3357
3468
  ]
3358
3469
  }),
3359
- notice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3360
- className: panel_module_css_default.banner,
3361
- "data-kind": "ok",
3362
- children: notice
3363
- }),
3364
3470
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3365
3471
  className: panel_module_css_default.banner,
3366
3472
  "data-kind": "error",
3367
3473
  children: tt("common.error", { error })
3368
3474
  }),
3369
- hosts === null && error === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3370
- className: panel_module_css_default.loading,
3371
- children: tt("common.loading")
3475
+ results === null && error === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3476
+ className: panel_module_css_default.empty,
3477
+ children: tt("cluster.empty")
3372
3478
  }),
3373
- hosts !== null && hosts.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3479
+ results !== null && results.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3374
3480
  className: panel_module_css_default.empty,
3375
- children: tt("hosts.empty")
3481
+ children: tt("cluster.noMatch")
3376
3482
  }),
3377
- hosts !== null && hosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3483
+ results !== null && results.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3378
3484
  className: panel_module_css_default.tableWrap,
3379
3485
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
3380
3486
  className: panel_module_css_default.table,
3381
3487
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3382
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.alias") }),
3383
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.host") }),
3384
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.user") }),
3385
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.auth") }),
3386
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.environment") }),
3387
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.tags") }),
3388
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.description") }),
3389
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.actions") })
3390
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: hosts.map((host) => {
3391
- const test = testResults[host.alias];
3488
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.alias") }),
3489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.status") }),
3490
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.exit") }),
3491
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stdout") }),
3492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stderr") }),
3493
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.error") }),
3494
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.duration") })
3495
+ ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: results.map((result) => {
3496
+ const status = result.ok ? "ok" : result.timedOut === true ? "timeout" : "fail";
3497
+ const label = result.ok ? tt("cluster.ok") : result.timedOut === true ? tt("cluster.timeout") : tt("cluster.fail");
3392
3498
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3393
3499
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3394
3500
  className: panel_module_css_default.mono,
3395
- children: host.alias
3396
- }),
3397
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
3398
- className: panel_module_css_default.mono,
3399
- children: [
3400
- host.host,
3401
- ":",
3402
- host.port
3403
- ]
3501
+ children: result.alias
3404
3502
  }),
3405
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: host.user }),
3406
3503
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3407
3504
  className: panel_module_css_default.badge,
3408
- "data-kind": host.auth,
3409
- children: host.auth === "key" ? tt("form.auth.key") : tt("form.auth.password")
3505
+ "data-status": status,
3506
+ children: label
3410
3507
  }) }),
3411
3508
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3412
- className: panel_module_css_default.cellMuted,
3413
- children: host.environment ?? ""
3509
+ className: panel_module_css_default.mono,
3510
+ children: result.exitCode ?? "-"
3414
3511
  }),
3512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stdout !== void 0 && result.stdout !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3513
+ className: panel_module_css_default.cellDetails,
3514
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stdout") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3515
+ className: panel_module_css_default.cellPre,
3516
+ children: result.stdout
3517
+ })]
3518
+ }) }),
3519
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stderr !== void 0 && result.stderr !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3520
+ className: panel_module_css_default.cellDetails,
3521
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stderr") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3522
+ className: panel_module_css_default.cellPre,
3523
+ children: result.stderr
3524
+ })]
3525
+ }) }),
3415
3526
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3416
3527
  className: panel_module_css_default.cellMuted,
3417
- children: host.tags.join(", ")
3528
+ children: result.error ?? ""
3418
3529
  }),
3419
3530
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3420
- className: panel_module_css_default.cellMuted,
3421
- children: host.description ?? ""
3422
- }),
3423
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3424
- className: panel_module_css_default.actions,
3425
- children: [
3426
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3427
- type: "button",
3428
- className: panel_module_css_default.linkButton,
3429
- disabled: testingAlias === host.alias,
3430
- onClick: () => {
3431
- runTest(host.alias);
3432
- },
3433
- children: testingAlias === host.alias ? tt("hosts.testing") : tt("hosts.test")
3434
- }),
3435
- testingAlias === host.alias && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3436
- className: panel_module_css_default.spinner,
3437
- "aria-hidden": "true"
3438
- }),
3439
- test !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3440
- className: panel_module_css_default.inlineTest,
3441
- "data-status": test.ok ? "ok" : "fail",
3442
- children: test.ok ? tt("hosts.testOk", { latency: test.latencyMs ?? 0 }) : tt("hosts.testFail", { error: test.error ?? "" })
3443
- }),
3444
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3445
- type: "button",
3446
- className: panel_module_css_default.linkButton,
3447
- onClick: () => {
3448
- setDialog({
3449
- mode: "edit",
3450
- host
3451
- });
3452
- },
3453
- children: tt("hosts.edit")
3454
- }),
3455
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3456
- type: "button",
3457
- className: panel_module_css_default.linkButton,
3458
- "data-danger": true,
3459
- onClick: () => {
3460
- deleteHost(host.alias);
3461
- },
3462
- children: tt("hosts.delete")
3463
- }),
3464
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3465
- type: "button",
3466
- className: panel_module_css_default.ghostButton,
3467
- onClick: () => {
3468
- onConnect(host.alias);
3469
- },
3470
- children: tt("hosts.connected")
3471
- })
3472
- ]
3473
- }) })
3474
- ] }, host.alias);
3531
+ className: panel_module_css_default.mono,
3532
+ children: result.durationMs !== void 0 ? result.durationMs + " ms" : "-"
3533
+ })
3534
+ ] }, result.alias);
3475
3535
  }) })]
3476
3536
  })
3477
- }),
3478
- dialog !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFormDialog, {
3479
- api,
3480
- editing: dialog.mode === "edit" ? dialog.host : null,
3481
- onClose: () => {
3482
- setDialog(null);
3483
- },
3484
- onSaved: () => {
3485
- setDialog(null);
3486
- load();
3487
- }
3488
- }),
3489
- hostKeyConfirm !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFingerprintDialog, {
3490
- api,
3491
- alias: hostKeyConfirm.alias,
3492
- fingerprintSha256: hostKeyConfirm.fingerprintSha256,
3493
- mismatch: hostKeyConfirm.mismatch,
3494
- onClose: () => {
3495
- setHostKeyConfirm(null);
3496
- },
3497
- onTrusted: () => {
3498
- retryAfterFingerprint(hostKeyConfirm.alias);
3499
- }
3500
- }),
3501
- secretConfirm !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionSecretDialog, {
3502
- api,
3503
- alias: secretConfirm.alias,
3504
- user: secretConfirm.user,
3505
- secret: secretConfirm.secret,
3506
- onClose: () => {
3507
- setSecretConfirm(null);
3508
- },
3509
- onProvided: () => {
3510
- retryAfterSecret(secretConfirm.alias);
3511
- }
3512
3537
  })
3513
3538
  ]
3514
3539
  });
@@ -16557,8 +16582,9 @@ window.__ModuleLoader__.load({
16557
16582
  //#region src/client/ssh/panel/TerminalTab.tsx
16558
16583
  /**
16559
16584
  * Terminal tab: an xterm.js PTY view over the host's WebSocket terminal route.
16560
- * A host <select> plus connect/disconnect controls; the terminal container is
16561
- * sized by FitAddon (default 80x24 before first fit). On remote exit the last
16585
+ * The target alias is inherited from the selected Session; only connect /
16586
+ * disconnect controls remain. The terminal container is sized by FitAddon
16587
+ * (default 80x24 before first fit). On remote exit the last
16562
16588
  * output stays visible and input is disabled. xterm's stylesheet is injected
16563
16589
  * once per page load (module-level guard).
16564
16590
  */
@@ -16574,9 +16600,7 @@ window.__ModuleLoader__.load({
16574
16600
  document.head.appendChild(style);
16575
16601
  }
16576
16602
  /** The xterm terminal view. */
16577
- function TerminalTab({ api, presetAlias, requestId }) {
16578
- const [hosts, setHosts] = (0, react.useState)([]);
16579
- const [alias, setAlias] = (0, react.useState)(presetAlias ?? "");
16603
+ function TerminalTab({ api, alias }) {
16580
16604
  const [status, setStatus] = (0, react.useState)({ kind: "idle" });
16581
16605
  const containerRef = (0, react.useRef)(null);
16582
16606
  const termRef = (0, react.useRef)(null);
@@ -16585,26 +16609,6 @@ window.__ModuleLoader__.load({
16585
16609
  (0, react.useEffect)(() => {
16586
16610
  ensureXtermCss();
16587
16611
  }, []);
16588
- (0, react.useEffect)(() => {
16589
- let disposed = false;
16590
- (async () => {
16591
- try {
16592
- const list = await api.listHosts();
16593
- if (!disposed) setHosts(list);
16594
- } catch (cause) {
16595
- if (!disposed) setStatus({
16596
- kind: "error",
16597
- detail: errorMessage(cause)
16598
- });
16599
- }
16600
- })();
16601
- return () => {
16602
- disposed = true;
16603
- };
16604
- }, [api]);
16605
- (0, react.useEffect)(() => {
16606
- if (presetAlias !== void 0) setAlias(presetAlias);
16607
- }, [presetAlias, requestId]);
16608
16612
  const teardown = () => {
16609
16613
  const connection = connRef.current;
16610
16614
  connRef.current = null;
@@ -16656,6 +16660,7 @@ window.__ModuleLoader__.load({
16656
16660
  term.loadAddon(fit);
16657
16661
  term.open(container);
16658
16662
  fit.fit();
16663
+ term.options.disableStdin = true;
16659
16664
  const connection = api.openTerminal(target, term.cols, term.rows);
16660
16665
  termRef.current = term;
16661
16666
  fitRef.current = fit;
@@ -16665,6 +16670,7 @@ window.__ModuleLoader__.load({
16665
16670
  connection.send(data);
16666
16671
  });
16667
16672
  connection.onReady = () => {
16673
+ term.options.disableStdin = false;
16668
16674
  setStatus({
16669
16675
  kind: "connected",
16670
16676
  alias: target
@@ -16697,29 +16703,14 @@ window.__ModuleLoader__.load({
16697
16703
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16698
16704
  className: panel_module_css_default.controls,
16699
16705
  children: [
16700
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
16701
- className: panel_module_css_default.input,
16702
- value: alias,
16703
- onChange: (event) => {
16704
- setAlias(event.target.value);
16705
- },
16706
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
16707
- value: "",
16708
- children: tt("terminal.selectHost")
16709
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
16710
- value: host.alias,
16711
- children: [
16712
- host.alias,
16713
- " (",
16714
- host.host,
16715
- ")"
16716
- ]
16717
- }, host.alias))]
16706
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16707
+ className: panel_module_css_default.targetBadge,
16708
+ children: alias
16718
16709
  }),
16719
16710
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16720
16711
  type: "button",
16721
16712
  className: panel_module_css_default.primaryButton,
16722
- disabled: alias === "" || active,
16713
+ disabled: active,
16723
16714
  onClick: connect,
16724
16715
  children: tt("terminal.connect")
16725
16716
  }),
@@ -16759,7 +16750,7 @@ window.__ModuleLoader__.load({
16759
16750
  className: panel_module_css_default.termContainer
16760
16751
  }), status.kind === "idle" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16761
16752
  className: panel_module_css_default.termPlaceholder,
16762
- children: hosts.length === 0 ? tt("hosts.empty") : tt("terminal.placeholder")
16753
+ children: tt("terminal.placeholder")
16763
16754
  })]
16764
16755
  })
16765
16756
  ]
@@ -16791,33 +16782,22 @@ window.__ModuleLoader__.load({
16791
16782
  return tt("transfer.gib", { value: (bytes / 1073741824).toFixed(1) });
16792
16783
  }
16793
16784
  /** The upload/download tab. */
16794
- function TransferTab({ api }) {
16795
- const [hosts, setHosts] = (0, react.useState)([]);
16785
+ function TransferTab({ api, alias, remoteRoot }) {
16796
16786
  const [listError, setListError] = (0, react.useState)(null);
16797
- const [alias, setAlias] = (0, react.useState)("");
16798
- const [remotePath, setRemotePath] = (0, react.useState)("");
16787
+ const [remotePath, setRemotePath] = (0, react.useState)(remoteRoot);
16799
16788
  const [browseOpen, setBrowseOpen] = (0, react.useState)(false);
16800
- const [browseDir, setBrowseDir] = (0, react.useState)("/");
16789
+ const [browseDir, setBrowseDir] = (0, react.useState)(remoteRoot);
16801
16790
  const [entries, setEntries] = (0, react.useState)([]);
16802
16791
  const [browsing, setBrowsing] = (0, react.useState)(false);
16803
16792
  const [transfer, setTransfer] = (0, react.useState)(null);
16804
16793
  const [status, setStatus] = (0, react.useState)(null);
16805
16794
  const fileRef = (0, react.useRef)(null);
16806
16795
  const seqRef = (0, react.useRef)(0);
16807
- (0, react.useEffect)(() => {
16808
- let disposed = false;
16809
- (async () => {
16810
- try {
16811
- const list = await api.listHosts();
16812
- if (!disposed) setHosts(list);
16813
- } catch (cause) {
16814
- if (!disposed) setListError(errorMessage(cause));
16815
- }
16816
- })();
16817
- return () => {
16818
- disposed = true;
16819
- };
16820
- }, [api]);
16796
+ const transferAbortRef = (0, react.useRef)(null);
16797
+ (0, react.useEffect)(() => () => {
16798
+ transferAbortRef.current?.abort(Object.assign(/* @__PURE__ */ new Error("transfer tab unmounted"), { name: "AbortError" }));
16799
+ transferAbortRef.current = null;
16800
+ }, []);
16821
16801
  const loadDir = async (path) => {
16822
16802
  if (alias === "") return;
16823
16803
  const seq = ++seqRef.current;
@@ -16839,15 +16819,16 @@ window.__ModuleLoader__.load({
16839
16819
  }
16840
16820
  };
16841
16821
  const openBrowse = () => {
16842
- if (alias === "") return;
16843
16822
  setBrowseOpen(true);
16844
- const path = remotePath.trim() === "" ? "/" : remotePath.trim();
16823
+ const path = remotePath.trim() === "" ? remoteRoot : remotePath.trim();
16845
16824
  loadDir(path);
16846
16825
  };
16847
16826
  const handleFile = async (event) => {
16848
16827
  const file = event.target.files?.[0];
16849
16828
  event.target.value = "";
16850
16829
  if (file === void 0 || alias === "" || remotePath.trim() === "" || transfer !== null) return;
16830
+ const controller = new AbortController();
16831
+ transferAbortRef.current = controller;
16851
16832
  setStatus(null);
16852
16833
  setTransfer({
16853
16834
  kind: "upload",
@@ -16857,6 +16838,7 @@ window.__ModuleLoader__.load({
16857
16838
  });
16858
16839
  try {
16859
16840
  const outcome = await api.uploadFile(file, alias, remotePath.trim(), (progress) => {
16841
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16860
16842
  setTransfer((prev) => prev === null ? prev : {
16861
16843
  kind: "upload",
16862
16844
  phase: progress.phase,
@@ -16864,22 +16846,27 @@ window.__ModuleLoader__.load({
16864
16846
  speedBps: progress.speedBps,
16865
16847
  file: progress.file
16866
16848
  });
16867
- });
16868
- setStatus({
16849
+ }, controller.signal);
16850
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16869
16851
  kind: "ok",
16870
16852
  bytes: outcome.transferredBytes
16871
16853
  });
16872
16854
  } catch (cause) {
16873
- setStatus({
16855
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16874
16856
  kind: "error",
16875
16857
  error: errorMessage(cause)
16876
16858
  });
16877
16859
  } finally {
16878
- setTransfer(null);
16860
+ if (transferAbortRef.current === controller) {
16861
+ transferAbortRef.current = null;
16862
+ if (!controller.signal.aborted) setTransfer(null);
16863
+ }
16879
16864
  }
16880
16865
  };
16881
16866
  const handleDownload = async () => {
16882
16867
  if (alias === "" || remotePath.trim() === "" || transfer !== null) return;
16868
+ const controller = new AbortController();
16869
+ transferAbortRef.current = controller;
16883
16870
  setStatus(null);
16884
16871
  setTransfer({
16885
16872
  kind: "download",
@@ -16889,6 +16876,7 @@ window.__ModuleLoader__.load({
16889
16876
  });
16890
16877
  try {
16891
16878
  const result = await api.downloadFile(alias, remotePath.trim(), (progress) => {
16879
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16892
16880
  setTransfer((prev) => prev === null ? prev : {
16893
16881
  kind: "download",
16894
16882
  phase: progress.phase,
@@ -16896,7 +16884,8 @@ window.__ModuleLoader__.load({
16896
16884
  speedBps: progress.speedBps,
16897
16885
  file: progress.file
16898
16886
  });
16899
- });
16887
+ }, controller.signal);
16888
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16900
16889
  if (!result.streamed && result.blob !== void 0) {
16901
16890
  const url = URL.createObjectURL(result.blob);
16902
16891
  const anchor = document.createElement("a");
@@ -16914,39 +16903,27 @@ window.__ModuleLoader__.load({
16914
16903
  bytes: result.bytes
16915
16904
  });
16916
16905
  } catch (cause) {
16917
- setStatus({
16906
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16918
16907
  kind: "error",
16919
16908
  error: errorMessage(cause)
16920
16909
  });
16921
16910
  } finally {
16922
- setTransfer(null);
16911
+ if (transferAbortRef.current === controller) {
16912
+ transferAbortRef.current = null;
16913
+ if (!controller.signal.aborted) setTransfer(null);
16914
+ }
16923
16915
  }
16924
16916
  };
16925
- const ready = alias !== "" && remotePath.trim() !== "" && transfer === null;
16917
+ const ready = remotePath.trim() !== "" && transfer === null;
16926
16918
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16927
16919
  className: panel_module_css_default.tabBody,
16928
16920
  children: [
16929
16921
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16930
16922
  className: panel_module_css_default.controls,
16931
16923
  children: [
16932
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
16933
- className: panel_module_css_default.input,
16934
- value: alias,
16935
- onChange: (event) => {
16936
- setAlias(event.target.value);
16937
- },
16938
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
16939
- value: "",
16940
- children: tt("transfer.selectHost")
16941
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
16942
- value: host.alias,
16943
- children: [
16944
- host.alias,
16945
- " (",
16946
- host.host,
16947
- ")"
16948
- ]
16949
- }, host.alias))]
16924
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16925
+ className: panel_module_css_default.targetBadge,
16926
+ children: alias
16950
16927
  }),
16951
16928
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
16952
16929
  className: panel_module_css_default.input,
@@ -16959,7 +16936,6 @@ window.__ModuleLoader__.load({
16959
16936
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16960
16937
  type: "button",
16961
16938
  className: panel_module_css_default.ghostButton,
16962
- disabled: alias === "",
16963
16939
  onClick: openBrowse,
16964
16940
  children: tt("transfer.browseRemote")
16965
16941
  }),
@@ -17088,28 +17064,14 @@ window.__ModuleLoader__.load({
17088
17064
  * and a new-tunnel form.
17089
17065
  */
17090
17066
  /** The tunnels tab. */
17091
- function TunnelsTab({ api }) {
17092
- const [hosts, setHosts] = (0, react.useState)([]);
17067
+ function TunnelsTab({ api, alias }) {
17093
17068
  const [tunnels, setTunnels] = (0, react.useState)(null);
17094
17069
  const [error, setError] = (0, react.useState)(null);
17095
17070
  const [notice, setNotice] = (0, react.useState)(null);
17096
- const [alias, setAlias] = (0, react.useState)("");
17097
17071
  const [remotePort, setRemotePort] = (0, react.useState)("");
17098
17072
  const [remoteHost, setRemoteHost] = (0, react.useState)("");
17099
17073
  const [localPort, setLocalPort] = (0, react.useState)("");
17100
17074
  const [busy, setBusy] = (0, react.useState)(false);
17101
- (0, react.useEffect)(() => {
17102
- let disposed = false;
17103
- (async () => {
17104
- try {
17105
- const list = await api.listHosts();
17106
- if (!disposed) setHosts(list);
17107
- } catch {}
17108
- })();
17109
- return () => {
17110
- disposed = true;
17111
- };
17112
- }, [api]);
17113
17075
  const seqRef = (0, react.useRef)(0);
17114
17076
  (0, react.useEffect)(() => {
17115
17077
  const load = async () => {
@@ -17117,7 +17079,7 @@ window.__ModuleLoader__.load({
17117
17079
  try {
17118
17080
  const list = await api.listTunnels();
17119
17081
  if (seq !== seqRef.current) return;
17120
- setTunnels(list);
17082
+ setTunnels(list.filter((tunnel) => tunnel.alias === alias));
17121
17083
  setError(null);
17122
17084
  } catch (cause) {
17123
17085
  if (seq !== seqRef.current) return;
@@ -17131,7 +17093,7 @@ window.__ModuleLoader__.load({
17131
17093
  return () => {
17132
17094
  clearInterval(timer);
17133
17095
  };
17134
- }, [api]);
17096
+ }, [api, alias]);
17135
17097
  const refresh = async () => {
17136
17098
  const seq = ++seqRef.current;
17137
17099
  try {
@@ -17156,7 +17118,7 @@ window.__ModuleLoader__.load({
17156
17118
  if (!window.confirm(tt("tunnel.stopAllConfirm"))) return;
17157
17119
  setBusy(true);
17158
17120
  try {
17159
- await api.stopAllTunnels(alias === "" ? void 0 : alias);
17121
+ await api.stopAllTunnels(alias);
17160
17122
  await refresh();
17161
17123
  } catch (cause) {
17162
17124
  setError(errorMessage(cause));
@@ -17265,19 +17227,9 @@ window.__ModuleLoader__.load({
17265
17227
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17266
17228
  className: panel_module_css_default.fieldLabel,
17267
17229
  children: tt("tunnel.alias")
17268
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
17269
- className: panel_module_css_default.input,
17270
- value: alias,
17271
- onChange: (event) => {
17272
- setAlias(event.target.value);
17273
- },
17274
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17275
- value: "",
17276
- children: tt("terminal.selectHost")
17277
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17278
- value: host.alias,
17279
- children: host.alias
17280
- }, host.alias))]
17230
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17231
+ className: panel_module_css_default.targetBadge,
17232
+ children: alias
17281
17233
  })]
17282
17234
  }),
17283
17235
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -17331,7 +17283,7 @@ window.__ModuleLoader__.load({
17331
17283
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17332
17284
  type: "button",
17333
17285
  className: panel_module_css_default.primaryButton,
17334
- disabled: busy || alias === "" || remotePort.trim() === "",
17286
+ disabled: busy || remotePort.trim() === "",
17335
17287
  onClick: () => {
17336
17288
  start();
17337
17289
  },
@@ -17344,18 +17296,13 @@ window.__ModuleLoader__.load({
17344
17296
  //#endregion
17345
17297
  //#region src/client/ssh/panel/SshPanel.tsx
17346
17298
  /**
17347
- * The SSH operations panel shell: a header with a close control, a five-tab
17348
- * bar, and the active tab's content. Tab state lives here (browser session
17349
- * state); inactive tabs unmount, so each tab fetches its own data on
17350
- * activation. The hosts tab's connect action switches here to the terminal
17351
- * tab with the chosen alias preselected.
17299
+ * Session-scoped SSH operations console. The right sidebar follows the selected
17300
+ * Session, so every operation inherits that Session's SSH workspace target.
17301
+ * Individual tabs never offer a host picker. A local Session mounts no operation
17302
+ * body and shows a blurred unavailable mask instead.
17352
17303
  */
17353
17304
  /** The tab bar definition (labels resolved at render time). */
17354
17305
  const TABS = [
17355
- {
17356
- id: "hosts",
17357
- label: () => tt("tab.hosts")
17358
- },
17359
17306
  {
17360
17307
  id: "terminal",
17361
17308
  label: () => tt("tab.terminal")
@@ -17373,36 +17320,39 @@ window.__ModuleLoader__.load({
17373
17320
  label: () => tt("tab.cluster")
17374
17321
  }
17375
17322
  ];
17376
- /** The tabbed SSH panel. */
17377
- function SshPanel({ controller, api }) {
17378
- const [activeTab, setActiveTab] = (0, react.useState)("hosts");
17379
- const [connectRequest, setConnectRequest] = (0, react.useState)(null);
17380
- const handleConnect = (alias) => {
17381
- setActiveTab("terminal");
17382
- setConnectRequest((prev) => ({
17383
- alias,
17384
- nonce: (prev?.nonce ?? 0) + 1
17385
- }));
17386
- };
17323
+ /** The tabbed SSH operations console. */
17324
+ function SshPanel({ api, target }) {
17325
+ const [activeTab, setActiveTab] = (0, react.useState)("terminal");
17326
+ const sessionTarget = (0, react.useSyncExternalStore)(target.subscribe, target.getSnapshot, target.getSnapshot);
17327
+ if (sessionTarget === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17328
+ className: panel_module_css_default.panel,
17329
+ "data-session-mode": "local",
17330
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17331
+ className: panel_module_css_default.disabledSurface,
17332
+ "aria-hidden": "true",
17333
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17334
+ className: panel_module_css_default.tabBar,
17335
+ role: "presentation",
17336
+ children: TABS.map((tab) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17337
+ className: panel_module_css_default.tab,
17338
+ children: tab.label()
17339
+ }, tab.id))
17340
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17341
+ className: panel_module_css_default.panelContent,
17342
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: panel_module_css_default.disabledPlaceholder })
17343
+ })]
17344
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17345
+ className: panel_module_css_default.localSessionMask,
17346
+ role: "status",
17347
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: tt("session.localTitle") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tt("session.localUnavailable") })]
17348
+ })]
17349
+ });
17350
+ const targetKey = `${sessionTarget.sessionId}:${sessionTarget.workspaceId}:${sessionTarget.alias}`;
17387
17351
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17388
17352
  className: panel_module_css_default.panel,
17353
+ "data-session-mode": "ssh",
17354
+ "data-session-alias": sessionTarget.alias,
17389
17355
  children: [
17390
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17391
- className: panel_module_css_default.panelHeader,
17392
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
17393
- className: panel_module_css_default.panelTitle,
17394
- children: tt("panel.title")
17395
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17396
- type: "button",
17397
- className: panel_module_css_default.iconButton,
17398
- title: tt("common.close"),
17399
- "aria-label": tt("common.close"),
17400
- onClick: () => {
17401
- controller.close();
17402
- },
17403
- children: "x"
17404
- })]
17405
- }),
17406
17356
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17407
17357
  className: panel_module_css_default.tabBar,
17408
17358
  role: "tablist",
@@ -17418,271 +17368,172 @@ window.__ModuleLoader__.load({
17418
17368
  children: tab.label()
17419
17369
  }, tab.id))
17420
17370
  }),
17371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17372
+ className: panel_module_css_default.sessionTarget,
17373
+ title: sessionTarget.remoteRoot,
17374
+ children: tt("session.target", {
17375
+ alias: sessionTarget.alias,
17376
+ remoteRoot: sessionTarget.remoteRoot
17377
+ })
17378
+ }),
17421
17379
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17422
17380
  className: panel_module_css_default.panelContent,
17423
17381
  children: [
17424
- activeTab === "hosts" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostsTab, {
17382
+ activeTab === "terminal" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalTab, {
17425
17383
  api,
17426
- onConnect: handleConnect
17384
+ alias: sessionTarget.alias
17427
17385
  }),
17428
- activeTab === "terminal" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalTab, {
17386
+ activeTab === "transfer" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TransferTab, {
17387
+ api,
17388
+ alias: sessionTarget.alias,
17389
+ remoteRoot: sessionTarget.remoteRoot
17390
+ }),
17391
+ activeTab === "tunnels" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TunnelsTab, {
17429
17392
  api,
17430
- presetAlias: connectRequest?.alias,
17431
- requestId: connectRequest?.nonce
17393
+ alias: sessionTarget.alias
17432
17394
  }),
17433
- activeTab === "transfer" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TransferTab, { api }),
17434
- activeTab === "tunnels" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TunnelsTab, { api }),
17435
- activeTab === "cluster" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClusterTab, { api })
17395
+ activeTab === "cluster" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClusterTab, {
17396
+ api,
17397
+ alias: sessionTarget.alias
17398
+ })
17436
17399
  ]
17437
- })
17400
+ }, targetKey)
17438
17401
  ]
17439
17402
  });
17440
17403
  }
17441
17404
  //#endregion
17442
- //#region src/client/ssh/mount.tsx
17443
- /**
17444
- * Panel view mounting.
17445
- *
17446
- * The `conversation` slot is single-occupant (ui-conversation) and external
17447
- * plugins cannot declare slots, so the panel takes over the center column at
17448
- * the DOM level: a container is appended inside the `[data-pane="conversation"]`
17449
- * grid item (an extra trailing child React never manages), and a stylesheet
17450
- * rule hides the conversation content while the panel is active. Toggling is
17451
- * a data attribute on <html> — no React involvement, so the conversation
17452
- * subtree underneath stays mounted and stateful.
17453
- */
17454
- const CONVERSATION_COLUMN_SELECTOR = "[data-pane=\"conversation\"]";
17455
- const ACTIVE_ATTR = "data-dsh-ssh-active";
17456
- /** Find the center column, or undefined while the frame is not mounted. */
17457
- function conversationColumn() {
17458
- return document.querySelector(CONVERSATION_COLUMN_SELECTOR) ?? void 0;
17459
- }
17405
+ //#region src/client/ssh/ops-tab.tsx
17406
+ /** This implementation's identity in the tab system; also the body/title key. */
17407
+ const OPS_TAB_ID = "@tiphareth/dsh-hardssh/operations";
17408
+ /** The page kind `ctx.sidebarRight.openTab(kind)` names. */
17409
+ const OPS_TAB_KIND = "hardssh-operations";
17460
17410
  /**
17461
- * Mount the panel React tree into the center column and bind its visibility
17462
- * to the controller's panelOpen state.
17463
- * @param controller - the panel controller driving the view.
17464
- * @param api - the SSH API client the tabs operate through.
17465
- * @returns disposer unmounting the tree and restoring the column.
17411
+ * Register the regression type, its body, and its chip title.
17412
+ * @param ctx - client root context carrying the tab registry and slot registry.
17413
+ * @param api - the SSH API client the panel's tabs operate through.
17414
+ * @param target - live target inherited from the selected Session.
17466
17415
  */
17467
- function mountPanel(controller, api) {
17468
- let root;
17469
- let container;
17470
- const ensure = () => {
17471
- if (container !== void 0) {
17472
- if (container.isConnected) return;
17473
- root?.unmount();
17474
- root = void 0;
17475
- container.remove();
17476
- container = void 0;
17477
- }
17478
- const column = conversationColumn();
17479
- if (column === void 0) return;
17480
- container = document.createElement("div");
17481
- container.dataset.dshSshView = "";
17482
- container.className = panel_module_css_default.view;
17483
- column.appendChild(container);
17484
- root = (0, react_dom_client.createRoot)(container);
17485
- root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SshPanel, {
17486
- controller,
17487
- api
17488
- }));
17489
- };
17490
- const waitObserver = new MutationObserver(() => {
17491
- ensure();
17492
- });
17493
- waitObserver.observe(document.body, {
17494
- childList: true,
17495
- subtree: true
17496
- });
17497
- const applyActive = () => {
17498
- if (controller.getSnapshot().panelOpen) document.documentElement.setAttribute(ACTIVE_ATTR, "");
17499
- else document.documentElement.removeAttribute(ACTIVE_ATTR);
17500
- };
17501
- const unsubscribe = controller.subscribe(applyActive);
17502
- applyActive();
17503
- ensure();
17504
- return () => {
17505
- waitObserver.disconnect();
17506
- unsubscribe();
17507
- document.documentElement.removeAttribute(ACTIVE_ATTR);
17508
- root?.unmount();
17509
- root = void 0;
17510
- container?.remove();
17511
- container = void 0;
17512
- };
17513
- }
17514
- //#endregion
17515
- //#region src/client/ssh/panel/controller.ts
17516
- /** The panel state owner the sidebar entry toggles and the view renders from. */
17517
- var PanelController = class {
17518
- panelOpen = false;
17519
- listeners = /* @__PURE__ */ new Set();
17520
- getSnapshot() {
17521
- return { panelOpen: this.panelOpen };
17522
- }
17523
- subscribe(fn) {
17524
- this.listeners.add(fn);
17525
- return () => {
17526
- this.listeners.delete(fn);
17527
- };
17528
- }
17529
- open() {
17530
- if (this.panelOpen) return;
17531
- this.panelOpen = true;
17532
- this.notify();
17533
- }
17534
- close() {
17535
- if (!this.panelOpen) return;
17536
- this.panelOpen = false;
17537
- this.notify();
17538
- }
17539
- toggle() {
17540
- if (this.panelOpen) this.close();
17541
- else this.open();
17542
- }
17543
- notify() {
17544
- for (const fn of [...this.listeners]) fn();
17545
- }
17546
- };
17547
- //#endregion
17548
- //#region src/client/ssh/sidebar-entry.ts
17549
- /** The sidebar column is the grid item AppFrame renders with this pane marker. */
17550
- const SIDEBAR_COLUMN_SELECTOR = "[data-pane=\"sidebar\"]";
17551
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
17552
- function sidebarRoot() {
17553
- return document.querySelector(SIDEBAR_COLUMN_SELECTOR)?.firstElementChild ?? void 0;
17554
- }
17555
- /** The New Session button: the shell's only direct-child button of the root. */
17556
- function newSessionButton(root) {
17557
- for (const child of root.children) if (child.tagName === "BUTTON") return child;
17558
- }
17559
- /** Build the entry row (a detached button; insert once the shell is up). */
17560
- function createEntry(controller) {
17561
- const entry = document.createElement("button");
17562
- entry.type = "button";
17563
- entry.dataset.dshSshEntry = "";
17564
- entry.className = panel_module_css_default.entry;
17565
- entry.setAttribute("aria-label", tt("entry.label"));
17566
- entry.setAttribute("title", tt("entry.tooltip"));
17567
- entry.innerHTML = "<span class=\"" + panel_module_css_default.entryIcon + "\"><svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"2.5\" width=\"12\" height=\"11\" rx=\"1.5\"/><path d=\"M4.5 5.5l2.5 2.5-2.5 2.5\"/><path d=\"M8.5 10.5h3\"/></svg></span><span class=\"" + panel_module_css_default.entryLabel + "\">" + tt("entry.label") + "</span>";
17568
- entry.addEventListener("click", () => {
17569
- controller.toggle();
17570
- });
17571
- return entry;
17572
- }
17573
- /** Re-insert the entry after the New Session button (before the browser region). */
17574
- function placeEntry(root, entry) {
17575
- const button = newSessionButton(root);
17576
- if (button === void 0) return false;
17577
- if (entry.parentElement !== root) root.insertBefore(entry, button.nextElementSibling);
17578
- return true;
17416
+ function registerOperationsTab(ctx, api, target) {
17417
+ ctx.effect(() => ctx.sidebarRightTabs.register({
17418
+ id: OPS_TAB_ID,
17419
+ kind: OPS_TAB_KIND,
17420
+ title: () => tt$1("ops.tab"),
17421
+ guide: [{
17422
+ order: 100,
17423
+ title: () => tt$1("ops.tab"),
17424
+ description: () => tt$1("ops.guide")
17425
+ }]
17426
+ }), "dsh-hardssh: operations tab type");
17427
+ ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
17428
+ name: "sidebar.right.pane.tab",
17429
+ key: OPS_TAB_ID,
17430
+ inject: () => ({
17431
+ api,
17432
+ target
17433
+ })
17434
+ }, SshPanel));
17435
+ ctx.slots.inject("sidebar.right.pane.tab.title", () => ctx.slots.register({
17436
+ name: "sidebar.right.pane.tab.title",
17437
+ key: OPS_TAB_ID
17438
+ }, OpsTabTitle));
17579
17439
  }
17580
- /**
17581
- * Mount the sidebar entry, waiting for the shell to render and self-healing
17582
- * on later React re-renders.
17583
- * @param controller - the panel controller the entry toggles.
17584
- * @returns disposer removing the entry and its observers.
17585
- */
17586
- function mountSidebarEntry(controller) {
17587
- const entry = createEntry(controller);
17588
- let root;
17589
- let placed = false;
17590
- const tryPlace = () => {
17591
- if (placed) return;
17592
- if (root !== void 0 && !root.isConnected) {
17593
- rootObserver.disconnect();
17594
- root = void 0;
17595
- }
17596
- root ??= sidebarRoot();
17597
- if (root === void 0) return;
17598
- placed = placeEntry(root, entry);
17599
- if (placed) rootObserver.observe(root, {
17600
- childList: true,
17601
- subtree: true
17602
- });
17603
- };
17604
- const waitObserver = new MutationObserver(() => {
17605
- tryPlace();
17606
- });
17607
- waitObserver.observe(document.body, {
17608
- childList: true,
17609
- subtree: true
17610
- });
17611
- const rootObserver = new MutationObserver(() => {
17612
- if (root === void 0 || !root.isConnected) {
17613
- placed = false;
17614
- tryPlace();
17615
- return;
17616
- }
17617
- if (!root.contains(entry)) placed = placeEntry(root, entry);
17618
- });
17619
- const unsubscribe = controller.subscribe(() => {
17620
- entry.dataset.active = controller.getSnapshot().panelOpen ? "true" : void 0;
17621
- });
17622
- entry.dataset.active = controller.getSnapshot().panelOpen ? "true" : void 0;
17623
- tryPlace();
17624
- return () => {
17625
- waitObserver.disconnect();
17626
- rootObserver.disconnect();
17627
- unsubscribe();
17628
- entry.remove();
17629
- };
17440
+ /** The tab chip and floating-panel header text. */
17441
+ function OpsTabTitle() {
17442
+ return tt$1("ops.tab");
17630
17443
  }
17631
17444
  //#endregion
17632
17445
  //#region src/client/ssh/apply.ts
17633
17446
  /** Locale namespace this capability owns (kept as 'dsh-ssh'). */
17634
17447
  const NS = "dsh-ssh";
17635
17448
  /**
17636
- * Mount the SSH operations surfaces (sidebar entry + operations panel).
17637
- * @param ctx - client root context (locale service).
17449
+ * Mount the SSH operations surfaces (locale dictionaries + the right-Sidebar tab).
17450
+ * @param ctx - client root context (locale service, slot registry, tab registry).
17451
+ * @param api - shared SSH API client.
17452
+ * @param target - selected Session's fixed SSH target, or null for local Sessions.
17638
17453
  */
17639
- function mountSshOperations(ctx) {
17454
+ function mountSshOperations(ctx, api, target) {
17455
+ try {
17456
+ registerOperationsTab(ctx, api, target);
17457
+ } catch (error) {
17458
+ console.warn("[dsh-ssh] operations tab registration failed:", error);
17459
+ }
17640
17460
  ctx.effect(() => ctx.locale.register(NS, {
17641
17461
  zh,
17642
17462
  en
17643
17463
  }), "dsh-ssh: dictionaries");
17644
- const controller = new PanelController();
17645
- const api = new SshApi();
17646
- const disposers = [];
17647
- try {
17648
- disposers.push(mountSidebarEntry(controller));
17649
- disposers.push(mountPanel(controller, api));
17650
- } catch (error) {
17651
- console.warn("[dsh-ssh] mount failed:", error);
17652
- }
17653
- ctx.effect(() => () => {
17654
- for (const dispose of disposers.splice(0)) dispose();
17655
- }, "dsh-ssh: ui mounts");
17464
+ }
17465
+ //#endregion
17466
+ //#region src/client/ssh/session-target.ts
17467
+ /** Build one stable Session → SSH target source over the public session list and workspace manager. */
17468
+ function createSessionSshTargetSource(sessions, manager) {
17469
+ const workspaceForCwd = makeAnchorWorkspaceResolver(() => manager.getSnapshot().workspaces);
17470
+ let cachedKey;
17471
+ let cachedValue = null;
17472
+ const getSnapshot = () => {
17473
+ const sessionId = sessions.currentSessionId();
17474
+ const cwd = sessionId === void 0 ? void 0 : sessions.sessionCwd(sessionId);
17475
+ const workspace = workspaceForCwd(cwd);
17476
+ const key = workspace === void 0 || sessionId === void 0 ? `local\u0000${sessionId ?? ""}\u0000${cwd ?? ""}` : `ssh\u0000${sessionId}\u0000${workspace.id}\u0000${workspace.alias}\u0000${workspace.remoteRoot}`;
17477
+ if (key === cachedKey) return cachedValue;
17478
+ cachedKey = key;
17479
+ cachedValue = workspace === void 0 || sessionId === void 0 ? null : {
17480
+ sessionId,
17481
+ workspaceId: workspace.id,
17482
+ alias: workspace.alias,
17483
+ remoteRoot: workspace.remoteRoot
17484
+ };
17485
+ return cachedValue;
17486
+ };
17487
+ return {
17488
+ getSnapshot,
17489
+ subscribe: (listener) => {
17490
+ const disposeSessions = sessions.subscribe(listener);
17491
+ const disposeWorkspaces = manager.subscribe(listener);
17492
+ return () => {
17493
+ disposeSessions();
17494
+ disposeWorkspaces();
17495
+ };
17496
+ }
17497
+ };
17656
17498
  }
17657
17499
  //#endregion
17658
17500
  //#region src/client/index.ts
17659
- /** Required services: slots for the header buttons + directory-flow holes,
17660
- * locale for the copy, workspaces for the native local-directory chooser. */
17501
+ /** Required services: slots for the extension seats, locale for copy,
17502
+ * workspaces for the native local-directory chooser, sessions for the
17503
+ * open/new-session connection gate, and sidebarRightTabs for the SSH
17504
+ * operations console type. Keep the registry explicit: without it Cordis may
17505
+ * activate this client before ui-sidebar-right provides the service; the
17506
+ * guarded registration then degrades silently and no HardSSH tab exists. */
17661
17507
  const inject = [
17662
17508
  "slots",
17663
17509
  "locale",
17664
- "workspaces"
17510
+ "workspaces",
17511
+ "sessions",
17512
+ "sidebarRightTabs"
17665
17513
  ];
17666
17514
  /** Apply the browser half. */
17667
17515
  function apply(ctx) {
17668
- mountSshOperations(ctx);
17669
- ctx.effect(() => ctx.locale.register(NS$1, dictionaries), "dsh-hardssh: dictionaries");
17670
17516
  const api = new WorkspaceApi();
17671
17517
  const hostsApi = new SshHostsApi();
17672
17518
  const sshApi = new SshApi();
17673
17519
  const manager = new WorkspaceManager(api);
17520
+ const sessionList = ctx.sessions.list;
17521
+ const sessions = {
17522
+ subscribe: (listener) => sessionList.subscribe(listener),
17523
+ phase: () => sessionList.getSnapshot().phase,
17524
+ currentSessionId: () => sessionList.getSnapshot().current,
17525
+ sessionIds: () => sessionList.getSnapshot().ids,
17526
+ sessionCwd: (id) => {
17527
+ return sessionList.getSnapshot().byId[id]?.cwd;
17528
+ }
17529
+ };
17530
+ mountSshOperations(ctx, sshApi, createSessionSshTargetSource(sessions, manager));
17531
+ ctx.effect(() => ctx.locale.register(NS$1, dictionaries), "dsh-hardssh: dictionaries");
17674
17532
  const disposers = [];
17675
17533
  try {
17676
- ctx.slots.inject("conversation.session.header.utilities", () => {
17677
- return ctx.slots.register({
17678
- name: "conversation.session.header.utilities",
17679
- id: "ssh-workspace-manager",
17680
- order: -10,
17681
- inject: () => ({
17682
- manager,
17683
- sshApi
17684
- })
17685
- }, WorkspaceManagerButton);
17534
+ registerWorkspacePanel(ctx, {
17535
+ manager,
17536
+ sshApi
17686
17537
  });
17687
17538
  const flowInject = () => ({
17688
17539
  pickDirectory: () => ctx.workspaces.pickDirectory(),
@@ -17743,25 +17594,10 @@ window.__ModuleLoader__.load({
17743
17594
  window.clearInterval(connTimer);
17744
17595
  });
17745
17596
  refreshConnections();
17746
- let gateDispose;
17747
- const refreshGates = () => {
17748
- try {
17749
- const workspaces = manager.getSnapshot().workspaces;
17750
- gateDispose?.();
17751
- gateDispose = mountWorkspaceGates(workspaces.map((workspace) => ({
17752
- id: workspace.id,
17753
- title: workspace.title,
17754
- alias: workspace.alias
17755
- })), sshApi);
17756
- } catch (error) {
17757
- console.warn("[dsh-hardssh] gate refresh failed:", error);
17758
- }
17759
- };
17760
- disposers.push(() => {
17761
- gateDispose?.();
17762
- });
17763
- disposers.push(manager.subscribe(() => {
17764
- refreshGates();
17597
+ disposers.push(mountSessionConnectGate({
17598
+ sessions,
17599
+ aliasForCwd: makeAnchorAliasResolver(() => manager.getSnapshot().workspaces),
17600
+ ensureConnected: (alias) => connectHost(sshApi, alias)
17765
17601
  }));
17766
17602
  } catch (error) {
17767
17603
  console.warn("[dsh-hardssh] mount failed:", error);