@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
@@ -1,96 +1,99 @@
1
- /**
2
- * Sidebar workspace-row augmentation: labels every host workspace row whose
3
- * title matches an SSH-bound workspace with a compact remote badge
4
- * (`alias @ remoteRoot`), so a glance tells a local workspace from a remote
5
- * one. Pure DOM-level extension (the sidebar shell exposes no slot for row
6
- * decoration), following the dsh-ssh sidebar-entry precedent: a
7
- * MutationObserver self-heals when React re-renders displace the injected
8
- * badge.
9
- *
10
- * The row title alone matches: SSH-bound workspace titles are unique enough
11
- * in practice (the ledger title is what the row shows). A title match against
12
- * `~/.dsh/ssh-workspaces/<id>` anchors also works when the host picks that
13
- * default title.
14
- *
15
- * @module dsh-hardssh/client/workspace-badges
16
- */
17
-
18
- const PROJECT_ROW_SELECTOR = '[class*="projectRow"]'
19
- const TITLE_SELECTOR = '[class*="title"]'
20
-
21
- /** Render a small remote badge element. The cloud icon marks "remote";
22
- * connected servers render in DeepSeek blue, disconnected stay gray. The
23
- * tooltip shows the REMOTE directory plus the server, e.g.
24
- * `/data/app (prod-01)`. */
25
- export function makeBadge(alias: string, remoteRoot: string, connected: boolean): HTMLElement {
26
- const badge = document.createElement('span')
27
- badge.dataset.sshBadge = connected ? 'connected' : 'disconnected'
28
- badge.title = `${remoteRoot}(${alias})`
29
- badge.setAttribute(
30
- 'style',
31
- connected
32
- ? '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'
33
- : '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',
34
- )
35
- const icon = document.createElement('span')
36
- icon.setAttribute('style', 'display:inline-flex;flex:none;line-height:1')
37
- 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>'
38
- const label = document.createElement('span')
39
- label.textContent = alias
40
- label.setAttribute('style', 'font-weight:400')
41
- badge.append(icon, label)
42
- return badge
43
- }
44
-
45
- /**
46
- * Mount the row-decoration pass. Queries the sidebar for project rows and
47
- * appends a remote badge when the row's title belongs to an SSH workspace.
48
- * Badges of aliases in `connected` render blue, others gray. Self-heals via
49
- * MutationObserver on the sidebar container (rows re-render on
50
- * session/workspace changes).
51
- * @param workspaces - the SSH workspace records (id/title/alias/remoteRoot).
52
- * @param connected - aliases with a live pooled connection (badge coloring).
53
- * @returns disposer.
54
- */
55
- export function mountWorkspaceBadges(
56
- workspaces: Array<{ id: string; title: string; alias: string; remoteRoot: string }>,
57
- connected?: ReadonlySet<string>,
58
- ): () => void {
59
- const byTitle = new Map<string, { alias: string; remoteRoot: string }>()
60
- for (const workspace of workspaces) byTitle.set(workspace.title, workspace)
61
-
62
- /** Decorate one project row (idempotent: skip rows already tagged). */
63
- const decorate = (row: HTMLElement): void => {
64
- if (row.querySelector('[data-ssh-badge]') !== null) return
65
- const titleEl = row.querySelector<HTMLElement>(TITLE_SELECTOR)
66
- if (titleEl === null || titleEl === undefined) return
67
- const title = titleEl.textContent?.trim() ?? ''
68
- const target = byTitle.get(title)
69
- if (target === undefined) return
70
- const isConnected = connected?.has(target.alias) ?? false
71
- // Hovering the row shows the REMOTE directory (not the local anchor path
72
- // the shell would otherwise display).
73
- row.title = `${target.remoteRoot}(${target.alias})`
74
- titleEl.appendChild(makeBadge(target.alias, target.remoteRoot, isConnected))
75
- }
76
-
77
- /** Scan the sidebar column for project rows and decorate each. */
78
- const scan = (): void => {
79
- for (const row of document.querySelectorAll<HTMLElement>(PROJECT_ROW_SELECTOR)) {
80
- decorate(row)
81
- }
82
- }
83
-
84
- // Watch the whole document for the sidebar column arriving / re-rendering.
85
- const observer = new MutationObserver(() => { scan() })
86
- observer.observe(document.body, { childList: true, subtree: true })
87
- scan()
88
-
89
- return () => {
90
- observer.disconnect()
91
- // Remove injected badges.
92
- for (const badge of document.querySelectorAll<HTMLElement>('[data-ssh-badge]')) {
93
- badge.remove()
94
- }
95
- }
96
- }
1
+ /**
2
+ * Sidebar workspace-row augmentation: labels every host workspace row whose
3
+ * title matches an SSH-bound workspace with a compact remote badge
4
+ * (`alias @ remoteRoot`), so a glance tells a local workspace from a remote
5
+ * one. Pure DOM-level extension (the sidebar shell exposes no slot for row
6
+ * decoration), following the dsh-ssh sidebar-entry precedent: a
7
+ * MutationObserver self-heals when React re-renders displace the injected
8
+ * badge.
9
+ *
10
+ * The row title alone matches: SSH-bound workspace titles are unique enough
11
+ * in practice (the ledger title is what the row shows). A title match against
12
+ * `~/.dsh/ssh-workspaces/<id>` anchors also works when the host picks that
13
+ * default title.
14
+ *
15
+ * @module dsh-hardssh/client/workspace-badges
16
+ */
17
+
18
+ const PROJECT_ROW_SELECTOR = '[class*="projectRow"]'
19
+ const TITLE_SELECTOR = '[class*="title"]'
20
+
21
+ /** Render a small remote badge element. The cloud icon marks "remote";
22
+ * connected servers render in DeepSeek blue, disconnected stay gray. The
23
+ * tooltip shows the REMOTE directory plus the server, e.g.
24
+ * `/data/app (prod-01)`. */
25
+ export function makeBadge(alias: string, remoteRoot: string, connected: boolean): HTMLElement {
26
+ const badge = document.createElement('span')
27
+ badge.dataset.sshBadge = connected ? 'connected' : 'disconnected'
28
+ badge.title = `${remoteRoot}(${alias})`
29
+ badge.setAttribute(
30
+ 'style',
31
+ connected
32
+ ? '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'
33
+ : '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',
34
+ )
35
+ const icon = document.createElement('span')
36
+ icon.setAttribute('style', 'display:inline-flex;flex:none;line-height:1')
37
+ 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>'
38
+ const label = document.createElement('span')
39
+ label.textContent = alias
40
+ label.setAttribute('style', 'font-weight:400')
41
+ badge.append(icon, label)
42
+ return badge
43
+ }
44
+
45
+ /**
46
+ * Mount the row-decoration pass. Queries the sidebar for project rows and
47
+ * appends a remote badge when the row's title belongs to an SSH workspace.
48
+ * Badges of aliases in `connected` render blue, others gray. Self-heals via
49
+ * MutationObserver on the sidebar container (rows re-render on
50
+ * session/workspace changes).
51
+ * @param workspaces - the SSH workspace records (id/title/alias/remoteRoot).
52
+ * @param connected - aliases with a live pooled connection (badge coloring).
53
+ * @returns disposer.
54
+ */
55
+ export function mountWorkspaceBadges(
56
+ workspaces: Array<{ id: string; title: string; alias: string; remoteRoot: string }>,
57
+ connected?: ReadonlySet<string>,
58
+ ): () => void {
59
+ const byTitle = new Map<string, { alias: string; remoteRoot: string }>()
60
+ for (const workspace of workspaces) byTitle.set(workspace.title, workspace)
61
+
62
+ /** Decorate one project row: ALWAYS keep the row tooltip = the REMOTE
63
+ * directory (server), never the local anchor path the shell would show;
64
+ * mount the badge once (idempotent via the guard). Re-runs on every scan,
65
+ * so a shell re-render that resets <title> gets corrected. */
66
+ const decorate = (row: HTMLElement): void => {
67
+ const titleEl = row.querySelector<HTMLElement>(TITLE_SELECTOR)
68
+ if (titleEl === null || titleEl === undefined) return
69
+ const title = titleEl.textContent?.trim() ?? ''
70
+ const target = byTitle.get(title)
71
+ if (target === undefined) return
72
+ const desiredTitle = `${target.remoteRoot}(${target.alias})`
73
+ if (row.title !== desiredTitle) row.title = desiredTitle
74
+ if (row.querySelector('[data-ssh-badge]') !== null) return
75
+ const isConnected = connected?.has(target.alias) ?? false
76
+ titleEl.appendChild(makeBadge(target.alias, target.remoteRoot, isConnected))
77
+ }
78
+
79
+ /** Scan the sidebar column for project rows and decorate each. */
80
+ const scan = (): void => {
81
+ for (const row of document.querySelectorAll<HTMLElement>(PROJECT_ROW_SELECTOR)) {
82
+ decorate(row)
83
+ }
84
+ }
85
+
86
+ // Watch the whole document for the sidebar column arriving / re-rendering,
87
+ // including <title> attribute changes (the shell may reset row tooltips).
88
+ const observer = new MutationObserver(() => { scan() })
89
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] })
90
+ scan()
91
+
92
+ return () => {
93
+ observer.disconnect()
94
+ // Remove injected badges.
95
+ for (const badge of document.querySelectorAll<HTMLElement>('[data-ssh-badge]')) {
96
+ badge.remove()
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The SSH workspace manager's LEFT-sidebar global entry and its CENTER panel.
3
+ *
4
+ * Both are standard plugin extension points, not DOM injection:
5
+ * - `sidebar.panellist` declares the row between "New session" and the
6
+ * workspace browser. Its `id` IS the main-panel key the row selects, and the
7
+ * shell owns the button, the label, and the active highlight.
8
+ * - `main` hosts the panel body under that same key.
9
+ *
10
+ * The id is deliberately our own rather than a shipped one: a fresh id is
11
+ * added beside the shipped rows, while reusing one would replace that row.
12
+ */
13
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
14
+ // Type-only: pulls the SlotMap augmentation for `sidebar.panellist` / `main`
15
+ // and their owner-prop shapes.
16
+ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
18
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
19
+ import type { WorkspaceManager } from './state.ts'
20
+ import type { SshApi } from './ssh/api.ts'
21
+ import { tt } from './text.ts'
22
+ import { WorkspaceManagerPanel } from './workspace-panel.tsx'
23
+
24
+ /** The main-panel key AND the sidebar row id: one identity addresses both. */
25
+ export const WORKSPACE_PANEL_ID = 'dsh-hardssh-workspaces'
26
+
27
+ /** Order among the global panel rows; the shipped rows sit around 0. */
28
+ const WORKSPACE_PANEL_ORDER = 100
29
+
30
+ /** Icon presentation the sidebar supplies to a global panel row. */
31
+ interface PanelIconProps {
32
+ size: number
33
+ active: boolean
34
+ }
35
+
36
+ /** The cloud/terminal glyph the workspace row shows in both the wide and rail sidebar. */
37
+ function WorkspacePanelIcon({ size }: PanelIconProps) {
38
+ return (
39
+ <svg
40
+ viewBox="0 0 16 16"
41
+ width={size}
42
+ height={size}
43
+ fill="none"
44
+ stroke="currentColor"
45
+ strokeWidth="1.3"
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ aria-hidden="true"
49
+ >
50
+ <rect x="2" y="2.5" width="12" height="11" rx="1.5" />
51
+ <path d="M4.5 5.5l2.5 2.5-2.5 2.5" />
52
+ <path d="M8.5 10.5h3" />
53
+ </svg>
54
+ )
55
+ }
56
+
57
+ /** Dependencies the panel body needs at render time. */
58
+ export interface WorkspacePanelDeps {
59
+ manager: WorkspaceManager
60
+ /** Absent when the plugin is mounted without the SSH operations half. */
61
+ sshApi?: SshApi
62
+ }
63
+
64
+ /**
65
+ * Register the left-sidebar row and the center panel body.
66
+ * @param ctx - client root context carrying the slot registry.
67
+ * @param deps - the workspace manager and the host API the panel uses.
68
+ */
69
+ export function registerWorkspacePanel(ctx: ClientContext, deps: WorkspacePanelDeps): void {
70
+ const inject = (): Record<string, unknown> => ({ manager: deps.manager, sshApi: deps.sshApi })
71
+
72
+ // The global row: the shell renders the button and the active highlight from
73
+ // the list metadata, and selecting it calls layout.selectPanel(id).
74
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
75
+ {
76
+ name: 'sidebar.panellist',
77
+ id: WORKSPACE_PANEL_ID,
78
+ order: WORKSPACE_PANEL_ORDER,
79
+ // A thunk is re-read on every projection, so a language change needs no
80
+ // re-registration.
81
+ label: () => tt('panel.workspaces'),
82
+ },
83
+ WorkspacePanelIcon,
84
+ ))
85
+
86
+ // The center panel body, under the same key the row selects.
87
+ ctx.slots.inject('main', () => ctx.slots.register(
88
+ { name: 'main', key: WORKSPACE_PANEL_ID, inject },
89
+ WorkspaceManagerPanel,
90
+ ))
91
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The SSH workspace manager as a CENTER panel (left sidebar global entry →
3
+ * `main` slot). Content is unchanged from the old session-header dropdown:
4
+ * server rows with their SSH-bound workspaces underneath, server edit/delete
5
+ * (delete is refused while the server still backs a workspace), and a
6
+ * "new server" action at the bottom.
7
+ *
8
+ * This is a plain panel body: no portal, no anchoring, no click-outside
9
+ * dismissal. The left sidebar owns the entry row and the center column owns
10
+ * the frame; closing the panel is the shell's business (select the
11
+ * Conversation row back), not ours.
12
+ */
13
+ import { useEffect, useState, type ReactElement } from 'react'
14
+ import type { WorkspaceManager } from './state.ts'
15
+ import type { SshWorkspaceRecord } from '../protocol.ts'
16
+ import { tt } from './text.ts'
17
+ import css from './workspace.module.css'
18
+ import { CloudIcon, ComputerIcon } from './icons.tsx'
19
+ import type { SshHostSummary } from '../ssh/protocol.ts'
20
+ import { HostFormDialog } from './ssh/panel/HostFormDialog.tsx'
21
+ import type { SshApi } from './ssh/api.ts'
22
+
23
+ /** The host-form dialog invocation state. */
24
+ type HostDialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
25
+
26
+ /** Panel props: the workspace snapshot owner and the host API. */
27
+ export interface WorkspaceManagerPanelProps {
28
+ manager: WorkspaceManager
29
+ /** Full SSH host API (list/create/update/delete) for server management. */
30
+ sshApi?: SshApi
31
+ }
32
+
33
+ /** The workspace manager panel body. */
34
+ export function WorkspaceManagerPanel({ manager, sshApi }: WorkspaceManagerPanelProps): ReactElement {
35
+ const [workspaces, setWorkspaces] = useState<SshWorkspaceRecord[]>(() => manager.getSnapshot().workspaces)
36
+ const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
37
+ const [connectedAliases, setConnectedAliases] = useState<ReadonlySet<string> | null>(null)
38
+ /** Why connection state is unavailable — surfaced, never silently degraded. */
39
+ const [connectionError, setConnectionError] = useState<string | null>(null)
40
+ const [error, setError] = useState<string | null>(null)
41
+ const [deleting, setDeleting] = useState<string | null>(null)
42
+ const [deletingAlias, setDeletingAlias] = useState<string | null>(null)
43
+ const [hostDialog, setHostDialog] = useState<HostDialogState | null>(null)
44
+
45
+ const loadHosts = (): void => {
46
+ if (sshApi === undefined) {
47
+ setHosts([])
48
+ return
49
+ }
50
+ void sshApi.listHosts().then(
51
+ (list) => { setHosts(list) },
52
+ (cause: unknown) => {
53
+ setError(cause instanceof Error ? cause.message : String(cause))
54
+ setHosts([])
55
+ },
56
+ )
57
+ }
58
+
59
+ useEffect(() => {
60
+ // Panel activation refreshes the workspace ledger and host list. Connection
61
+ // status is a read-only view of the existing pool: connectedAliases() never
62
+ // dials a server, so opening this panel cannot create SSH connections.
63
+ setWorkspaces(manager.getSnapshot().workspaces)
64
+ void manager.refresh()
65
+ const unsubscribe = manager.subscribe(() => { setWorkspaces(manager.getSnapshot().workspaces) })
66
+ loadHosts()
67
+
68
+ let disposed = false
69
+ const refreshConnections = async (): Promise<void> => {
70
+ if (sshApi === undefined) {
71
+ if (!disposed) setConnectedAliases(new Set())
72
+ return
73
+ }
74
+ try {
75
+ const aliases = await sshApi.connectedAliases()
76
+ if (disposed) return
77
+ setConnectedAliases(new Set(aliases))
78
+ setConnectionError(null)
79
+ } catch (cause: unknown) {
80
+ if (disposed) return
81
+ // Previously an empty `catch` that only blanked the badges: a broken
82
+ // connection-state route looked like "no server is connected".
83
+ setConnectedAliases(null)
84
+ setConnectionError(cause instanceof Error ? cause.message : String(cause))
85
+ }
86
+ }
87
+ void refreshConnections()
88
+ const timer = window.setInterval(() => { void refreshConnections() }, 3000)
89
+ return () => {
90
+ disposed = true
91
+ window.clearInterval(timer)
92
+ unsubscribe()
93
+ }
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [manager, sshApi])
96
+
97
+ const remove = (id: string): void => {
98
+ setDeleting(id)
99
+ void manager.remove(id)
100
+ .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)))
101
+ .finally(() => setDeleting(null))
102
+ }
103
+
104
+ const deleteHost = (alias: string): void => {
105
+ if (sshApi === undefined) return
106
+ setDeletingAlias(alias)
107
+ void sshApi.deleteHost(alias)
108
+ .then(() => { loadHosts() })
109
+ .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)))
110
+ .finally(() => setDeletingAlias(null))
111
+ }
112
+
113
+ const savedHost = (): void => {
114
+ setHostDialog(null)
115
+ loadHosts()
116
+ void manager.refresh()
117
+ }
118
+
119
+ return (
120
+ <div className={css.managerPanel}>
121
+ <div className={css.managerPanelHeader}>
122
+ <h2 className={css.managerPanelTitle}>{tt('manager.title')}</h2>
123
+ </div>
124
+
125
+ {error !== null && <div className={css.dialogFail}>{error}</div>}
126
+
127
+ {connectionError !== null && (
128
+ <div className={css.dialogFail} data-test="connection-state-error">
129
+ {tt('manager.connectionError', connectionError)}
130
+ </div>
131
+ )}
132
+
133
+ {hosts === null && <div className={css.dialogInfo}>{tt('panel.loading')}</div>}
134
+
135
+ {workspaces.length === 0 && hosts !== null && hosts.length === 0 && (
136
+ <div className={css.dialogHint}>{tt('manager.empty')}</div>
137
+ )}
138
+
139
+ {/* Server rows (edit + delete) with their workspaces underneath. */}
140
+ {hosts !== null && hosts.map((host) => {
141
+ const hostWorkspaces = workspaces.filter(workspace => workspace.alias === host.alias)
142
+ const inUse = hostWorkspaces.length > 0
143
+ const connectionState = connectedAliases === null
144
+ ? 'unknown'
145
+ : connectedAliases.has(host.alias) ? 'connected' : 'disconnected'
146
+ const connectionLabel = connectionState === 'connected'
147
+ ? tt('manager.connected')
148
+ : connectionState === 'disconnected' ? tt('manager.disconnected') : tt('manager.connectionUnknown')
149
+ return (
150
+ <div key={host.alias} className={css.managerPanelGroup}>
151
+ <div className={css.managerGroupHeader} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
152
+ <ComputerIcon size={13} />
153
+ <span style={{ fontWeight: 500, fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
154
+ {host.alias}
155
+ </span>
156
+ <span className={css.connectionBadge} data-state={connectionState} title={connectionLabel}>
157
+ <span className={css.connectionDot} aria-hidden="true" />
158
+ {connectionLabel}
159
+ </span>
160
+ <span className={css.hostMeta} style={{ fontSize: 11 }}>{host.user}@{host.host}:{host.port}</span>
161
+ {/* Gear: edit server */}
162
+ <button
163
+ type="button"
164
+ className={css.menuItemIconButton}
165
+ title={tt('manager.editServer')}
166
+ aria-label={tt('manager.editServer')}
167
+ onClick={() => { setHostDialog({ mode: 'edit', host }) }}
168
+ >
169
+
170
+ </button>
171
+ {/* Minus: delete server (disabled while it backs workspaces) */}
172
+ <button
173
+ type="button"
174
+ className={css.menuItemIconButton}
175
+ title={inUse ? tt('manager.hostInUse', hostWorkspaces.length) : tt('manager.deleteServer')}
176
+ aria-label={tt('manager.deleteServer')}
177
+ disabled={inUse || deletingAlias === host.alias}
178
+ onClick={() => { if (!inUse) deleteHost(host.alias) }}
179
+ >
180
+
181
+ </button>
182
+ </div>
183
+ {hostWorkspaces.map((workspace) => (
184
+ <div key={workspace.id} className={css.hostRow} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px' }}>
185
+ <CloudIcon size={12} />
186
+ <span className={css.hostAlias} style={{ fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
187
+ {workspace.title}
188
+ </span>
189
+ <span className={css.hostMeta} style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 220 }}>
190
+ {workspace.remoteRoot}
191
+ </span>
192
+ <button
193
+ type="button"
194
+ className={`${css.button} ${css.danger}`}
195
+ style={{ flex: 'none', padding: '2px 8px', fontSize: 12 }}
196
+ disabled={deleting === workspace.id}
197
+ onClick={() => { if (deleting === null) remove(workspace.id) }}
198
+ >
199
+ {deleting === workspace.id ? '…' : tt('manager.delete')}
200
+ </button>
201
+ </div>
202
+ ))}
203
+ </div>
204
+ )
205
+ })}
206
+
207
+ {/* Bottom: create a new server. */}
208
+ {sshApi !== undefined && (
209
+ <div className={css.managerPanelFooter}>
210
+ <button type="button" className={css.menuItem} onClick={() => { setHostDialog({ mode: 'create' }) }}>
211
+ <span className={css.menuItemIcon}>+</span>
212
+ <span>{tt('manager.newHost')}</span>
213
+ </button>
214
+ </div>
215
+ )}
216
+
217
+ {hostDialog !== null && sshApi !== undefined && (
218
+ <HostFormDialog
219
+ api={sshApi}
220
+ editing={hostDialog.mode === 'edit' ? hostDialog.host : null}
221
+ onClose={() => { setHostDialog(null) }}
222
+ onSaved={savedHost}
223
+ />
224
+ )}
225
+ </div>
226
+ )
227
+ }
@@ -68,7 +68,7 @@
68
68
  border-radius: 6px;
69
69
  background: var(--dsw-alias-bg-base-2, rgba(0, 0, 0, 0.04));
70
70
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
71
- color: #000000;
71
+ color: var(--dsw-alias-label-primary, #0f1115);
72
72
  font-size: 12px;
73
73
  }
74
74
 
@@ -163,7 +163,7 @@
163
163
  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.2));
164
164
  height: 32px;
165
165
  min-width: 111px;
166
- color: #000000;
166
+ color: var(--dsw-alias-label-primary, #0f1115);
167
167
  font-family: var(--dsw-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI',
168
168
  Roboto, system-ui, 'PingFang SC', 'Microsoft YaHei', sans-serif);
169
169
  cursor: pointer;
@@ -255,6 +255,40 @@
255
255
  margin-bottom: 5px;
256
256
  }
257
257
 
258
+ .connectionBadge {
259
+ flex: none;
260
+ display: inline-flex;
261
+ align-items: center;
262
+ gap: 5px;
263
+ padding: 1px 7px;
264
+ color: var(--dsw-alias-label-secondary, #61666b);
265
+ font-size: 11px;
266
+ line-height: 18px;
267
+ white-space: nowrap;
268
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
269
+ border-radius: 999px;
270
+ }
271
+
272
+ .connectionDot {
273
+ width: 7px;
274
+ height: 7px;
275
+ background: var(--dsw-alias-label-tertiary, #81858c);
276
+ border-radius: 50%;
277
+ }
278
+
279
+ .connectionBadge[data-state='connected'] {
280
+ color: var(--dsw-alias-state-success-primary, #18a058);
281
+ border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #18a058) 48%, transparent);
282
+ }
283
+
284
+ .connectionBadge[data-state='connected'] .connectionDot {
285
+ background: var(--dsw-alias-state-success-primary, #18a058);
286
+ }
287
+
288
+ .connectionBadge[data-state='disconnected'] {
289
+ color: var(--dsw-alias-label-tertiary, #81858c);
290
+ }
291
+
258
292
  /* Icon-only round buttons on server rows (edit/delete) */
259
293
  .menuItemIconButton {
260
294
  flex: none;
@@ -281,3 +315,43 @@
281
315
  opacity: 0.4;
282
316
  cursor: default;
283
317
  }
318
+
319
+ /* The workspace manager as a CENTER panel (left sidebar global entry → main
320
+ slot). The center column owns the frame; this only fills it and scrolls. */
321
+ .managerPanel {
322
+ box-sizing: border-box;
323
+ width: 100%;
324
+ height: 100%;
325
+ min-height: 0;
326
+ overflow-y: auto;
327
+ padding: 16px 20px 24px;
328
+ color: var(--dsw-alias-label-primary, #0f1115);
329
+ font-family: var(--dsw-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI',
330
+ Roboto, system-ui, 'PingFang SC', 'Microsoft YaHei', sans-serif);
331
+ font-size: 13px;
332
+ line-height: 20px;
333
+ scrollbar-width: thin;
334
+ scrollbar-color: var(--dsw-alias-label-caption, rgba(128, 128, 128, 0.5)) transparent;
335
+ }
336
+ .managerPanelHeader {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 10px;
340
+ margin-bottom: 12px;
341
+ }
342
+ .managerPanelTitle {
343
+ flex: 1;
344
+ margin: 0;
345
+ font-size: 16px;
346
+ font-weight: 700;
347
+ white-space: nowrap;
348
+ }
349
+ .managerPanelGroup {
350
+ margin-bottom: 14px;
351
+ }
352
+ .managerPanelFooter {
353
+ margin-top: 8px;
354
+ padding-top: 8px;
355
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
356
+ }
357
+