@tiphareth/dsh-hardssh 0.1.2 → 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 +1171 -1321
  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 +58 -25
  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 -96
  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 -225
  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,92 +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 (idempotent: skip rows already tagged). */
2586
- const decorate = (row) => {
2587
- if (row.querySelector("[data-ssh-badge]") !== null) return;
2588
- const titleEl = row.querySelector(TITLE_SELECTOR$1);
2589
- if (titleEl === null || titleEl === void 0) return;
2590
- const title = titleEl.textContent?.trim() ?? "";
2591
- const target = byTitle.get(title);
2592
- if (target === void 0) return;
2593
- const isConnected = connected?.has(target.alias) ?? false;
2594
- row.title = `${target.remoteRoot}(${target.alias})`;
2595
- titleEl.appendChild(makeBadge(target.alias, target.remoteRoot, isConnected));
2596
- };
2597
- /** Scan the sidebar column for project rows and decorate each. */
2598
- const scan = () => {
2599
- for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR$1)) decorate(row);
2600
- };
2601
- const observer = new MutationObserver(() => {
2602
- scan();
2603
- });
2604
- observer.observe(document.body, {
2605
- childList: true,
2606
- subtree: true
2607
- });
2608
- scan();
2609
- return () => {
2610
- observer.disconnect();
2611
- for (const badge of document.querySelectorAll("[data-ssh-badge]")) badge.remove();
2612
- };
2613
- }
2614
- //#endregion
2615
- //#region src/client/ssh/panel/HostFingerprintDialog.tsx
2616
- /**
2617
- * Host fingerprint confirm dialog: shown when a connection is refused because
2618
- * the server's host key is not trusted yet (TOFU first encounter) or changed
2619
- * (possible MITM / key rotation). Displays the canonical SHA256 fingerprint
2620
- * 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.
2621
2829
  */
2622
2830
  /** Confirm (or reset) one host key. */
2623
2831
  function HostFingerprintDialog({ api, alias, fingerprintSha256, mismatch, onClose, onTrusted }) {
@@ -2729,7 +2937,7 @@ window.__ModuleLoader__.load({
2729
2937
  * process exit. Nothing is written to disk.
2730
2938
  */
2731
2939
  /** Prompt for a session-only credential. */
2732
- function SessionSecretDialog({ api, alias, user, secret, onClose, onProvided }) {
2940
+ function SessionSecretDialog({ api, alias, user, secret, reason, onClose, onProvided }) {
2733
2941
  const [value, setValue] = (0, react.useState)("");
2734
2942
  const [busy, setBusy] = (0, react.useState)(false);
2735
2943
  const [error, setError] = (0, react.useState)(null);
@@ -2770,22 +2978,29 @@ window.__ModuleLoader__.load({
2770
2978
  }),
2771
2979
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2772
2980
  className: panel_module_css_default.modalBody,
2773
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2774
- className: panel_module_css_default.input,
2775
- type: "password",
2776
- value,
2777
- autoFocus: true,
2778
- placeholder: secret === "password" ? tt("sessionSecret.placeholder") : tt("sessionSecret.placeholderPassphrase"),
2779
- onChange: (event) => {
2780
- setValue(event.target.value);
2781
- },
2782
- onKeyDown: (event) => {
2783
- if (event.key === "Enter") submit();
2784
- }
2785
- }), error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2786
- className: panel_module_css_default.formError,
2787
- children: error
2788
- })]
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
+ ]
2789
3004
  }),
2790
3005
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2791
3006
  className: panel_module_css_default.modalFooter,
@@ -2810,58 +3025,58 @@ window.__ModuleLoader__.load({
2810
3025
  });
2811
3026
  }
2812
3027
  //#endregion
2813
- //#region src/client/workspace-gate.ts
3028
+ //#region src/client/connect-host.ts
2814
3029
  /**
2815
- * Sidebar workspace-row click gate: when the operator clicks an SSH-bound
2816
- * workspace row in the session sidebar, ensure the server connection can
2817
- * actually be established BEFORE the shell proceeds with the native open —
2818
- * otherwise the shell surfaces raw SSH errors (host key untrusted, session
2819
- * password missing) and the workspace appears "dead" with no prompt.
3030
+ * Probe-and-prompt for an SSH alias, with a browser-session pass cache.
2820
3031
  *
2821
- * The gate runs the engine's /test probe (a real, lightweight connect):
2822
- * - HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH the host fingerprint confirm
2823
- * dialog (HostFingerprintDialog; TOFU first encounter / key rotation);
2824
- * - NEEDS_PASSWORD / NEEDS_PASSPHRASE → the VSCode-style session password
2825
- * dialog (SessionSecretDialog; never persisted, session-only);
2826
- * - ok → mark the alias as gated (5-minute TTL — trust is durable and the
2827
- * 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.
2828
3035
  *
2829
- * Pure DOM-level extension following the workspace-badges precedent: the
2830
- * sidebar shell exposes no row slot, so a MutationObserver self-heals when
2831
- * React re-renders displace the injected listener. A capture-phase listener
2832
- * on the row blocks the shell's click while a dialog is needed, then lets
2833
- * 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).
2834
3043
  *
2835
- * @module dsh-hardssh/client/workspace-gate
3044
+ * @module dsh-hardssh/client/connect-host
2836
3045
  */
2837
- const PROJECT_ROW_SELECTOR = "[class*=\"projectRow\"]";
2838
- const TITLE_SELECTOR = "[class*=\"title\"]";
2839
- /** A passed gate stays valid this long (trust is durable; the session
2840
- * 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). */
2841
3048
  const GATE_TTL_MS = 3e5;
2842
3049
  /** Cap on interactive retry rounds (wrong password loops etc.). */
2843
3050
  const MAX_ATTEMPTS = 3;
2844
- /** alias → last successful gate timestamp (browser-session scope). */
2845
- const gatePassedAt = /* @__PURE__ */ new Map();
2846
- /** 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). */
2847
3054
  const pending = /* @__PURE__ */ new Map();
2848
3055
  /** alias → SSH login name (fetched once for the user@alias prompt). */
2849
3056
  let hostsCache = null;
3057
+ /** At most one visible non-interactive failure dialog per alias. */
3058
+ const failureDialogs = /* @__PURE__ */ new Map();
2850
3059
  async function hostsUser(api, alias) {
2851
3060
  if (hostsCache === null) try {
2852
3061
  const list = await api.listHosts();
2853
3062
  hostsCache = new Map(list.map((host) => [host.alias, host.user]));
2854
3063
  } catch {
2855
- hostsCache = /* @__PURE__ */ new Map();
3064
+ return;
2856
3065
  }
2857
3066
  return hostsCache.get(alias);
2858
3067
  }
3068
+ /** Whether the alias passed the gate within its TTL. */
2859
3069
  function gateActive(alias, now) {
2860
- const at = gatePassedAt.get(alias);
3070
+ const at = passedAt.get(alias);
2861
3071
  return at !== void 0 && now - at < GATE_TTL_MS;
2862
3072
  }
2863
- /** Mount a dialog component into a fresh portal root; returns its closer.
2864
- * The dialog's onClose prop is forced to the closer so cancel works. */
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
+ */
2865
3080
  function mountModal(node) {
2866
3081
  const host = document.createElement("div");
2867
3082
  host.dataset.sshGateDialog = "";
@@ -2871,24 +3086,31 @@ window.__ModuleLoader__.load({
2871
3086
  root.unmount();
2872
3087
  host.remove();
2873
3088
  };
3089
+ const callerOnClose = node.props.onClose;
3090
+ const chainedOnClose = callerOnClose !== void 0 ? () => {
3091
+ callerOnClose();
3092
+ close();
3093
+ } : close;
2874
3094
  root.render((0, react.createElement)(node.type, {
2875
3095
  ...node.props,
2876
- onClose: close
3096
+ onClose: chainedOnClose
2877
3097
  }));
2878
3098
  return close;
2879
3099
  }
2880
- /** Transient inline error under the clicked row (removed after 8 s).
2881
- * A null row means the caller owns error surfacing — stay silent. */
2882
- function showBanner(row, alias, message) {
2883
- if (row === null) return;
2884
- const banner = document.createElement("div");
2885
- banner.dataset.sshGateError = "";
2886
- banner.textContent = `${tt$1("gate.failed", alias)}: ${message}`;
2887
- 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");
2888
- row.insertAdjacentElement("afterend", banner);
2889
- setTimeout(() => {
2890
- banner.remove();
2891
- }, 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;
2892
3114
  }
2893
3115
  /** Interactive prompts, resolving true when the user finished the action. */
2894
3116
  function promptFingerprint(api, alias, fingerprintSha256, mismatch) {
@@ -2908,7 +3130,7 @@ window.__ModuleLoader__.load({
2908
3130
  }));
2909
3131
  });
2910
3132
  }
2911
- function promptSecret(api, alias, secret) {
3133
+ function promptSecret(api, alias, secret, reason) {
2912
3134
  return new Promise((resolve) => {
2913
3135
  let closed = false;
2914
3136
  const finish = (ok) => {
@@ -2922,6 +3144,7 @@ window.__ModuleLoader__.load({
2922
3144
  alias,
2923
3145
  user,
2924
3146
  secret,
3147
+ reason,
2925
3148
  onClose: () => {
2926
3149
  finish(false);
2927
3150
  },
@@ -2934,39 +3157,56 @@ window.__ModuleLoader__.load({
2934
3157
  });
2935
3158
  }
2936
3159
  /**
2937
- * Probe + prompt until connected. Resolves true when the alias may proceed
2938
- * (the gate passed); false when the user cancelled or the failure is not
2939
- * 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.
2940
3168
  */
2941
- function ensureConnected(api, alias, row) {
3169
+ function connectHost(api, alias) {
2942
3170
  if (gateActive(alias, Date.now())) return Promise.resolve(true);
2943
3171
  const inFlight = pending.get(alias);
2944
3172
  if (inFlight !== void 0) return inFlight;
2945
3173
  const attempt = (async () => {
3174
+ let secretKind;
3175
+ let lastFailure = null;
2946
3176
  for (let i = 0; i < MAX_ATTEMPTS; i++) try {
2947
3177
  const result = await api.testHost(alias);
2948
3178
  if (result.ok) {
2949
- gatePassedAt.set(alias, Date.now());
3179
+ passedAt.set(alias, Date.now());
2950
3180
  return true;
2951
3181
  }
2952
3182
  if (result.code === "NEEDS_PASSWORD" && (result.secret === "password" || result.secret === "passphrase")) {
2953
- 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
+ }
2954
3188
  return false;
2955
3189
  }
2956
- showBanner(row, alias, result.error ?? "connection failed");
2957
- return false;
3190
+ lastFailure = result.error ?? "connection failed";
3191
+ return reportConnectionFailure(alias, lastFailure);
2958
3192
  } catch (cause) {
2959
- 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")) {
2960
3194
  const fingerprint = cause.hostKeyFingerprint ?? "";
2961
3195
  if (fingerprint !== "" && await promptFingerprint(api, alias, fingerprint, cause.code === "HOST_KEY_MISMATCH")) continue;
2962
- 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
+ }
2963
3205
  return false;
2964
3206
  }
2965
- showBanner(row, alias, cause instanceof Error ? cause.message : String(cause));
2966
- return false;
3207
+ return reportConnectionFailure(alias, lastFailure);
2967
3208
  }
2968
- showBanner(row, alias, tt$1("gate.retryLimit"));
2969
- return false;
3209
+ return reportConnectionFailure(alias, lastFailure ?? tt$1("gate.retryLimit"));
2970
3210
  })();
2971
3211
  pending.set(alias, attempt);
2972
3212
  attempt.finally(() => {
@@ -2974,68 +3214,199 @@ window.__ModuleLoader__.load({
2974
3214
  });
2975
3215
  return attempt;
2976
3216
  }
2977
- /** Silent prompt-only connection gate (no row / banner) for flows that
2978
- * surface their own errors (e.g. the directory browser). */
2979
- function connectHost(api, alias) {
2980
- 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;
2981
3254
  }
2982
3255
  /**
2983
- * Mount the click gate over every sidebar workspace row whose title belongs
2984
- * to an SSH-bound workspace. Self-heals via MutationObserver. Returns a
2985
- * 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.
2986
3264
  */
2987
- function mountWorkspaceGates(workspaces, api) {
3265
+ function mountWorkspaceBadges(workspaces, connected) {
2988
3266
  const byTitle = /* @__PURE__ */ new Map();
2989
- for (const workspace of workspaces) byTitle.set(workspace.title, workspace.alias);
2990
- const attach = (row) => {
2991
- 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) => {
2992
3273
  const titleEl = row.querySelector(TITLE_SELECTOR);
2993
3274
  if (titleEl === null || titleEl === void 0) return;
2994
- const alias = byTitle.get(titleEl.textContent?.trim() ?? "");
2995
- if (alias === void 0) return;
2996
- row.dataset.sshGate = "1";
2997
- row.addEventListener("click", (event) => {
2998
- if (gateActive(alias, Date.now())) return;
2999
- event.preventDefault();
3000
- event.stopPropagation();
3001
- ensureConnected(api, alias, row).then((ok) => {
3002
- if (ok) row.click();
3003
- });
3004
- }, 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));
3005
3283
  };
3284
+ /** Scan the sidebar column for project rows and decorate each. */
3006
3285
  const scan = () => {
3007
- for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR)) attach(row);
3286
+ for (const row of document.querySelectorAll(PROJECT_ROW_SELECTOR)) decorate(row);
3008
3287
  };
3009
3288
  const observer = new MutationObserver(() => {
3010
3289
  scan();
3011
3290
  });
3012
3291
  observer.observe(document.body, {
3013
3292
  childList: true,
3014
- subtree: true
3293
+ subtree: true,
3294
+ attributes: true,
3295
+ attributeFilter: ["title"]
3015
3296
  });
3016
3297
  scan();
3017
3298
  return () => {
3018
3299
  observer.disconnect();
3019
- 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();
3020
3301
  };
3021
3302
  }
3022
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
+ }
3395
+ };
3396
+ const dispose = deps.sessions.subscribe(pass);
3397
+ pass();
3398
+ return dispose;
3399
+ }
3400
+ //#endregion
3023
3401
  //#region src/client/ssh/panel/ClusterTab.tsx
3024
3402
  /**
3025
- * Cluster tab: run one command across many hosts (aliases / environment /
3026
- * 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
3027
3405
  * collapsed <details> blocks; status renders as a colored badge.
3028
3406
  */
3029
- /** Split a comma-separated input into a trimmed, non-empty string list. */
3030
- function splitList(text) {
3031
- return text.split(",").map((part) => part.trim()).filter((part) => part !== "");
3032
- }
3033
3407
  /** The cluster execution tab. */
3034
- function ClusterTab({ api }) {
3408
+ function ClusterTab({ api, alias }) {
3035
3409
  const [command, setCommand] = (0, react.useState)("");
3036
- const [aliases, setAliases] = (0, react.useState)("");
3037
- const [environment, setEnvironment] = (0, react.useState)("");
3038
- const [tags, setTags] = (0, react.useState)("");
3039
3410
  const [running, setRunning] = (0, react.useState)(false);
3040
3411
  const [results, setResults] = (0, react.useState)(null);
3041
3412
  const [error, setError] = (0, react.useState)(null);
@@ -3045,13 +3416,9 @@ window.__ModuleLoader__.load({
3045
3416
  setRunning(true);
3046
3417
  setError(null);
3047
3418
  try {
3048
- const aliasList = splitList(aliases);
3049
- const tagList = splitList(tags);
3050
3419
  const outcome = await api.cluster({
3051
3420
  command: command.trim(),
3052
- aliases: aliasList.length > 0 ? aliasList : void 0,
3053
- environment: environment.trim() === "" ? void 0 : environment.trim(),
3054
- tags: tagList.length > 0 ? tagList : void 0
3421
+ aliases: [alias]
3055
3422
  });
3056
3423
  setResults(outcome);
3057
3424
  } catch (cause) {
@@ -3080,421 +3447,93 @@ window.__ModuleLoader__.load({
3080
3447
  })]
3081
3448
  }),
3082
3449
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3083
- className: panel_module_css_default.clusterFilters,
3084
- children: [
3085
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3086
- className: panel_module_css_default.input,
3087
- placeholder: tt("cluster.aliases"),
3088
- value: aliases,
3089
- onChange: (event) => {
3090
- setAliases(event.target.value);
3091
- }
3092
- }),
3093
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3094
- className: panel_module_css_default.input,
3095
- placeholder: tt("cluster.environment"),
3096
- value: environment,
3097
- onChange: (event) => {
3098
- setEnvironment(event.target.value);
3099
- }
3100
- }),
3101
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3102
- className: panel_module_css_default.input,
3103
- placeholder: tt("cluster.tags"),
3104
- value: tags,
3105
- onChange: (event) => {
3106
- setTags(event.target.value);
3107
- }
3108
- })
3109
- ]
3110
- }),
3111
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3112
- type: "button",
3113
- className: panel_module_css_default.primaryButton,
3114
- disabled: running || command.trim() === "",
3115
- onClick: () => {
3116
- run();
3117
- },
3118
- children: tt("cluster.run")
3119
- }) })
3120
- ]
3121
- }),
3122
- error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3123
- className: panel_module_css_default.banner,
3124
- "data-kind": "error",
3125
- children: tt("common.error", { error })
3126
- }),
3127
- results === null && error === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3128
- className: panel_module_css_default.empty,
3129
- children: tt("cluster.empty")
3130
- }),
3131
- results !== null && results.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3132
- className: panel_module_css_default.empty,
3133
- children: tt("cluster.noMatch")
3134
- }),
3135
- results !== null && results.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3136
- className: panel_module_css_default.tableWrap,
3137
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
3138
- className: panel_module_css_default.table,
3139
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3140
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.alias") }),
3141
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.status") }),
3142
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.exit") }),
3143
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stdout") }),
3144
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.stderr") }),
3145
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.error") }),
3146
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("cluster.col.duration") })
3147
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: results.map((result) => {
3148
- const status = result.ok ? "ok" : result.timedOut === true ? "timeout" : "fail";
3149
- const label = result.ok ? tt("cluster.ok") : result.timedOut === true ? tt("cluster.timeout") : tt("cluster.fail");
3150
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3151
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3152
- className: panel_module_css_default.mono,
3153
- children: result.alias
3154
- }),
3155
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3156
- className: panel_module_css_default.badge,
3157
- "data-status": status,
3158
- children: label
3159
- }) }),
3160
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3161
- className: panel_module_css_default.mono,
3162
- children: result.exitCode ?? "-"
3163
- }),
3164
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stdout !== void 0 && result.stdout !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3165
- className: panel_module_css_default.cellDetails,
3166
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stdout") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3167
- className: panel_module_css_default.cellPre,
3168
- children: result.stdout
3169
- })]
3170
- }) }),
3171
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: result.stderr !== void 0 && result.stderr !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
3172
- className: panel_module_css_default.cellDetails,
3173
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("summary", { children: tt("cluster.col.stderr") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3174
- className: panel_module_css_default.cellPre,
3175
- children: result.stderr
3176
- })]
3177
- }) }),
3178
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3179
- className: panel_module_css_default.cellMuted,
3180
- children: result.error ?? ""
3181
- }),
3182
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3183
- className: panel_module_css_default.mono,
3184
- children: result.durationMs !== void 0 ? result.durationMs + " ms" : "-"
3185
- })
3186
- ] }, result.alias);
3187
- }) })]
3188
- })
3189
- })
3190
- ]
3191
- });
3192
- }
3193
- //#endregion
3194
- //#region src/client/ssh/panel/HostsTab.tsx
3195
- /**
3196
- * Hosts tab: the host table with search (debounced through listHosts),
3197
- * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
3198
- * that hands the alias to the terminal tab via onConnect.
3199
- */
3200
- /** The hosts table plus its toolbar and dialogs. */
3201
- function HostsTab({ api, onConnect }) {
3202
- const [hosts, setHosts] = (0, react.useState)(null);
3203
- const [error, setError] = (0, react.useState)(null);
3204
- const [search, setSearch] = (0, react.useState)("");
3205
- const [testingAlias, setTestingAlias] = (0, react.useState)(null);
3206
- const [testResults, setTestResults] = (0, react.useState)({});
3207
- const [importing, setImporting] = (0, react.useState)(false);
3208
- const [notice, setNotice] = (0, react.useState)(null);
3209
- const [dialog, setDialog] = (0, react.useState)(null);
3210
- const [hostKeyConfirm, setHostKeyConfirm] = (0, react.useState)(null);
3211
- const [secretConfirm, setSecretConfirm] = (0, react.useState)(null);
3212
- const seqRef = (0, react.useRef)(0);
3213
- const load = (0, react.useCallback)(async (query) => {
3214
- const seq = ++seqRef.current;
3215
- try {
3216
- const list = await api.listHosts(query);
3217
- if (seq !== seqRef.current) return;
3218
- setHosts(list);
3219
- setError(null);
3220
- } catch (cause) {
3221
- if (seq !== seqRef.current) return;
3222
- setError(errorMessage(cause));
3223
- }
3224
- }, [api]);
3225
- (0, react.useEffect)(() => {
3226
- load();
3227
- }, [load]);
3228
- (0, react.useEffect)(() => {
3229
- const timer = setTimeout(() => {
3230
- const query = search.trim();
3231
- load(query === "" ? void 0 : query);
3232
- }, 300);
3233
- return () => {
3234
- clearTimeout(timer);
3235
- };
3236
- }, [search, load]);
3237
- const runTest = async (alias) => {
3238
- setTestingAlias(alias);
3239
- try {
3240
- const result = await api.testHost(alias);
3241
- if (result.code === "NEEDS_PASSWORD" && (result.secret === "password" || result.secret === "passphrase")) {
3242
- setSecretConfirm({
3243
- alias,
3244
- secret: result.secret,
3245
- user: hosts?.find((host) => host.alias === alias)?.user
3246
- });
3247
- setTestingAlias(null);
3248
- return;
3249
- }
3250
- setTestResults((prev) => ({
3251
- ...prev,
3252
- [alias]: result
3253
- }));
3254
- } catch (cause) {
3255
- if (cause instanceof SshApiError && (cause.code === "HOST_KEY_UNKNOWN" || cause.code === "HOST_KEY_MISMATCH")) {
3256
- setHostKeyConfirm({
3257
- alias,
3258
- fingerprintSha256: cause.hostKeyFingerprint ?? "",
3259
- mismatch: cause.code === "HOST_KEY_MISMATCH"
3260
- });
3261
- setTestingAlias(null);
3262
- return;
3263
- }
3264
- setTestResults((prev) => ({
3265
- ...prev,
3266
- [alias]: {
3267
- ok: false,
3268
- error: errorMessage(cause)
3269
- }
3270
- }));
3271
- } finally {
3272
- setTestingAlias(null);
3273
- }
3274
- };
3275
- /** After the fingerprint is confirmed (or reset), retry the test. */
3276
- const retryAfterFingerprint = async (alias) => {
3277
- setHostKeyConfirm(null);
3278
- await runTest(alias);
3279
- };
3280
- /** After the session secret is provided, retry the test. */
3281
- const retryAfterSecret = async (alias) => {
3282
- setSecretConfirm(null);
3283
- await runTest(alias);
3284
- };
3285
- const deleteHost = async (alias) => {
3286
- if (!window.confirm(tt("hosts.deleteConfirm", { alias }))) return;
3287
- try {
3288
- await api.deleteHost(alias);
3289
- load();
3290
- } catch (cause) {
3291
- setError(errorMessage(cause));
3292
- }
3293
- };
3294
- const importConfig = async () => {
3295
- setImporting(true);
3296
- try {
3297
- const result = await api.importSshConfig();
3298
- setNotice(tt("hosts.imported", {
3299
- parsed: result.parsed,
3300
- added: result.added,
3301
- skipped: result.skipped
3302
- }));
3303
- load();
3304
- } catch (cause) {
3305
- setError(errorMessage(cause));
3306
- } finally {
3307
- setImporting(false);
3308
- }
3309
- };
3310
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3311
- className: panel_module_css_default.fillBody,
3312
- children: [
3313
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3314
- className: panel_module_css_default.toolbar,
3315
- children: [
3316
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3317
- className: panel_module_css_default.search,
3318
- type: "search",
3319
- placeholder: tt("hosts.search"),
3320
- value: search,
3321
- onChange: (event) => {
3322
- setSearch(event.target.value);
3323
- }
3324
- }),
3325
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: panel_module_css_default.toolbarSpacer }),
3326
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3327
- type: "button",
3328
- className: panel_module_css_default.primaryButton,
3329
- onClick: () => {
3330
- setDialog({ mode: "create" });
3331
- },
3332
- 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
+ })]
3333
3458
  }),
3334
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3459
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3335
3460
  type: "button",
3336
- className: panel_module_css_default.ghostButton,
3337
- disabled: importing,
3461
+ className: panel_module_css_default.primaryButton,
3462
+ disabled: running || command.trim() === "",
3338
3463
  onClick: () => {
3339
- importConfig();
3464
+ run();
3340
3465
  },
3341
- children: importing ? tt("common.loading") : tt("hosts.import")
3342
- })
3466
+ children: tt("cluster.run")
3467
+ }) })
3343
3468
  ]
3344
3469
  }),
3345
- notice !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3346
- className: panel_module_css_default.banner,
3347
- "data-kind": "ok",
3348
- children: notice
3349
- }),
3350
3470
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3351
3471
  className: panel_module_css_default.banner,
3352
3472
  "data-kind": "error",
3353
3473
  children: tt("common.error", { error })
3354
3474
  }),
3355
- hosts === null && error === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3356
- className: panel_module_css_default.loading,
3357
- 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")
3358
3478
  }),
3359
- 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", {
3360
3480
  className: panel_module_css_default.empty,
3361
- children: tt("hosts.empty")
3481
+ children: tt("cluster.noMatch")
3362
3482
  }),
3363
- 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", {
3364
3484
  className: panel_module_css_default.tableWrap,
3365
3485
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
3366
3486
  className: panel_module_css_default.table,
3367
3487
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3368
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.alias") }),
3369
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.host") }),
3370
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.user") }),
3371
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.auth") }),
3372
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.environment") }),
3373
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.tags") }),
3374
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.description") }),
3375
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.actions") })
3376
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: hosts.map((host) => {
3377
- 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");
3378
3498
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
3379
3499
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3380
3500
  className: panel_module_css_default.mono,
3381
- children: host.alias
3382
- }),
3383
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
3384
- className: panel_module_css_default.mono,
3385
- children: [
3386
- host.host,
3387
- ":",
3388
- host.port
3389
- ]
3501
+ children: result.alias
3390
3502
  }),
3391
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: host.user }),
3392
3503
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3393
3504
  className: panel_module_css_default.badge,
3394
- "data-kind": host.auth,
3395
- children: host.auth === "key" ? tt("form.auth.key") : tt("form.auth.password")
3505
+ "data-status": status,
3506
+ children: label
3396
3507
  }) }),
3397
3508
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3398
- className: panel_module_css_default.cellMuted,
3399
- children: host.environment ?? ""
3509
+ className: panel_module_css_default.mono,
3510
+ children: result.exitCode ?? "-"
3400
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
+ }) }),
3401
3526
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3402
3527
  className: panel_module_css_default.cellMuted,
3403
- children: host.tags.join(", ")
3528
+ children: result.error ?? ""
3404
3529
  }),
3405
3530
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3406
- className: panel_module_css_default.cellMuted,
3407
- children: host.description ?? ""
3408
- }),
3409
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3410
- className: panel_module_css_default.actions,
3411
- children: [
3412
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3413
- type: "button",
3414
- className: panel_module_css_default.linkButton,
3415
- disabled: testingAlias === host.alias,
3416
- onClick: () => {
3417
- runTest(host.alias);
3418
- },
3419
- children: testingAlias === host.alias ? tt("hosts.testing") : tt("hosts.test")
3420
- }),
3421
- testingAlias === host.alias && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3422
- className: panel_module_css_default.spinner,
3423
- "aria-hidden": "true"
3424
- }),
3425
- test !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3426
- className: panel_module_css_default.inlineTest,
3427
- "data-status": test.ok ? "ok" : "fail",
3428
- children: test.ok ? tt("hosts.testOk", { latency: test.latencyMs ?? 0 }) : tt("hosts.testFail", { error: test.error ?? "" })
3429
- }),
3430
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3431
- type: "button",
3432
- className: panel_module_css_default.linkButton,
3433
- onClick: () => {
3434
- setDialog({
3435
- mode: "edit",
3436
- host
3437
- });
3438
- },
3439
- children: tt("hosts.edit")
3440
- }),
3441
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3442
- type: "button",
3443
- className: panel_module_css_default.linkButton,
3444
- "data-danger": true,
3445
- onClick: () => {
3446
- deleteHost(host.alias);
3447
- },
3448
- children: tt("hosts.delete")
3449
- }),
3450
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3451
- type: "button",
3452
- className: panel_module_css_default.ghostButton,
3453
- onClick: () => {
3454
- onConnect(host.alias);
3455
- },
3456
- children: tt("hosts.connected")
3457
- })
3458
- ]
3459
- }) })
3460
- ] }, host.alias);
3531
+ className: panel_module_css_default.mono,
3532
+ children: result.durationMs !== void 0 ? result.durationMs + " ms" : "-"
3533
+ })
3534
+ ] }, result.alias);
3461
3535
  }) })]
3462
3536
  })
3463
- }),
3464
- dialog !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFormDialog, {
3465
- api,
3466
- editing: dialog.mode === "edit" ? dialog.host : null,
3467
- onClose: () => {
3468
- setDialog(null);
3469
- },
3470
- onSaved: () => {
3471
- setDialog(null);
3472
- load();
3473
- }
3474
- }),
3475
- hostKeyConfirm !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFingerprintDialog, {
3476
- api,
3477
- alias: hostKeyConfirm.alias,
3478
- fingerprintSha256: hostKeyConfirm.fingerprintSha256,
3479
- mismatch: hostKeyConfirm.mismatch,
3480
- onClose: () => {
3481
- setHostKeyConfirm(null);
3482
- },
3483
- onTrusted: () => {
3484
- retryAfterFingerprint(hostKeyConfirm.alias);
3485
- }
3486
- }),
3487
- secretConfirm !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionSecretDialog, {
3488
- api,
3489
- alias: secretConfirm.alias,
3490
- user: secretConfirm.user,
3491
- secret: secretConfirm.secret,
3492
- onClose: () => {
3493
- setSecretConfirm(null);
3494
- },
3495
- onProvided: () => {
3496
- retryAfterSecret(secretConfirm.alias);
3497
- }
3498
3537
  })
3499
3538
  ]
3500
3539
  });
@@ -16543,8 +16582,9 @@ window.__ModuleLoader__.load({
16543
16582
  //#region src/client/ssh/panel/TerminalTab.tsx
16544
16583
  /**
16545
16584
  * Terminal tab: an xterm.js PTY view over the host's WebSocket terminal route.
16546
- * A host <select> plus connect/disconnect controls; the terminal container is
16547
- * 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
16548
16588
  * output stays visible and input is disabled. xterm's stylesheet is injected
16549
16589
  * once per page load (module-level guard).
16550
16590
  */
@@ -16560,9 +16600,7 @@ window.__ModuleLoader__.load({
16560
16600
  document.head.appendChild(style);
16561
16601
  }
16562
16602
  /** The xterm terminal view. */
16563
- function TerminalTab({ api, presetAlias, requestId }) {
16564
- const [hosts, setHosts] = (0, react.useState)([]);
16565
- const [alias, setAlias] = (0, react.useState)(presetAlias ?? "");
16603
+ function TerminalTab({ api, alias }) {
16566
16604
  const [status, setStatus] = (0, react.useState)({ kind: "idle" });
16567
16605
  const containerRef = (0, react.useRef)(null);
16568
16606
  const termRef = (0, react.useRef)(null);
@@ -16571,26 +16609,6 @@ window.__ModuleLoader__.load({
16571
16609
  (0, react.useEffect)(() => {
16572
16610
  ensureXtermCss();
16573
16611
  }, []);
16574
- (0, react.useEffect)(() => {
16575
- let disposed = false;
16576
- (async () => {
16577
- try {
16578
- const list = await api.listHosts();
16579
- if (!disposed) setHosts(list);
16580
- } catch (cause) {
16581
- if (!disposed) setStatus({
16582
- kind: "error",
16583
- detail: errorMessage(cause)
16584
- });
16585
- }
16586
- })();
16587
- return () => {
16588
- disposed = true;
16589
- };
16590
- }, [api]);
16591
- (0, react.useEffect)(() => {
16592
- if (presetAlias !== void 0) setAlias(presetAlias);
16593
- }, [presetAlias, requestId]);
16594
16612
  const teardown = () => {
16595
16613
  const connection = connRef.current;
16596
16614
  connRef.current = null;
@@ -16642,6 +16660,7 @@ window.__ModuleLoader__.load({
16642
16660
  term.loadAddon(fit);
16643
16661
  term.open(container);
16644
16662
  fit.fit();
16663
+ term.options.disableStdin = true;
16645
16664
  const connection = api.openTerminal(target, term.cols, term.rows);
16646
16665
  termRef.current = term;
16647
16666
  fitRef.current = fit;
@@ -16651,6 +16670,7 @@ window.__ModuleLoader__.load({
16651
16670
  connection.send(data);
16652
16671
  });
16653
16672
  connection.onReady = () => {
16673
+ term.options.disableStdin = false;
16654
16674
  setStatus({
16655
16675
  kind: "connected",
16656
16676
  alias: target
@@ -16683,29 +16703,14 @@ window.__ModuleLoader__.load({
16683
16703
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16684
16704
  className: panel_module_css_default.controls,
16685
16705
  children: [
16686
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
16687
- className: panel_module_css_default.input,
16688
- value: alias,
16689
- onChange: (event) => {
16690
- setAlias(event.target.value);
16691
- },
16692
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
16693
- value: "",
16694
- children: tt("terminal.selectHost")
16695
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
16696
- value: host.alias,
16697
- children: [
16698
- host.alias,
16699
- " (",
16700
- host.host,
16701
- ")"
16702
- ]
16703
- }, host.alias))]
16706
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16707
+ className: panel_module_css_default.targetBadge,
16708
+ children: alias
16704
16709
  }),
16705
16710
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16706
16711
  type: "button",
16707
16712
  className: panel_module_css_default.primaryButton,
16708
- disabled: alias === "" || active,
16713
+ disabled: active,
16709
16714
  onClick: connect,
16710
16715
  children: tt("terminal.connect")
16711
16716
  }),
@@ -16745,7 +16750,7 @@ window.__ModuleLoader__.load({
16745
16750
  className: panel_module_css_default.termContainer
16746
16751
  }), status.kind === "idle" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
16747
16752
  className: panel_module_css_default.termPlaceholder,
16748
- children: hosts.length === 0 ? tt("hosts.empty") : tt("terminal.placeholder")
16753
+ children: tt("terminal.placeholder")
16749
16754
  })]
16750
16755
  })
16751
16756
  ]
@@ -16777,33 +16782,22 @@ window.__ModuleLoader__.load({
16777
16782
  return tt("transfer.gib", { value: (bytes / 1073741824).toFixed(1) });
16778
16783
  }
16779
16784
  /** The upload/download tab. */
16780
- function TransferTab({ api }) {
16781
- const [hosts, setHosts] = (0, react.useState)([]);
16785
+ function TransferTab({ api, alias, remoteRoot }) {
16782
16786
  const [listError, setListError] = (0, react.useState)(null);
16783
- const [alias, setAlias] = (0, react.useState)("");
16784
- const [remotePath, setRemotePath] = (0, react.useState)("");
16787
+ const [remotePath, setRemotePath] = (0, react.useState)(remoteRoot);
16785
16788
  const [browseOpen, setBrowseOpen] = (0, react.useState)(false);
16786
- const [browseDir, setBrowseDir] = (0, react.useState)("/");
16789
+ const [browseDir, setBrowseDir] = (0, react.useState)(remoteRoot);
16787
16790
  const [entries, setEntries] = (0, react.useState)([]);
16788
16791
  const [browsing, setBrowsing] = (0, react.useState)(false);
16789
16792
  const [transfer, setTransfer] = (0, react.useState)(null);
16790
16793
  const [status, setStatus] = (0, react.useState)(null);
16791
16794
  const fileRef = (0, react.useRef)(null);
16792
16795
  const seqRef = (0, react.useRef)(0);
16793
- (0, react.useEffect)(() => {
16794
- let disposed = false;
16795
- (async () => {
16796
- try {
16797
- const list = await api.listHosts();
16798
- if (!disposed) setHosts(list);
16799
- } catch (cause) {
16800
- if (!disposed) setListError(errorMessage(cause));
16801
- }
16802
- })();
16803
- return () => {
16804
- disposed = true;
16805
- };
16806
- }, [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
+ }, []);
16807
16801
  const loadDir = async (path) => {
16808
16802
  if (alias === "") return;
16809
16803
  const seq = ++seqRef.current;
@@ -16825,15 +16819,16 @@ window.__ModuleLoader__.load({
16825
16819
  }
16826
16820
  };
16827
16821
  const openBrowse = () => {
16828
- if (alias === "") return;
16829
16822
  setBrowseOpen(true);
16830
- const path = remotePath.trim() === "" ? "/" : remotePath.trim();
16823
+ const path = remotePath.trim() === "" ? remoteRoot : remotePath.trim();
16831
16824
  loadDir(path);
16832
16825
  };
16833
16826
  const handleFile = async (event) => {
16834
16827
  const file = event.target.files?.[0];
16835
16828
  event.target.value = "";
16836
16829
  if (file === void 0 || alias === "" || remotePath.trim() === "" || transfer !== null) return;
16830
+ const controller = new AbortController();
16831
+ transferAbortRef.current = controller;
16837
16832
  setStatus(null);
16838
16833
  setTransfer({
16839
16834
  kind: "upload",
@@ -16843,6 +16838,7 @@ window.__ModuleLoader__.load({
16843
16838
  });
16844
16839
  try {
16845
16840
  const outcome = await api.uploadFile(file, alias, remotePath.trim(), (progress) => {
16841
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16846
16842
  setTransfer((prev) => prev === null ? prev : {
16847
16843
  kind: "upload",
16848
16844
  phase: progress.phase,
@@ -16850,22 +16846,27 @@ window.__ModuleLoader__.load({
16850
16846
  speedBps: progress.speedBps,
16851
16847
  file: progress.file
16852
16848
  });
16853
- });
16854
- setStatus({
16849
+ }, controller.signal);
16850
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16855
16851
  kind: "ok",
16856
16852
  bytes: outcome.transferredBytes
16857
16853
  });
16858
16854
  } catch (cause) {
16859
- setStatus({
16855
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16860
16856
  kind: "error",
16861
16857
  error: errorMessage(cause)
16862
16858
  });
16863
16859
  } finally {
16864
- setTransfer(null);
16860
+ if (transferAbortRef.current === controller) {
16861
+ transferAbortRef.current = null;
16862
+ if (!controller.signal.aborted) setTransfer(null);
16863
+ }
16865
16864
  }
16866
16865
  };
16867
16866
  const handleDownload = async () => {
16868
16867
  if (alias === "" || remotePath.trim() === "" || transfer !== null) return;
16868
+ const controller = new AbortController();
16869
+ transferAbortRef.current = controller;
16869
16870
  setStatus(null);
16870
16871
  setTransfer({
16871
16872
  kind: "download",
@@ -16875,6 +16876,7 @@ window.__ModuleLoader__.load({
16875
16876
  });
16876
16877
  try {
16877
16878
  const result = await api.downloadFile(alias, remotePath.trim(), (progress) => {
16879
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16878
16880
  setTransfer((prev) => prev === null ? prev : {
16879
16881
  kind: "download",
16880
16882
  phase: progress.phase,
@@ -16882,7 +16884,8 @@ window.__ModuleLoader__.load({
16882
16884
  speedBps: progress.speedBps,
16883
16885
  file: progress.file
16884
16886
  });
16885
- });
16887
+ }, controller.signal);
16888
+ if (transferAbortRef.current !== controller || controller.signal.aborted) return;
16886
16889
  if (!result.streamed && result.blob !== void 0) {
16887
16890
  const url = URL.createObjectURL(result.blob);
16888
16891
  const anchor = document.createElement("a");
@@ -16900,39 +16903,27 @@ window.__ModuleLoader__.load({
16900
16903
  bytes: result.bytes
16901
16904
  });
16902
16905
  } catch (cause) {
16903
- setStatus({
16906
+ if (transferAbortRef.current === controller && !controller.signal.aborted) setStatus({
16904
16907
  kind: "error",
16905
16908
  error: errorMessage(cause)
16906
16909
  });
16907
16910
  } finally {
16908
- setTransfer(null);
16911
+ if (transferAbortRef.current === controller) {
16912
+ transferAbortRef.current = null;
16913
+ if (!controller.signal.aborted) setTransfer(null);
16914
+ }
16909
16915
  }
16910
16916
  };
16911
- const ready = alias !== "" && remotePath.trim() !== "" && transfer === null;
16917
+ const ready = remotePath.trim() !== "" && transfer === null;
16912
16918
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16913
16919
  className: panel_module_css_default.tabBody,
16914
16920
  children: [
16915
16921
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16916
16922
  className: panel_module_css_default.controls,
16917
16923
  children: [
16918
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
16919
- className: panel_module_css_default.input,
16920
- value: alias,
16921
- onChange: (event) => {
16922
- setAlias(event.target.value);
16923
- },
16924
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
16925
- value: "",
16926
- children: tt("transfer.selectHost")
16927
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
16928
- value: host.alias,
16929
- children: [
16930
- host.alias,
16931
- " (",
16932
- host.host,
16933
- ")"
16934
- ]
16935
- }, host.alias))]
16924
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16925
+ className: panel_module_css_default.targetBadge,
16926
+ children: alias
16936
16927
  }),
16937
16928
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
16938
16929
  className: panel_module_css_default.input,
@@ -16945,7 +16936,6 @@ window.__ModuleLoader__.load({
16945
16936
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16946
16937
  type: "button",
16947
16938
  className: panel_module_css_default.ghostButton,
16948
- disabled: alias === "",
16949
16939
  onClick: openBrowse,
16950
16940
  children: tt("transfer.browseRemote")
16951
16941
  }),
@@ -17074,28 +17064,14 @@ window.__ModuleLoader__.load({
17074
17064
  * and a new-tunnel form.
17075
17065
  */
17076
17066
  /** The tunnels tab. */
17077
- function TunnelsTab({ api }) {
17078
- const [hosts, setHosts] = (0, react.useState)([]);
17067
+ function TunnelsTab({ api, alias }) {
17079
17068
  const [tunnels, setTunnels] = (0, react.useState)(null);
17080
17069
  const [error, setError] = (0, react.useState)(null);
17081
17070
  const [notice, setNotice] = (0, react.useState)(null);
17082
- const [alias, setAlias] = (0, react.useState)("");
17083
17071
  const [remotePort, setRemotePort] = (0, react.useState)("");
17084
17072
  const [remoteHost, setRemoteHost] = (0, react.useState)("");
17085
17073
  const [localPort, setLocalPort] = (0, react.useState)("");
17086
17074
  const [busy, setBusy] = (0, react.useState)(false);
17087
- (0, react.useEffect)(() => {
17088
- let disposed = false;
17089
- (async () => {
17090
- try {
17091
- const list = await api.listHosts();
17092
- if (!disposed) setHosts(list);
17093
- } catch {}
17094
- })();
17095
- return () => {
17096
- disposed = true;
17097
- };
17098
- }, [api]);
17099
17075
  const seqRef = (0, react.useRef)(0);
17100
17076
  (0, react.useEffect)(() => {
17101
17077
  const load = async () => {
@@ -17103,7 +17079,7 @@ window.__ModuleLoader__.load({
17103
17079
  try {
17104
17080
  const list = await api.listTunnels();
17105
17081
  if (seq !== seqRef.current) return;
17106
- setTunnels(list);
17082
+ setTunnels(list.filter((tunnel) => tunnel.alias === alias));
17107
17083
  setError(null);
17108
17084
  } catch (cause) {
17109
17085
  if (seq !== seqRef.current) return;
@@ -17117,7 +17093,7 @@ window.__ModuleLoader__.load({
17117
17093
  return () => {
17118
17094
  clearInterval(timer);
17119
17095
  };
17120
- }, [api]);
17096
+ }, [api, alias]);
17121
17097
  const refresh = async () => {
17122
17098
  const seq = ++seqRef.current;
17123
17099
  try {
@@ -17142,7 +17118,7 @@ window.__ModuleLoader__.load({
17142
17118
  if (!window.confirm(tt("tunnel.stopAllConfirm"))) return;
17143
17119
  setBusy(true);
17144
17120
  try {
17145
- await api.stopAllTunnels(alias === "" ? void 0 : alias);
17121
+ await api.stopAllTunnels(alias);
17146
17122
  await refresh();
17147
17123
  } catch (cause) {
17148
17124
  setError(errorMessage(cause));
@@ -17251,19 +17227,9 @@ window.__ModuleLoader__.load({
17251
17227
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17252
17228
  className: panel_module_css_default.fieldLabel,
17253
17229
  children: tt("tunnel.alias")
17254
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
17255
- className: panel_module_css_default.input,
17256
- value: alias,
17257
- onChange: (event) => {
17258
- setAlias(event.target.value);
17259
- },
17260
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17261
- value: "",
17262
- children: tt("terminal.selectHost")
17263
- }), hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17264
- value: host.alias,
17265
- children: host.alias
17266
- }, host.alias))]
17230
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17231
+ className: panel_module_css_default.targetBadge,
17232
+ children: alias
17267
17233
  })]
17268
17234
  }),
17269
17235
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -17317,7 +17283,7 @@ window.__ModuleLoader__.load({
17317
17283
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17318
17284
  type: "button",
17319
17285
  className: panel_module_css_default.primaryButton,
17320
- disabled: busy || alias === "" || remotePort.trim() === "",
17286
+ disabled: busy || remotePort.trim() === "",
17321
17287
  onClick: () => {
17322
17288
  start();
17323
17289
  },
@@ -17330,18 +17296,13 @@ window.__ModuleLoader__.load({
17330
17296
  //#endregion
17331
17297
  //#region src/client/ssh/panel/SshPanel.tsx
17332
17298
  /**
17333
- * The SSH operations panel shell: a header with a close control, a five-tab
17334
- * bar, and the active tab's content. Tab state lives here (browser session
17335
- * state); inactive tabs unmount, so each tab fetches its own data on
17336
- * activation. The hosts tab's connect action switches here to the terminal
17337
- * 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.
17338
17303
  */
17339
17304
  /** The tab bar definition (labels resolved at render time). */
17340
17305
  const TABS = [
17341
- {
17342
- id: "hosts",
17343
- label: () => tt("tab.hosts")
17344
- },
17345
17306
  {
17346
17307
  id: "terminal",
17347
17308
  label: () => tt("tab.terminal")
@@ -17359,36 +17320,39 @@ window.__ModuleLoader__.load({
17359
17320
  label: () => tt("tab.cluster")
17360
17321
  }
17361
17322
  ];
17362
- /** The tabbed SSH panel. */
17363
- function SshPanel({ controller, api }) {
17364
- const [activeTab, setActiveTab] = (0, react.useState)("hosts");
17365
- const [connectRequest, setConnectRequest] = (0, react.useState)(null);
17366
- const handleConnect = (alias) => {
17367
- setActiveTab("terminal");
17368
- setConnectRequest((prev) => ({
17369
- alias,
17370
- nonce: (prev?.nonce ?? 0) + 1
17371
- }));
17372
- };
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}`;
17373
17351
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17374
17352
  className: panel_module_css_default.panel,
17353
+ "data-session-mode": "ssh",
17354
+ "data-session-alias": sessionTarget.alias,
17375
17355
  children: [
17376
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17377
- className: panel_module_css_default.panelHeader,
17378
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
17379
- className: panel_module_css_default.panelTitle,
17380
- children: tt("panel.title")
17381
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17382
- type: "button",
17383
- className: panel_module_css_default.iconButton,
17384
- title: tt("common.close"),
17385
- "aria-label": tt("common.close"),
17386
- onClick: () => {
17387
- controller.close();
17388
- },
17389
- children: "x"
17390
- })]
17391
- }),
17392
17356
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17393
17357
  className: panel_module_css_default.tabBar,
17394
17358
  role: "tablist",
@@ -17404,271 +17368,172 @@ window.__ModuleLoader__.load({
17404
17368
  children: tab.label()
17405
17369
  }, tab.id))
17406
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
+ }),
17407
17379
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17408
17380
  className: panel_module_css_default.panelContent,
17409
17381
  children: [
17410
- activeTab === "hosts" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostsTab, {
17382
+ activeTab === "terminal" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalTab, {
17411
17383
  api,
17412
- onConnect: handleConnect
17384
+ alias: sessionTarget.alias
17413
17385
  }),
17414
- 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, {
17415
17392
  api,
17416
- presetAlias: connectRequest?.alias,
17417
- requestId: connectRequest?.nonce
17393
+ alias: sessionTarget.alias
17418
17394
  }),
17419
- activeTab === "transfer" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TransferTab, { api }),
17420
- activeTab === "tunnels" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TunnelsTab, { api }),
17421
- 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
+ })
17422
17399
  ]
17423
- })
17400
+ }, targetKey)
17424
17401
  ]
17425
17402
  });
17426
17403
  }
17427
17404
  //#endregion
17428
- //#region src/client/ssh/mount.tsx
17429
- /**
17430
- * Panel view mounting.
17431
- *
17432
- * The `conversation` slot is single-occupant (ui-conversation) and external
17433
- * plugins cannot declare slots, so the panel takes over the center column at
17434
- * the DOM level: a container is appended inside the `[data-pane="conversation"]`
17435
- * grid item (an extra trailing child React never manages), and a stylesheet
17436
- * rule hides the conversation content while the panel is active. Toggling is
17437
- * a data attribute on <html> — no React involvement, so the conversation
17438
- * subtree underneath stays mounted and stateful.
17439
- */
17440
- const CONVERSATION_COLUMN_SELECTOR = "[data-pane=\"conversation\"]";
17441
- const ACTIVE_ATTR = "data-dsh-ssh-active";
17442
- /** Find the center column, or undefined while the frame is not mounted. */
17443
- function conversationColumn() {
17444
- return document.querySelector(CONVERSATION_COLUMN_SELECTOR) ?? void 0;
17445
- }
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";
17446
17410
  /**
17447
- * Mount the panel React tree into the center column and bind its visibility
17448
- * to the controller's panelOpen state.
17449
- * @param controller - the panel controller driving the view.
17450
- * @param api - the SSH API client the tabs operate through.
17451
- * @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.
17452
17415
  */
17453
- function mountPanel(controller, api) {
17454
- let root;
17455
- let container;
17456
- const ensure = () => {
17457
- if (container !== void 0) {
17458
- if (container.isConnected) return;
17459
- root?.unmount();
17460
- root = void 0;
17461
- container.remove();
17462
- container = void 0;
17463
- }
17464
- const column = conversationColumn();
17465
- if (column === void 0) return;
17466
- container = document.createElement("div");
17467
- container.dataset.dshSshView = "";
17468
- container.className = panel_module_css_default.view;
17469
- column.appendChild(container);
17470
- root = (0, react_dom_client.createRoot)(container);
17471
- root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SshPanel, {
17472
- controller,
17473
- api
17474
- }));
17475
- };
17476
- const waitObserver = new MutationObserver(() => {
17477
- ensure();
17478
- });
17479
- waitObserver.observe(document.body, {
17480
- childList: true,
17481
- subtree: true
17482
- });
17483
- const applyActive = () => {
17484
- if (controller.getSnapshot().panelOpen) document.documentElement.setAttribute(ACTIVE_ATTR, "");
17485
- else document.documentElement.removeAttribute(ACTIVE_ATTR);
17486
- };
17487
- const unsubscribe = controller.subscribe(applyActive);
17488
- applyActive();
17489
- ensure();
17490
- return () => {
17491
- waitObserver.disconnect();
17492
- unsubscribe();
17493
- document.documentElement.removeAttribute(ACTIVE_ATTR);
17494
- root?.unmount();
17495
- root = void 0;
17496
- container?.remove();
17497
- container = void 0;
17498
- };
17499
- }
17500
- //#endregion
17501
- //#region src/client/ssh/panel/controller.ts
17502
- /** The panel state owner the sidebar entry toggles and the view renders from. */
17503
- var PanelController = class {
17504
- panelOpen = false;
17505
- listeners = /* @__PURE__ */ new Set();
17506
- getSnapshot() {
17507
- return { panelOpen: this.panelOpen };
17508
- }
17509
- subscribe(fn) {
17510
- this.listeners.add(fn);
17511
- return () => {
17512
- this.listeners.delete(fn);
17513
- };
17514
- }
17515
- open() {
17516
- if (this.panelOpen) return;
17517
- this.panelOpen = true;
17518
- this.notify();
17519
- }
17520
- close() {
17521
- if (!this.panelOpen) return;
17522
- this.panelOpen = false;
17523
- this.notify();
17524
- }
17525
- toggle() {
17526
- if (this.panelOpen) this.close();
17527
- else this.open();
17528
- }
17529
- notify() {
17530
- for (const fn of [...this.listeners]) fn();
17531
- }
17532
- };
17533
- //#endregion
17534
- //#region src/client/ssh/sidebar-entry.ts
17535
- /** The sidebar column is the grid item AppFrame renders with this pane marker. */
17536
- const SIDEBAR_COLUMN_SELECTOR = "[data-pane=\"sidebar\"]";
17537
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
17538
- function sidebarRoot() {
17539
- return document.querySelector(SIDEBAR_COLUMN_SELECTOR)?.firstElementChild ?? void 0;
17540
- }
17541
- /** The New Session button: the shell's only direct-child button of the root. */
17542
- function newSessionButton(root) {
17543
- for (const child of root.children) if (child.tagName === "BUTTON") return child;
17544
- }
17545
- /** Build the entry row (a detached button; insert once the shell is up). */
17546
- function createEntry(controller) {
17547
- const entry = document.createElement("button");
17548
- entry.type = "button";
17549
- entry.dataset.dshSshEntry = "";
17550
- entry.className = panel_module_css_default.entry;
17551
- entry.setAttribute("aria-label", tt("entry.label"));
17552
- entry.setAttribute("title", tt("entry.tooltip"));
17553
- 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>";
17554
- entry.addEventListener("click", () => {
17555
- controller.toggle();
17556
- });
17557
- return entry;
17558
- }
17559
- /** Re-insert the entry after the New Session button (before the browser region). */
17560
- function placeEntry(root, entry) {
17561
- const button = newSessionButton(root);
17562
- if (button === void 0) return false;
17563
- if (entry.parentElement !== root) root.insertBefore(entry, button.nextElementSibling);
17564
- 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));
17565
17439
  }
17566
- /**
17567
- * Mount the sidebar entry, waiting for the shell to render and self-healing
17568
- * on later React re-renders.
17569
- * @param controller - the panel controller the entry toggles.
17570
- * @returns disposer removing the entry and its observers.
17571
- */
17572
- function mountSidebarEntry(controller) {
17573
- const entry = createEntry(controller);
17574
- let root;
17575
- let placed = false;
17576
- const tryPlace = () => {
17577
- if (placed) return;
17578
- if (root !== void 0 && !root.isConnected) {
17579
- rootObserver.disconnect();
17580
- root = void 0;
17581
- }
17582
- root ??= sidebarRoot();
17583
- if (root === void 0) return;
17584
- placed = placeEntry(root, entry);
17585
- if (placed) rootObserver.observe(root, {
17586
- childList: true,
17587
- subtree: true
17588
- });
17589
- };
17590
- const waitObserver = new MutationObserver(() => {
17591
- tryPlace();
17592
- });
17593
- waitObserver.observe(document.body, {
17594
- childList: true,
17595
- subtree: true
17596
- });
17597
- const rootObserver = new MutationObserver(() => {
17598
- if (root === void 0 || !root.isConnected) {
17599
- placed = false;
17600
- tryPlace();
17601
- return;
17602
- }
17603
- if (!root.contains(entry)) placed = placeEntry(root, entry);
17604
- });
17605
- const unsubscribe = controller.subscribe(() => {
17606
- entry.dataset.active = controller.getSnapshot().panelOpen ? "true" : void 0;
17607
- });
17608
- entry.dataset.active = controller.getSnapshot().panelOpen ? "true" : void 0;
17609
- tryPlace();
17610
- return () => {
17611
- waitObserver.disconnect();
17612
- rootObserver.disconnect();
17613
- unsubscribe();
17614
- entry.remove();
17615
- };
17440
+ /** The tab chip and floating-panel header text. */
17441
+ function OpsTabTitle() {
17442
+ return tt$1("ops.tab");
17616
17443
  }
17617
17444
  //#endregion
17618
17445
  //#region src/client/ssh/apply.ts
17619
17446
  /** Locale namespace this capability owns (kept as 'dsh-ssh'). */
17620
17447
  const NS = "dsh-ssh";
17621
17448
  /**
17622
- * Mount the SSH operations surfaces (sidebar entry + operations panel).
17623
- * @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.
17624
17453
  */
17625
- 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
+ }
17626
17460
  ctx.effect(() => ctx.locale.register(NS, {
17627
17461
  zh,
17628
17462
  en
17629
17463
  }), "dsh-ssh: dictionaries");
17630
- const controller = new PanelController();
17631
- const api = new SshApi();
17632
- const disposers = [];
17633
- try {
17634
- disposers.push(mountSidebarEntry(controller));
17635
- disposers.push(mountPanel(controller, api));
17636
- } catch (error) {
17637
- console.warn("[dsh-ssh] mount failed:", error);
17638
- }
17639
- ctx.effect(() => () => {
17640
- for (const dispose of disposers.splice(0)) dispose();
17641
- }, "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
+ };
17642
17498
  }
17643
17499
  //#endregion
17644
17500
  //#region src/client/index.ts
17645
- /** Required services: slots for the header buttons + directory-flow holes,
17646
- * 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. */
17647
17507
  const inject = [
17648
17508
  "slots",
17649
17509
  "locale",
17650
- "workspaces"
17510
+ "workspaces",
17511
+ "sessions",
17512
+ "sidebarRightTabs"
17651
17513
  ];
17652
17514
  /** Apply the browser half. */
17653
17515
  function apply(ctx) {
17654
- mountSshOperations(ctx);
17655
- ctx.effect(() => ctx.locale.register(NS$1, dictionaries), "dsh-hardssh: dictionaries");
17656
17516
  const api = new WorkspaceApi();
17657
17517
  const hostsApi = new SshHostsApi();
17658
17518
  const sshApi = new SshApi();
17659
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");
17660
17532
  const disposers = [];
17661
17533
  try {
17662
- ctx.slots.inject("conversation.session.header.utilities", () => {
17663
- return ctx.slots.register({
17664
- name: "conversation.session.header.utilities",
17665
- id: "ssh-workspace-manager",
17666
- order: -10,
17667
- inject: () => ({
17668
- manager,
17669
- sshApi
17670
- })
17671
- }, WorkspaceManagerButton);
17534
+ registerWorkspacePanel(ctx, {
17535
+ manager,
17536
+ sshApi
17672
17537
  });
17673
17538
  const flowInject = () => ({
17674
17539
  pickDirectory: () => ctx.workspaces.pickDirectory(),
@@ -17729,25 +17594,10 @@ window.__ModuleLoader__.load({
17729
17594
  window.clearInterval(connTimer);
17730
17595
  });
17731
17596
  refreshConnections();
17732
- let gateDispose;
17733
- const refreshGates = () => {
17734
- try {
17735
- const workspaces = manager.getSnapshot().workspaces;
17736
- gateDispose?.();
17737
- gateDispose = mountWorkspaceGates(workspaces.map((workspace) => ({
17738
- id: workspace.id,
17739
- title: workspace.title,
17740
- alias: workspace.alias
17741
- })), sshApi);
17742
- } catch (error) {
17743
- console.warn("[dsh-hardssh] gate refresh failed:", error);
17744
- }
17745
- };
17746
- disposers.push(() => {
17747
- gateDispose?.();
17748
- });
17749
- disposers.push(manager.subscribe(() => {
17750
- refreshGates();
17597
+ disposers.push(mountSessionConnectGate({
17598
+ sessions,
17599
+ aliasForCwd: makeAnchorAliasResolver(() => manager.getSnapshot().workspaces),
17600
+ ensureConnected: (alias) => connectHost(sshApi, alias)
17751
17601
  }));
17752
17602
  } catch (error) {
17753
17603
  console.warn("[dsh-hardssh] mount failed:", error);