@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- 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')}>×</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
|
-
|
package/src/client/ssh/mount.tsx
DELETED
|
@@ -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
|
-
}
|