@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,269 +0,0 @@
1
- /**
2
- * The SSH workspace manager: a session-header button opening a dropdown that
3
- * lists SSH-bound workspaces grouped by server (two-level: server → its
4
- * workspaces) and lets the operator:
5
- * - delete a workspace (existing),
6
- * - edit / delete a SERVER (gear / minus on each server row; deleting a
7
- * server is allowed only when it backs no workspace — the API also enforces
8
- * this with a 409 HOST_IN_USE),
9
- * - create a new server (bottom action), reusing the host form dialog.
10
- * Rendering is a portal dropdown with its own React root (the shell exposes
11
- * no modal slot for external plugins).
12
- */
13
- import { createRoot } from 'react-dom/client'
14
- import { useEffect, useRef, useState, type ReactElement } from 'react'
15
- import { createPortal } from 'react-dom'
16
- import type { WorkspaceManager } from './state.ts'
17
- import type { SshWorkspaceRecord } from '../protocol.ts'
18
- import { tt } from './text.ts'
19
- import css from './workspace.module.css'
20
- import { CloudIcon, ComputerIcon } from './icons.tsx'
21
- import type { SshHostSummary } from '../ssh/protocol.ts'
22
- import { HostFormDialog } from './ssh/panel/HostFormDialog.tsx'
23
- import type { SshApi } from './ssh/api.ts'
24
-
25
- /** The session-header button. Slot-injected props: manager + sshApi. */
26
- export function WorkspaceManagerButton(props: {
27
- manager: WorkspaceManager
28
- /** Full SSH host API (list/create/update/delete) for server management. */
29
- sshApi?: SshApi
30
- sessionId?: unknown
31
- useSession?: unknown
32
- useSessions?: unknown
33
- useWorkspaces?: unknown
34
- useProjection?: unknown
35
- useInput?: unknown
36
- inputActions?: unknown
37
- renderSlot?: unknown
38
- renderSlotChain?: unknown
39
- t?: unknown
40
- }): ReactElement {
41
- return (
42
- <button
43
- type="button"
44
- className={css.managerButton}
45
- data-ssh-workspace-manager=""
46
- title={tt('manager.tooltip')}
47
- onClick={(event) => openManager(props.manager, props.sshApi, event.currentTarget)}
48
- >
49
- <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
50
- <rect x="2" y="3" width="12" height="10" rx="2" />
51
- <path d="M6 6.5l2.2 1.6L6 9.7" />
52
- <path d="M9.5 9.5h2" />
53
- </svg>
54
- <span>{tt('manager.label')}</span>
55
- </button>
56
- )
57
- }
58
-
59
- /** Open the manager dropdown, anchored under the trigger button. */
60
- export function openManager(manager: WorkspaceManager, sshApi?: SshApi, trigger?: HTMLElement): void {
61
- const element = document.createElement('div')
62
- element.dataset.sshWorkspaceManagerOverlay = ''
63
- document.body.appendChild(element)
64
- const root = createRoot(element)
65
- const close = (): void => {
66
- root.unmount()
67
- element.remove()
68
- }
69
- let anchor: { bottom: number; left: number } | undefined
70
- if (trigger !== undefined) {
71
- const rect = trigger.getBoundingClientRect()
72
- anchor = { bottom: rect.bottom, left: rect.left }
73
- }
74
- root.render(<ManagerOverlay manager={manager} sshApi={sshApi} onClose={close} anchor={anchor} />)
75
- }
76
-
77
- /** Group workspaces by server alias for the two-level listing. */
78
- function groupByServer(workspaces: SshWorkspaceRecord[]): Array<{ alias: string; workspaces: SshWorkspaceRecord[] }> {
79
- const map = new Map<string, SshWorkspaceRecord[]>()
80
- for (const workspace of workspaces) {
81
- const list = map.get(workspace.alias)
82
- if (list === undefined) map.set(workspace.alias, [workspace])
83
- else list.push(workspace)
84
- }
85
- return [...map.entries()].map(([alias, workspaces]) => ({ alias, workspaces }))
86
- }
87
-
88
- /** The host-form dialog invocation state. */
89
- type HostDialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
90
-
91
- /** The manager dropdown: server + workspace rows, with server CRUD. */
92
- export function ManagerOverlay(props: {
93
- manager: WorkspaceManager
94
- sshApi?: SshApi
95
- onClose: () => void
96
- anchor?: { bottom: number; left: number }
97
- }): ReactElement {
98
- const [workspaces, setWorkspaces] = useState<SshWorkspaceRecord[] | null>(null)
99
- const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
100
- const [error, setError] = useState<string | null>(null)
101
- const [deleting, setDeleting] = useState<string | null>(null)
102
- const [deletingAlias, setDeletingAlias] = useState<string | null>(null)
103
- const [hostDialog, setHostDialog] = useState<HostDialogState | null>(null)
104
- const menuRef = useRef<HTMLDivElement | null>(null)
105
-
106
- // Close when a click lands OUTSIDE the dropdown.
107
- useEffect(() => {
108
- const onMouseDown = (event: MouseEvent): void => {
109
- if (menuRef.current !== null && menuRef.current.contains(event.target as Node)) return
110
- props.onClose()
111
- }
112
- document.addEventListener('mousedown', onMouseDown)
113
- return () => document.removeEventListener('mousedown', onMouseDown)
114
- }, [props])
115
-
116
- const load = (): void => {
117
- setWorkspaces(props.manager.getSnapshot().workspaces)
118
- }
119
-
120
- const loadHosts = (): void => {
121
- if (props.sshApi === undefined) return
122
- void props.sshApi.listHosts().then(
123
- (list) => { setHosts(list) },
124
- (cause: unknown) => {
125
- setError(cause instanceof Error ? cause.message : String(cause))
126
- setHosts([])
127
- },
128
- )
129
- }
130
-
131
- useEffect(() => {
132
- load()
133
- void props.manager.refresh()
134
- const unsubscribe = props.manager.subscribe(() => { load() })
135
- loadHosts()
136
- return () => { unsubscribe() }
137
- // eslint-disable-next-line react-hooks/exhaustive-deps
138
- }, [])
139
-
140
- const remove = (id: string): void => {
141
- setDeleting(id)
142
- void props.manager.remove(id)
143
- .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
144
- .finally(() => setDeleting(null))
145
- }
146
-
147
- const deleteHost = (alias: string): void => {
148
- if (props.sshApi === undefined) return
149
- setDeletingAlias(alias)
150
- void props.sshApi.deleteHost(alias)
151
- .then(() => { loadHosts() })
152
- .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
153
- .finally(() => setDeletingAlias(null))
154
- }
155
-
156
- const savedHost = (): void => {
157
- setHostDialog(null)
158
- loadHosts()
159
- void props.manager.refresh()
160
- }
161
-
162
- const anchorTop = props.anchor !== undefined ? props.anchor.bottom + 4 : 56
163
- const anchorLeft = props.anchor !== undefined ? props.anchor.left : Math.max(8, window.innerWidth - 380)
164
- const top = Math.max(8, Math.min(anchorTop, window.innerHeight - 340))
165
- const left = Math.max(8, Math.min(anchorLeft, window.innerWidth - 380))
166
-
167
-
168
- return createPortal(
169
- <div
170
- ref={menuRef}
171
- className={css.menuDropdown}
172
- data-ssh-workspace-manager-menu=""
173
- style={{ top, left, width: 380 }}
174
- onClick={(event) => event.stopPropagation()}
175
- >
176
- <p className={css.managerMenuHeader}>
177
- {tt('manager.title')}
178
- <button type="button" className={css.managerMenuClose} onClick={props.onClose} aria-label={tt('create.cancel')}>&times;</button>
179
- </p>
180
-
181
- {error !== null && <div className={css.dialogFail}>{error}</div>}
182
-
183
- {workspaces === null && hosts === null && <div className={css.dialogInfo}>{tt('panel.loading')}</div>}
184
-
185
- {workspaces !== null && workspaces.length === 0 && (
186
- <div className={css.dialogHint}>{tt('manager.empty')}</div>
187
- )}
188
-
189
- {/* Server rows (edit + delete) with their workspaces underneath */}
190
- {hosts !== null && hosts.map((host) => {
191
- const hostWorkspaces = workspaces === null ? [] : workspaces.filter(w => w.alias === host.alias)
192
- const inUse = hostWorkspaces.length > 0
193
- return (
194
- <div key={host.alias} style={{ marginBottom: 10 }}>
195
- <div className={css.managerGroupHeader} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
196
- <ComputerIcon size={13} />
197
- <span style={{ fontWeight: 500, fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
198
- {host.alias}
199
- </span>
200
- <span className={css.hostMeta} style={{ fontSize: 11 }}>{host.user}@{host.host}:{host.port}</span>
201
- {/* Gear: edit server */}
202
- <button
203
- type="button"
204
- className={css.menuItemIconButton}
205
- title={tt('manager.editServer')}
206
- aria-label={tt('manager.editServer')}
207
- onClick={() => { setHostDialog({ mode: 'edit', host }) }}
208
- >
209
-
210
- </button>
211
- {/* Minus: delete server (disabled while it backs workspaces) */}
212
- <button
213
- type="button"
214
- className={css.menuItemIconButton}
215
- title={inUse ? tt('manager.hostInUse', hostWorkspaces.length) : tt('manager.deleteServer')}
216
- aria-label={tt('manager.deleteServer')}
217
- disabled={inUse || deletingAlias === host.alias}
218
- onClick={() => { if (!inUse) deleteHost(host.alias) }}
219
- >
220
-
221
- </button>
222
- </div>
223
- {hostWorkspaces.map((workspace) => (
224
- <div key={workspace.id} className={css.hostRow} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px' }}>
225
- <CloudIcon size={12} />
226
- <span className={css.hostAlias} style={{ fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>
227
- {workspace.title}
228
- </span>
229
- <span className={css.hostMeta} style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 150 }}>
230
- {workspace.remoteRoot}
231
- </span>
232
- <button
233
- type="button"
234
- className={`${css.button} ${css.danger}`}
235
- style={{ flex: 'none', padding: '2px 8px', fontSize: 12 }}
236
- disabled={deleting === workspace.id}
237
- onClick={() => { if (!deleting) remove(workspace.id) }}
238
- >
239
- {deleting === workspace.id ? '…' : tt('manager.delete')}
240
- </button>
241
- </div>
242
- ))}
243
- </div>
244
- )
245
- })}
246
-
247
- {/* Bottom: create a new server */}
248
- {props.sshApi !== undefined && (
249
- <div style={{ marginTop: 4, borderTop: `1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.1))`, paddingTop: 6 }}>
250
- <button type="button" className={css.menuItem} onClick={() => { setHostDialog({ mode: 'create' }) }}>
251
- <span className={css.menuItemIcon}>+</span>
252
- <span>{tt('manager.newHost')}</span>
253
- </button>
254
- </div>
255
- )}
256
-
257
- {hostDialog !== null && props.sshApi !== undefined && (
258
- <HostFormDialog
259
- api={props.sshApi}
260
- editing={hostDialog.mode === 'edit' ? hostDialog.host : null}
261
- onClose={() => { setHostDialog(null) }}
262
- onSaved={savedHost}
263
- />
264
- )}
265
- </div>,
266
- document.body,
267
- )
268
- }
269
-
@@ -1,83 +0,0 @@
1
- /**
2
- * Panel view mounting.
3
- *
4
- * The `conversation` slot is single-occupant (ui-conversation) and external
5
- * plugins cannot declare slots, so the panel takes over the center column at
6
- * the DOM level: a container is appended inside the `[data-pane="conversation"]`
7
- * grid item (an extra trailing child React never manages), and a stylesheet
8
- * rule hides the conversation content while the panel is active. Toggling is
9
- * a data attribute on <html> — no React involvement, so the conversation
10
- * subtree underneath stays mounted and stateful.
11
- */
12
- import { createRoot, type Root } from 'react-dom/client'
13
- import type { SshApi } from './api.ts'
14
- import type { PanelController } from './panel/controller.ts'
15
- import { SshPanel } from './panel/SshPanel.tsx'
16
- import css from './panel/panel.module.css'
17
-
18
- /** The injected panel container (kept in the DOM, hidden when inactive). */
19
- export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
20
-
21
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
22
- const ACTIVE_ATTR = 'data-dsh-ssh-active'
23
-
24
- /** Find the center column, or undefined while the frame is not mounted. */
25
- function conversationColumn(): HTMLElement | undefined {
26
- return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
27
- }
28
-
29
- /**
30
- * Mount the panel React tree into the center column and bind its visibility
31
- * to the controller's panelOpen state.
32
- * @param controller - the panel controller driving the view.
33
- * @param api - the SSH API client the tabs operate through.
34
- * @returns disposer unmounting the tree and restoring the column.
35
- */
36
- export function mountPanel(controller: PanelController, api: SshApi): () => void {
37
- let root: Root | undefined
38
- let container: HTMLDivElement | undefined
39
-
40
- const ensure = (): void => {
41
- if (container !== undefined) {
42
- if (container.isConnected) return
43
- // The conversation pane was replaced; drop the stale tree and remount.
44
- root?.unmount()
45
- root = undefined
46
- container.remove()
47
- container = undefined
48
- }
49
- const column = conversationColumn()
50
- if (column === undefined) return
51
- container = document.createElement('div')
52
- container.dataset.dshSshView = ''
53
- container.className = css.view
54
- column.appendChild(container)
55
- root = createRoot(container)
56
- root.render(<SshPanel controller={controller} api={api} />)
57
- }
58
-
59
- // The frame mounts after boot settlement; watch for the column's arrival.
60
- const waitObserver = new MutationObserver(() => { ensure() })
61
- waitObserver.observe(document.body, { childList: true, subtree: true })
62
-
63
- const applyActive = (): void => {
64
- if (controller.getSnapshot().panelOpen) {
65
- document.documentElement.setAttribute(ACTIVE_ATTR, '')
66
- } else {
67
- document.documentElement.removeAttribute(ACTIVE_ATTR)
68
- }
69
- }
70
- const unsubscribe = controller.subscribe(applyActive)
71
- applyActive()
72
- ensure()
73
-
74
- return () => {
75
- waitObserver.disconnect()
76
- unsubscribe()
77
- document.documentElement.removeAttribute(ACTIVE_ATTR)
78
- root?.unmount()
79
- root = undefined
80
- container?.remove()
81
- container = undefined
82
- }
83
- }
@@ -1,236 +0,0 @@
1
- /**
2
- * Hosts tab: the host table with search (debounced through listHosts),
3
- * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
4
- * that hands the alias to the terminal tab via onConnect.
5
- */
6
- import { useCallback, useEffect, useRef, useState } from 'react'
7
- import type { SshApi } from '../api.ts'
8
- import type { SshHostSummary, TestResult } from '../../../ssh/protocol.ts'
9
- import { errorMessage, tt } from './helpers.ts'
10
- import { HostFormDialog } from './HostFormDialog.tsx'
11
- import { HostFingerprintDialog } from './HostFingerprintDialog.tsx'
12
- import { SessionSecretDialog } from './SessionSecretDialog.tsx'
13
- import { SshApiError } from '../api.ts'
14
- import css from './panel.module.css'
15
-
16
- /** Hosts tab props. */
17
- export interface HostsTabProps {
18
- api: SshApi
19
- /** Connect the given alias in the terminal tab. */
20
- onConnect: (alias: string) => void
21
- }
22
-
23
- /** The host-form dialog invocation. */
24
- type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
25
-
26
- /** A pending host-key confirmation. */
27
- interface HostKeyConfirmState {
28
- alias: string
29
- fingerprintSha256: string
30
- mismatch: boolean
31
- }
32
-
33
- /** A pending session-secret prompt (VSCode Remote-SSH style). */
34
- interface SessionSecretState {
35
- alias: string
36
- secret: 'password' | 'passphrase'
37
- user?: string
38
- }
39
-
40
- /** The hosts table plus its toolbar and dialogs. */
41
- export function HostsTab({ api, onConnect }: HostsTabProps) {
42
- const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
43
- const [error, setError] = useState<string | null>(null)
44
- const [search, setSearch] = useState('')
45
- const [testingAlias, setTestingAlias] = useState<string | null>(null)
46
- const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
47
- const [importing, setImporting] = useState(false)
48
- const [notice, setNotice] = useState<string | null>(null)
49
- const [dialog, setDialog] = useState<DialogState | null>(null)
50
- const [hostKeyConfirm, setHostKeyConfirm] = useState<HostKeyConfirmState | null>(null)
51
- const [secretConfirm, setSecretConfirm] = useState<SessionSecretState | null>(null)
52
- const seqRef = useRef(0)
53
-
54
- const load = useCallback(async (query?: string): Promise<void> => {
55
- const seq = ++seqRef.current
56
- try {
57
- const list = await api.listHosts(query)
58
- if (seq !== seqRef.current) return
59
- setHosts(list)
60
- setError(null)
61
- } catch (cause) {
62
- if (seq !== seqRef.current) return
63
- setError(errorMessage(cause))
64
- }
65
- }, [api])
66
-
67
- useEffect(() => { void load() }, [load])
68
-
69
- // Debounced search: every keystroke re-filters through the API.
70
- useEffect(() => {
71
- const timer = setTimeout(() => {
72
- const query = search.trim()
73
- void load(query === '' ? undefined : query)
74
- }, 300)
75
- return () => { clearTimeout(timer) }
76
- }, [search, load])
77
-
78
- const runTest = async (alias: string): Promise<void> => {
79
- setTestingAlias(alias)
80
- try {
81
- const result = await api.testHost(alias)
82
- if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
83
- // VSCode Remote-SSH style: prompt once per session, then retry.
84
- setSecretConfirm({
85
- alias,
86
- secret: result.secret,
87
- user: hosts?.find(host => host.alias === alias)?.user,
88
- })
89
- setTestingAlias(null)
90
- return
91
- }
92
- setTestResults(prev => ({ ...prev, [alias]: result }))
93
- } catch (cause) {
94
- if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
95
- setHostKeyConfirm({
96
- alias,
97
- fingerprintSha256: cause.hostKeyFingerprint ?? '',
98
- mismatch: cause.code === 'HOST_KEY_MISMATCH',
99
- })
100
- setTestingAlias(null)
101
- return
102
- }
103
- setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
104
- } finally {
105
- setTestingAlias(null)
106
- }
107
- }
108
-
109
- /** After the fingerprint is confirmed (or reset), retry the test. */
110
- const retryAfterFingerprint = async (alias: string): Promise<void> => {
111
- setHostKeyConfirm(null)
112
- await runTest(alias)
113
- }
114
-
115
- /** After the session secret is provided, retry the test. */
116
- const retryAfterSecret = async (alias: string): Promise<void> => {
117
- setSecretConfirm(null)
118
- await runTest(alias)
119
- }
120
-
121
- const deleteHost = async (alias: string): Promise<void> => {
122
- if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
123
- try {
124
- await api.deleteHost(alias)
125
- void load()
126
- } catch (cause) {
127
- setError(errorMessage(cause))
128
- }
129
- }
130
-
131
- const importConfig = async (): Promise<void> => {
132
- setImporting(true)
133
- try {
134
- const result = await api.importSshConfig()
135
- setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
136
- void load()
137
- } catch (cause) {
138
- setError(errorMessage(cause))
139
- } finally {
140
- setImporting(false)
141
- }
142
- }
143
-
144
- return (
145
- <div className={css.fillBody}>
146
- <div className={css.toolbar}>
147
- <input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
148
- <div className={css.toolbarSpacer} />
149
- <button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
150
- <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
151
- </div>
152
- {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
153
- {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
154
- {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
155
- {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
156
- {hosts !== null && hosts.length > 0 && (
157
- <div className={css.tableWrap}>
158
- <table className={css.table}>
159
- <thead>
160
- <tr>
161
- <th>{tt('hosts.col.alias')}</th>
162
- <th>{tt('hosts.col.host')}</th>
163
- <th>{tt('hosts.col.user')}</th>
164
- <th>{tt('hosts.col.auth')}</th>
165
- <th>{tt('hosts.col.environment')}</th>
166
- <th>{tt('hosts.col.tags')}</th>
167
- <th>{tt('hosts.col.description')}</th>
168
- <th>{tt('hosts.col.actions')}</th>
169
- </tr>
170
- </thead>
171
- <tbody>
172
- {hosts.map(host => {
173
- const test = testResults[host.alias]
174
- return (
175
- <tr key={host.alias}>
176
- <td className={css.mono}>{host.alias}</td>
177
- <td className={css.mono}>{host.host}:{host.port}</td>
178
- <td>{host.user}</td>
179
- <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
180
- <td className={css.cellMuted}>{host.environment ?? ''}</td>
181
- <td className={css.cellMuted}>{host.tags.join(', ')}</td>
182
- <td className={css.cellMuted}>{host.description ?? ''}</td>
183
- <td>
184
- <div className={css.actions}>
185
- <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
186
- {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
187
- </button>
188
- {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
189
- {test !== undefined && (
190
- <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
191
- {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
192
- </span>
193
- )}
194
- <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
195
- <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
196
- <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
197
- </div>
198
- </td>
199
- </tr>
200
- )
201
- })}
202
- </tbody>
203
- </table>
204
- </div>
205
- )}
206
- {dialog !== null && (
207
- <HostFormDialog
208
- api={api}
209
- editing={dialog.mode === 'edit' ? dialog.host : null}
210
- onClose={() => { setDialog(null) }}
211
- onSaved={() => { setDialog(null); void load() }}
212
- />
213
- )}
214
- {hostKeyConfirm !== null && (
215
- <HostFingerprintDialog
216
- api={api}
217
- alias={hostKeyConfirm.alias}
218
- fingerprintSha256={hostKeyConfirm.fingerprintSha256}
219
- mismatch={hostKeyConfirm.mismatch}
220
- onClose={() => { setHostKeyConfirm(null) }}
221
- onTrusted={() => { void retryAfterFingerprint(hostKeyConfirm.alias) }}
222
- />
223
- )}
224
- {secretConfirm !== null && (
225
- <SessionSecretDialog
226
- api={api}
227
- alias={secretConfirm.alias}
228
- user={secretConfirm.user}
229
- secret={secretConfirm.secret}
230
- onClose={() => { setSecretConfirm(null) }}
231
- onProvided={() => { void retryAfterSecret(secretConfirm.alias) }}
232
- />
233
- )}
234
- </div>
235
- )
236
- }
@@ -1,48 +0,0 @@
1
- /**
2
- * SSH panel controller: the single owner of the panel's open/closed state.
3
- *
4
- * Framework-free (structural runtime faces, task-board core/controller.ts
5
- * style) so the DOM mounts and the React panel share one tiny subscription
6
- * surface. The state lives only for the browser session (no persistence).
7
- */
8
-
9
- /** Immutable controller snapshot for UI subscriptions. */
10
- export interface PanelControllerSnapshot {
11
- panelOpen: boolean
12
- }
13
-
14
- /** The panel state owner the sidebar entry toggles and the view renders from. */
15
- export class PanelController {
16
- private panelOpen = false
17
- private listeners = new Set<() => void>()
18
-
19
- getSnapshot(): PanelControllerSnapshot {
20
- return { panelOpen: this.panelOpen }
21
- }
22
-
23
- subscribe(fn: () => void): () => void {
24
- this.listeners.add(fn)
25
- return () => { this.listeners.delete(fn) }
26
- }
27
-
28
- open(): void {
29
- if (this.panelOpen) return
30
- this.panelOpen = true
31
- this.notify()
32
- }
33
-
34
- close(): void {
35
- if (!this.panelOpen) return
36
- this.panelOpen = false
37
- this.notify()
38
- }
39
-
40
- toggle(): void {
41
- if (this.panelOpen) this.close()
42
- else this.open()
43
- }
44
-
45
- private notify(): void {
46
- for (const fn of [...this.listeners]) fn()
47
- }
48
- }