@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.
- 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 +1171 -1321
- 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 +58 -25
- 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 -96
- 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 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/client/index.ts
CHANGED
|
@@ -22,12 +22,14 @@ import { SshApi } from './ssh/api.ts'
|
|
|
22
22
|
import { NS, dictionaries, type WorkspaceKey } from './locales.ts'
|
|
23
23
|
import { WorkspaceManager } from './state.ts'
|
|
24
24
|
import { setLanguage } from './text.ts'
|
|
25
|
-
import {
|
|
25
|
+
import { registerWorkspacePanel } from './workspace-panel-entry.tsx'
|
|
26
26
|
import { migrateLegacySessionMemory } from './migrate.ts'
|
|
27
27
|
import { DirectoryFlow } from './directory-flow.tsx'
|
|
28
|
+
import { connectHost } from './connect-host.ts'
|
|
28
29
|
import { mountWorkspaceBadges } from './workspace-badges.ts'
|
|
29
|
-
import {
|
|
30
|
+
import { makeAnchorAliasResolver, mountSessionConnectGate, type SessionGateList } from './session-connect-gate.ts'
|
|
30
31
|
import { mountSshOperations } from './ssh/apply.ts'
|
|
32
|
+
import { createSessionSshTargetSource } from './ssh/session-target.ts'
|
|
31
33
|
|
|
32
34
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
33
35
|
interface LocaleNamespaceMap {
|
|
@@ -37,9 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
// Type-only re-exports of the SSH operations surfaces (merge phase 2).
|
|
40
|
-
export type { PanelControllerSnapshot } from './ssh/panel/controller.ts'
|
|
41
42
|
export type { SshPanelProps } from './ssh/panel/SshPanel.tsx'
|
|
42
|
-
export type { HostsTabProps } from './ssh/panel/HostsTab.tsx'
|
|
43
43
|
export type { HostFormDialogProps } from './ssh/panel/HostFormDialog.tsx'
|
|
44
44
|
export type { TerminalTabProps } from './ssh/panel/TerminalTab.tsx'
|
|
45
45
|
export type { TransferTabProps } from './ssh/panel/TransferTab.tsx'
|
|
@@ -47,32 +47,47 @@ export type { TunnelsTabProps } from './ssh/panel/TunnelsTab.tsx'
|
|
|
47
47
|
export type { ClusterTabProps } from './ssh/panel/ClusterTab.tsx'
|
|
48
48
|
export type { SshKey } from './ssh/locales.ts'
|
|
49
49
|
|
|
50
|
-
/** Required services: slots for the
|
|
51
|
-
*
|
|
52
|
-
|
|
50
|
+
/** Required services: slots for the extension seats, locale for copy,
|
|
51
|
+
* workspaces for the native local-directory chooser, sessions for the
|
|
52
|
+
* open/new-session connection gate, and sidebarRightTabs for the SSH
|
|
53
|
+
* operations console type. Keep the registry explicit: without it Cordis may
|
|
54
|
+
* activate this client before ui-sidebar-right provides the service; the
|
|
55
|
+
* guarded registration then degrades silently and no HardSSH tab exists. */
|
|
56
|
+
export const inject = ['slots', 'locale', 'workspaces', 'sessions', 'sidebarRightTabs']
|
|
53
57
|
|
|
54
58
|
/** Apply the browser half. */
|
|
55
59
|
export function apply(ctx: ClientContext): void {
|
|
56
|
-
// SSH operations surfaces (sidebar entry + operations panel), merged from
|
|
57
|
-
// the legacy dsh-ssh client. Mounted first; its failures degrade the SSH
|
|
58
|
-
// panel only, never the GUI.
|
|
59
|
-
mountSshOperations(ctx)
|
|
60
|
-
|
|
61
|
-
ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-hardssh: dictionaries')
|
|
62
|
-
|
|
63
60
|
const api = new WorkspaceApi()
|
|
64
61
|
const hostsApi = new SshHostsApi()
|
|
65
62
|
const sshApi = new SshApi()
|
|
66
63
|
const manager = new WorkspaceManager(api)
|
|
64
|
+
const sessionList = ctx.sessions.list
|
|
65
|
+
const sessions: SessionGateList = {
|
|
66
|
+
subscribe: (listener) => sessionList.subscribe(listener),
|
|
67
|
+
phase: () => sessionList.getSnapshot().phase,
|
|
68
|
+
currentSessionId: () => sessionList.getSnapshot().current,
|
|
69
|
+
sessionIds: () => sessionList.getSnapshot().ids,
|
|
70
|
+
sessionCwd: (id) => {
|
|
71
|
+
const byId = sessionList.getSnapshot().byId as unknown as
|
|
72
|
+
Record<string, { cwd?: string } | undefined>
|
|
73
|
+
return byId[id]?.cwd
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The operations console inherits its ONLY target from the selected Session's
|
|
78
|
+
// workspace. A local Session resolves to null and the panel renders disabled;
|
|
79
|
+
// individual operation tabs never own a host picker.
|
|
80
|
+
mountSshOperations(ctx, sshApi, createSessionSshTargetSource(sessions, manager))
|
|
81
|
+
|
|
82
|
+
ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-hardssh: dictionaries')
|
|
67
83
|
|
|
68
84
|
const disposers: Array<() => void> = []
|
|
69
85
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})
|
|
86
|
+
// The SSH workspace manager is a GLOBAL surface: a left-sidebar entry row
|
|
87
|
+
// (sidebar.panellist) opening a center panel (main). The shell owns the
|
|
88
|
+
// row, its label and its active highlight; selecting it switches the
|
|
89
|
+
// center column, and selecting the Conversation row switches back.
|
|
90
|
+
registerWorkspacePanel(ctx, { manager, sshApi })
|
|
76
91
|
|
|
77
92
|
// Replace the native-only directory-flow occupant (both holes) with the
|
|
78
93
|
// SSH/local chooser. `pickDirectory` restores the original native local
|
|
@@ -119,14 +134,13 @@ export function apply(ctx: ClientContext): void {
|
|
|
119
134
|
|
|
120
135
|
manager.start()
|
|
121
136
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
137
|
+
// Sidebar row decoration: label every host workspace whose title belongs to
|
|
138
|
+
// an SSH-bound workspace with a compact `alias` badge, tinted by whether the
|
|
139
|
+
// server currently holds a pooled connection. The shell still exposes no
|
|
140
|
+
// row-decoration slot, so this stays the documented DOM extension it always
|
|
141
|
+
// was (MutationObserver self-heal), unlike the session list below.
|
|
127
142
|
let badgesDispose: (() => void) | undefined
|
|
128
|
-
|
|
129
|
-
let connectedAliases = new Set<string>()
|
|
143
|
+
let connectedAliases: ReadonlySet<string> = new Set<string>()
|
|
130
144
|
const refreshBadges = (): void => {
|
|
131
145
|
try {
|
|
132
146
|
const workspaces = manager.getSnapshot().workspaces
|
|
@@ -142,20 +156,18 @@ export function apply(ctx: ClientContext): void {
|
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
disposers.push(() => { badgesDispose?.() })
|
|
145
|
-
// Same-source sync: no independent fetch
|
|
146
|
-
// its 3s poll and on create/remove/rename
|
|
147
|
-
// manager's initial refresh resolves (start() runs before this subscribe
|
|
148
|
-
// is registered, and its first emit happens after the synchronous body).
|
|
159
|
+
// Same-source sync: no independent workspace fetch — the manager emits on
|
|
160
|
+
// its 3s poll and on create/remove/rename.
|
|
149
161
|
disposers.push(manager.subscribe(() => { refreshBadges() }))
|
|
150
162
|
|
|
151
|
-
// Connection-state poll:
|
|
152
|
-
//
|
|
163
|
+
// Connection-state poll: badge colors AND the gate's pass cache both key off
|
|
164
|
+
// "which servers have a live pooled transport right now".
|
|
153
165
|
const refreshConnections = async (): Promise<void> => {
|
|
154
166
|
try {
|
|
155
167
|
connectedAliases = new Set(await sshApi.connectedAliases())
|
|
156
168
|
} catch (error) {
|
|
157
169
|
console.warn('[dsh-hardssh] connection-state poll failed:', error)
|
|
158
|
-
connectedAliases = new Set()
|
|
170
|
+
connectedAliases = new Set<string>()
|
|
159
171
|
}
|
|
160
172
|
refreshBadges()
|
|
161
173
|
}
|
|
@@ -163,25 +175,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
163
175
|
disposers.push(() => { window.clearInterval(connTimer) })
|
|
164
176
|
void refreshConnections()
|
|
165
177
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
title: workspace.title,
|
|
177
|
-
alias: workspace.alias,
|
|
178
|
-
})), sshApi)
|
|
179
|
-
} catch (error) {
|
|
180
|
-
console.warn('[dsh-hardssh] gate refresh failed:', error)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
disposers.push(() => { gateDispose?.() })
|
|
184
|
-
disposers.push(manager.subscribe(() => { refreshGates() }))
|
|
178
|
+
// Session-open connection gate: opening a session (or creating a New
|
|
179
|
+
// Session) inside an SSH workspace probes the owning server first, so the
|
|
180
|
+
// fingerprint / session-password dialogs appear before any remote operation
|
|
181
|
+
// fails. Driven by the public session list — not by DOM rows — so it
|
|
182
|
+
// survives any sidebar restyle.
|
|
183
|
+
disposers.push(mountSessionConnectGate({
|
|
184
|
+
sessions,
|
|
185
|
+
aliasForCwd: makeAnchorAliasResolver(() => manager.getSnapshot().workspaces),
|
|
186
|
+
ensureConnected: (alias) => connectHost(sshApi, alias),
|
|
187
|
+
}))
|
|
185
188
|
} catch (error) {
|
|
186
189
|
console.warn('[dsh-hardssh] mount failed:', error)
|
|
187
190
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -13,7 +13,14 @@ export const zh = {
|
|
|
13
13
|
'manager.label': 'SSH 工作区',
|
|
14
14
|
'manager.tooltip': '管理 SSH 工作区:绑定服务器目录,绑定后的会话在远程操作,其余会话在本机',
|
|
15
15
|
'manager.title': 'SSH 工作区管理',
|
|
16
|
+
'panel.workspaces': 'SSH 工作区',
|
|
17
|
+
'ops.tab': 'SSH',
|
|
18
|
+
'ops.guide': '打开 SSH 运维面板:终端、传输、隧道、集群',
|
|
16
19
|
'manager.empty': '还没有 SSH 工作区',
|
|
20
|
+
'manager.connected': '已连接',
|
|
21
|
+
'manager.disconnected': '未连接',
|
|
22
|
+
'manager.connectionUnknown': '状态未知',
|
|
23
|
+
'manager.connectionError': '无法读取服务器连接状态:%s',
|
|
17
24
|
'manager.delete': '删除',
|
|
18
25
|
'manager.editServer': '编辑服务器',
|
|
19
26
|
'manager.deleteServer': '删除服务器',
|
|
@@ -61,7 +68,14 @@ export const en: Record<WorkspaceKey, string> = {
|
|
|
61
68
|
'manager.label': 'SSH workspaces',
|
|
62
69
|
'manager.tooltip': 'Manage SSH workspaces: bind a server directory — sessions in a bound workspace operate remotely, all others stay local',
|
|
63
70
|
'manager.title': 'SSH Workspaces',
|
|
71
|
+
'panel.workspaces': 'SSH workspaces',
|
|
72
|
+
'ops.tab': 'SSH',
|
|
73
|
+
'ops.guide': 'Open the SSH operations panel: terminal, transfer, tunnels, cluster',
|
|
64
74
|
'manager.empty': 'No SSH workspaces yet',
|
|
75
|
+
'manager.connected': 'Connected',
|
|
76
|
+
'manager.disconnected': 'Disconnected',
|
|
77
|
+
'manager.connectionUnknown': 'Status unknown',
|
|
78
|
+
'manager.connectionError': 'Could not read server connection state: %s',
|
|
65
79
|
'manager.delete': 'Delete',
|
|
66
80
|
'manager.editServer': 'Edit server',
|
|
67
81
|
'manager.deleteServer': 'Delete server',
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-open connection gate: whenever the shell opens a session whose cwd
|
|
3
|
+
* belongs to an SSH-bound workspace, probe the owning server FIRST so the
|
|
4
|
+
* interactive connection dialogs appear (host-key TOFU confirm / VSCode-style
|
|
5
|
+
* session password) instead of the workspace silently failing underneath.
|
|
6
|
+
*
|
|
7
|
+
* This replaces the old sidebar row-click gate. The shell exposes no row slot
|
|
8
|
+
* for workspace rows, and the previous `[class*="projectRow"]` DOM listener was
|
|
9
|
+
* never a contract — the session list, by contrast, IS a public client surface
|
|
10
|
+
* (`ctx.sessions.list`), so the trigger is now "a session became current" (open)
|
|
11
|
+
* plus "a session appeared while we were watching" (New Session in an SSH
|
|
12
|
+
* workspace) rather than a click on a DOM node we do not own.
|
|
13
|
+
*
|
|
14
|
+
* Probing is delegated to `connectHost`, which owns the TTL pass cache and the
|
|
15
|
+
* in-flight coalescing: re-selecting the same workspace inside the TTL, or
|
|
16
|
+
* creating several sessions in it, prompts at most once.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-hardssh/client/session-connect-gate
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The narrow slice of `ctx.sessions.list` this gate reads. Structural on
|
|
22
|
+
* purpose: the shell's `SessionId`/`SessionSummary` types stay out of the
|
|
23
|
+
* plugin's public surface, and the adapter at the call site does the one
|
|
24
|
+
* unavoidable cast. */
|
|
25
|
+
export interface SessionGateList {
|
|
26
|
+
subscribe(listener: () => void): () => void
|
|
27
|
+
/** Initial host list arrival; pending means an empty list is not authoritative. */
|
|
28
|
+
phase(): 'pending' | 'ready'
|
|
29
|
+
/** Currently selected session, undefined on the empty state. */
|
|
30
|
+
currentSessionId(): string | undefined
|
|
31
|
+
/** Listed session ids, in host order. */
|
|
32
|
+
sessionIds(): readonly string[]
|
|
33
|
+
/** The session's workspace cwd (the anchor path for SSH workspaces). */
|
|
34
|
+
sessionCwd(sessionId: string): string | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Gate dependencies. */
|
|
38
|
+
export interface SessionConnectGateDeps {
|
|
39
|
+
sessions: SessionGateList
|
|
40
|
+
/** Alias owning the SSH workspace that CONTAINS this cwd, when bound. */
|
|
41
|
+
aliasForCwd(cwd: string | undefined): string | undefined
|
|
42
|
+
/** Probe + prompt; resolves true once the server may be used. */
|
|
43
|
+
ensureConnected(alias: string): Promise<boolean>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalize one path for anchor comparison: unified separators, no trailing
|
|
47
|
+
* separator, and case-folded for Windows-shaped paths (Windows anchors are
|
|
48
|
+
* case-insensitive; POSIX anchors are not).
|
|
49
|
+
*
|
|
50
|
+
* Deliberately a SECOND implementation of `base/ledger.ts`'s
|
|
51
|
+
* `normalizeAnchorPath`: the base module imports `node:fs` for its own
|
|
52
|
+
* persistence and therefore cannot be pulled into the browser bundle. Keep the
|
|
53
|
+
* two in step — the host-side duplicates were consolidated for exactly this
|
|
54
|
+
* reason, and this one is the documented exception. */
|
|
55
|
+
function normalizeAnchor(path: string): string {
|
|
56
|
+
const unified = path.replace(/\\/gu, '/').replace(/\/+$/u, '')
|
|
57
|
+
if (/^[a-zA-Z]:\//u.test(unified) || unified.startsWith('//')) return unified.toLowerCase()
|
|
58
|
+
return unified
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True when `cwd` is the anchor itself or sits inside it. */
|
|
62
|
+
function isUnderAnchor(anchor: string, cwd: string): boolean {
|
|
63
|
+
return cwd === anchor || cwd.startsWith(`${anchor}/`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the cwd → workspace resolver over a live workspace list. The LONGEST
|
|
68
|
+
* matching anchor wins, so a workspace nested under another one still routes to
|
|
69
|
+
* its own server.
|
|
70
|
+
* @param workspaces - live records carrying an anchor path.
|
|
71
|
+
* @returns resolver returning the owning record, or undefined when unbound.
|
|
72
|
+
*/
|
|
73
|
+
export function makeAnchorWorkspaceResolver<T extends { anchorPath: string }>(
|
|
74
|
+
workspaces: () => ReadonlyArray<T>,
|
|
75
|
+
): (cwd: string | undefined) => T | undefined {
|
|
76
|
+
return (cwd) => {
|
|
77
|
+
if (cwd === undefined || cwd === '') return undefined
|
|
78
|
+
const target = normalizeAnchor(cwd)
|
|
79
|
+
let best: T | undefined
|
|
80
|
+
let bestLength = -1
|
|
81
|
+
for (const workspace of workspaces()) {
|
|
82
|
+
const anchor = normalizeAnchor(workspace.anchorPath)
|
|
83
|
+
if (anchor === '' || !isUnderAnchor(anchor, target)) continue
|
|
84
|
+
if (anchor.length > bestLength) {
|
|
85
|
+
bestLength = anchor.length
|
|
86
|
+
best = workspace
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return best
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Alias-only compatibility face used by the connection gate. */
|
|
94
|
+
export function makeAnchorAliasResolver(
|
|
95
|
+
workspaces: () => ReadonlyArray<{ alias: string; anchorPath: string }>,
|
|
96
|
+
): (cwd: string | undefined) => string | undefined {
|
|
97
|
+
const resolve = makeAnchorWorkspaceResolver(workspaces)
|
|
98
|
+
return (cwd) => resolve(cwd)?.alias
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Watch the session list and probe the server behind every SSH-bound session
|
|
103
|
+
* the operator opens or creates.
|
|
104
|
+
*
|
|
105
|
+
* The first pass ADOPTS the existing list without probing: a GUI restore must
|
|
106
|
+
* not fire one connection dialog per remembered session. Only the session the
|
|
107
|
+
* shell actually selects (including the restored current one) and sessions
|
|
108
|
+
* created after that pass trigger a probe.
|
|
109
|
+
*
|
|
110
|
+
* @param deps - session list view, anchor resolver and probe.
|
|
111
|
+
* @returns disposer removing the subscription.
|
|
112
|
+
*/
|
|
113
|
+
export function mountSessionConnectGate(deps: SessionConnectGateDeps): () => void {
|
|
114
|
+
const adopted = new Set<string>()
|
|
115
|
+
let primed = false
|
|
116
|
+
let lastCurrent: string | undefined
|
|
117
|
+
|
|
118
|
+
/** Probe the server owning one session's workspace (best-effort). */
|
|
119
|
+
const probe = (sessionId: string | undefined): void => {
|
|
120
|
+
if (sessionId === undefined) return
|
|
121
|
+
const alias = deps.aliasForCwd(deps.sessions.sessionCwd(sessionId))
|
|
122
|
+
if (alias === undefined) return
|
|
123
|
+
void deps.ensureConnected(alias).catch((error: unknown) => {
|
|
124
|
+
console.warn('[dsh-hardssh] session connect gate failed:', error)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pass = (): void => {
|
|
129
|
+
// The session store starts `pending` with an empty list. Do not prime from
|
|
130
|
+
// that placeholder: the first successful host pull would otherwise make
|
|
131
|
+
// every restored historical Session look newly-created and probe every
|
|
132
|
+
// configured server at GUI startup.
|
|
133
|
+
if (!primed && deps.sessions.phase() !== 'ready') return
|
|
134
|
+
|
|
135
|
+
const ids = deps.sessions.sessionIds()
|
|
136
|
+
// One probe per session per pass: a New Session both appears and becomes
|
|
137
|
+
// current in the same snapshot, and probing it twice would open a second
|
|
138
|
+
// dialog path for a single operator action.
|
|
139
|
+
const probed = new Set<string>()
|
|
140
|
+
if (!primed) {
|
|
141
|
+
for (const id of ids) adopted.add(id)
|
|
142
|
+
primed = true
|
|
143
|
+
} else {
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
if (adopted.has(id)) continue
|
|
146
|
+
adopted.add(id)
|
|
147
|
+
// A session created while we watch: the New Session flow in an SSH
|
|
148
|
+
// workspace lands here before its first operation runs.
|
|
149
|
+
probed.add(id)
|
|
150
|
+
probe(id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const current = deps.sessions.currentSessionId()
|
|
154
|
+
if (current !== lastCurrent) {
|
|
155
|
+
lastCurrent = current
|
|
156
|
+
if (current !== undefined && !probed.has(current)) probe(current)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const dispose = deps.sessions.subscribe(pass)
|
|
161
|
+
pass()
|
|
162
|
+
return dispose
|
|
163
|
+
}
|