@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-half entry for the dsh-hardssh plugin — runs inside the dsh
|
|
3
|
+
* web GUI. Registers a session-header utility (SSH workspace manager, left
|
|
4
|
+
* of the session log) that lists SSH-bound workspaces, creates new ones
|
|
5
|
+
* (host + remote directory picker) and deletes them. The execution-world
|
|
6
|
+
* routing itself is HOST-side: a session whose cwd is the anchor path of an
|
|
7
|
+
* SSH workspace routes its fs/subprocess calls remote; every other session
|
|
8
|
+
* stays local. There is no global local⇄remote toggle anymore.
|
|
9
|
+
* Failure policy: every DOM wiring problem is logged, never thrown — the
|
|
10
|
+
* web shell fails the whole boot when a plugin apply throws.
|
|
11
|
+
*/
|
|
12
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
13
|
+
// Type-only: pulls the conversation SlotMap augmentation (the utilities slot).
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
16
|
+
// Type-only: pulls the SlotMap augmentation for the two directory-flow holes.
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
18
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
19
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
20
|
+
import { SshHostsApi, WorkspaceApi } from './api.ts'
|
|
21
|
+
import { SshApi } from './ssh/api.ts'
|
|
22
|
+
import { NS, dictionaries, type WorkspaceKey } from './locales.ts'
|
|
23
|
+
import { WorkspaceManager } from './state.ts'
|
|
24
|
+
import { setLanguage } from './text.ts'
|
|
25
|
+
import { WorkspaceManagerButton } from './manager-button.tsx'
|
|
26
|
+
import { migrateLegacySessionMemory } from './migrate.ts'
|
|
27
|
+
import { DirectoryFlow } from './directory-flow.tsx'
|
|
28
|
+
import { mountWorkspaceBadges } from './workspace-badges.ts'
|
|
29
|
+
import { connectHost, mountWorkspaceGates } from './workspace-gate.ts'
|
|
30
|
+
import { mountSshOperations } from './ssh/apply.ts'
|
|
31
|
+
|
|
32
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
33
|
+
interface LocaleNamespaceMap {
|
|
34
|
+
/** dsh-hardssh surface copy. */
|
|
35
|
+
'dsh-hardssh': WorkspaceKey
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Type-only re-exports of the SSH operations surfaces (merge phase 2).
|
|
40
|
+
export type { PanelControllerSnapshot } from './ssh/panel/controller.ts'
|
|
41
|
+
export type { SshPanelProps } from './ssh/panel/SshPanel.tsx'
|
|
42
|
+
export type { HostsTabProps } from './ssh/panel/HostsTab.tsx'
|
|
43
|
+
export type { HostFormDialogProps } from './ssh/panel/HostFormDialog.tsx'
|
|
44
|
+
export type { TerminalTabProps } from './ssh/panel/TerminalTab.tsx'
|
|
45
|
+
export type { TransferTabProps } from './ssh/panel/TransferTab.tsx'
|
|
46
|
+
export type { TunnelsTabProps } from './ssh/panel/TunnelsTab.tsx'
|
|
47
|
+
export type { ClusterTabProps } from './ssh/panel/ClusterTab.tsx'
|
|
48
|
+
export type { SshKey } from './ssh/locales.ts'
|
|
49
|
+
|
|
50
|
+
/** Required services: slots for the header buttons + directory-flow holes,
|
|
51
|
+
* locale for the copy, workspaces for the native local-directory chooser. */
|
|
52
|
+
export const inject = ['slots', 'locale', 'workspaces']
|
|
53
|
+
|
|
54
|
+
/** Apply the browser half. */
|
|
55
|
+
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
|
+
const api = new WorkspaceApi()
|
|
64
|
+
const hostsApi = new SshHostsApi()
|
|
65
|
+
const sshApi = new SshApi()
|
|
66
|
+
const manager = new WorkspaceManager(api)
|
|
67
|
+
|
|
68
|
+
const disposers: Array<() => void> = []
|
|
69
|
+
try {
|
|
70
|
+
ctx.slots.inject('conversation.session.header.utilities', () => {
|
|
71
|
+
return ctx.slots.register(
|
|
72
|
+
{ name: 'conversation.session.header.utilities', id: 'ssh-workspace-manager', order: -10, inject: () => ({ manager, sshApi }) },
|
|
73
|
+
WorkspaceManagerButton,
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Replace the native-only directory-flow occupant (both holes) with the
|
|
78
|
+
// SSH/local chooser. `pickDirectory` restores the original native local
|
|
79
|
+
// picking for the "Local workspace" branch; the SSH branch drives our own
|
|
80
|
+
// creation dialog and hands the anchor dir to the owner.
|
|
81
|
+
//
|
|
82
|
+
// Mirror the native picker's registration shape exactly: nested
|
|
83
|
+
// `inject(hero) -> inject(sidebar) -> generator`, so the declarations are
|
|
84
|
+
// re-checked each activation and our occupant lands once ui-workspace is
|
|
85
|
+
// live. A negative priority beats the native occupant (priority 0) under
|
|
86
|
+
// the single-slot "lowest wins" rule.
|
|
87
|
+
const flowInject = (): Record<string, unknown> => ({
|
|
88
|
+
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
|
89
|
+
createSshWorkspace: (input: { title: string; alias: string; remoteRoot: string }) => api.createWorkspace(input),
|
|
90
|
+
listHosts: () => api.listHosts(),
|
|
91
|
+
createHost: (input: {
|
|
92
|
+
alias: string
|
|
93
|
+
host: string
|
|
94
|
+
port?: number
|
|
95
|
+
user: string
|
|
96
|
+
auth: { kind: 'password'; password: string } | { kind: 'key'; keyPath: string; passphrase?: string }
|
|
97
|
+
}) => hostsApi.create(input),
|
|
98
|
+
listRemoteDir: (alias: string, path?: string) => api.listRemoteDir(alias, path),
|
|
99
|
+
ensureConnected: (alias: string) => connectHost(sshApi, alias),
|
|
100
|
+
})
|
|
101
|
+
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
|
102
|
+
yield (ctx.slots.register as unknown as (options: {
|
|
103
|
+
name: 'conversation.hero.workspace.directoryFlow'
|
|
104
|
+
priority?: number
|
|
105
|
+
inject: () => Record<string, unknown>
|
|
106
|
+
}, component: unknown) => () => void)(
|
|
107
|
+
{ name: 'conversation.hero.workspace.directoryFlow', priority: -1, inject: flowInject },
|
|
108
|
+
DirectoryFlow,
|
|
109
|
+
)
|
|
110
|
+
yield (ctx.slots.register as unknown as (options: {
|
|
111
|
+
name: 'sidebar.workspaces.directoryFlow'
|
|
112
|
+
priority?: number
|
|
113
|
+
inject: () => Record<string, unknown>
|
|
114
|
+
}, component: unknown) => () => void)(
|
|
115
|
+
{ name: 'sidebar.workspaces.directoryFlow', priority: -1, inject: flowInject },
|
|
116
|
+
DirectoryFlow,
|
|
117
|
+
)
|
|
118
|
+
}))
|
|
119
|
+
|
|
120
|
+
manager.start()
|
|
121
|
+
|
|
122
|
+
// Decorate the sidebar workspace rows: append a remote badge to every
|
|
123
|
+
// host workspace whose title belongs to an SSH-bound workspace. The
|
|
124
|
+
// badge set is driven by the manager (the single owner of the workspace
|
|
125
|
+
// list): every successful poll / mutation emits, and the snapshot is the
|
|
126
|
+
// only data source. Best-effort: a DOM miss only logs.
|
|
127
|
+
let badgesDispose: (() => void) | undefined
|
|
128
|
+
// Live connection state (aliases with a pooled transport) → blue badge.
|
|
129
|
+
let connectedAliases = new Set<string>()
|
|
130
|
+
const refreshBadges = (): void => {
|
|
131
|
+
try {
|
|
132
|
+
const workspaces = manager.getSnapshot().workspaces
|
|
133
|
+
badgesDispose?.()
|
|
134
|
+
badgesDispose = mountWorkspaceBadges(workspaces.map((workspace) => ({
|
|
135
|
+
id: workspace.id,
|
|
136
|
+
title: workspace.title,
|
|
137
|
+
alias: workspace.alias,
|
|
138
|
+
remoteRoot: workspace.remoteRoot,
|
|
139
|
+
})), connectedAliases)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.warn('[dsh-hardssh] badge refresh failed:', error)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
disposers.push(() => { badgesDispose?.() })
|
|
145
|
+
// Same-source sync: no independent fetch or timer — the manager emits on
|
|
146
|
+
// its 3s poll and on create/remove/rename; the first pass lands when the
|
|
147
|
+
// manager's initial refresh resolves (start() runs before this subscribe
|
|
148
|
+
// is registered, and its first emit happens after the synchronous body).
|
|
149
|
+
disposers.push(manager.subscribe(() => { refreshBadges() }))
|
|
150
|
+
|
|
151
|
+
// Connection-state poll: keep badge colors current (same cadence as the
|
|
152
|
+
// manager's workspace poll). Failure keeps everything gray (safe).
|
|
153
|
+
const refreshConnections = async (): Promise<void> => {
|
|
154
|
+
try {
|
|
155
|
+
connectedAliases = new Set(await sshApi.connectedAliases())
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.warn('[dsh-hardssh] connection-state poll failed:', error)
|
|
158
|
+
connectedAliases = new Set()
|
|
159
|
+
}
|
|
160
|
+
refreshBadges()
|
|
161
|
+
}
|
|
162
|
+
const connTimer = window.setInterval(() => { void refreshConnections() }, 3000)
|
|
163
|
+
disposers.push(() => { window.clearInterval(connTimer) })
|
|
164
|
+
void refreshConnections()
|
|
165
|
+
|
|
166
|
+
// Click gate: probing a workspace row must first establish the SSH
|
|
167
|
+
// connection (host-key TOFU confirm + session password dialogs), so the
|
|
168
|
+
// shell never opens a workspace that silently fails underneath.
|
|
169
|
+
let gateDispose: (() => void) | undefined
|
|
170
|
+
const refreshGates = (): void => {
|
|
171
|
+
try {
|
|
172
|
+
const workspaces = manager.getSnapshot().workspaces
|
|
173
|
+
gateDispose?.()
|
|
174
|
+
gateDispose = mountWorkspaceGates(workspaces.map((workspace) => ({
|
|
175
|
+
id: workspace.id,
|
|
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() }))
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.warn('[dsh-hardssh] mount failed:', error)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// One-time migration: the old build tracked a per-session GLOBAL local⇄remote
|
|
190
|
+
// mode in localStorage (`ssh-session-state:<id>`). Convert every remembered
|
|
191
|
+
// remote target into a real SSH-bound workspace record, then forget the
|
|
192
|
+
// legacy state. Runs async and best-effort; a failure only logs.
|
|
193
|
+
void migrateLegacySessionMemory(api).catch((error: unknown) => {
|
|
194
|
+
console.warn('[dsh-hardssh] legacy migration skipped:', error)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Language mirroring (the shell owns <html lang>; the dictionary follows).
|
|
198
|
+
const syncLanguage = (): void => {
|
|
199
|
+
setLanguage(document.documentElement.lang?.startsWith('zh') ?? false)
|
|
200
|
+
}
|
|
201
|
+
const langObserver = new MutationObserver(syncLanguage)
|
|
202
|
+
langObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
|
|
203
|
+
syncLanguage()
|
|
204
|
+
|
|
205
|
+
ctx.effect(() => () => {
|
|
206
|
+
manager.stop()
|
|
207
|
+
langObserver.disconnect()
|
|
208
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
209
|
+
}, 'dsh-hardssh: wiring')
|
|
210
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale dictionaries for the dsh-hardssh surface (zh/en).
|
|
3
|
+
* The key union derives from the zh dictionary (mirrors the dsh-ssh locale
|
|
4
|
+
* pattern), so the LocaleNamespaceMap augmentation types the `t` seat and
|
|
5
|
+
* the register call against exactly the shipped keys.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Locale namespace this plugin owns. */
|
|
9
|
+
export const NS = 'dsh-hardssh'
|
|
10
|
+
|
|
11
|
+
/** The zh dictionary (the key union source). */
|
|
12
|
+
export const zh = {
|
|
13
|
+
'manager.label': 'SSH 工作区',
|
|
14
|
+
'manager.tooltip': '管理 SSH 工作区:绑定服务器目录,绑定后的会话在远程操作,其余会话在本机',
|
|
15
|
+
'manager.title': 'SSH 工作区管理',
|
|
16
|
+
'manager.empty': '还没有 SSH 工作区',
|
|
17
|
+
'manager.delete': '删除',
|
|
18
|
+
'manager.editServer': '编辑服务器',
|
|
19
|
+
'manager.deleteServer': '删除服务器',
|
|
20
|
+
'manager.newHost': '新建服务器',
|
|
21
|
+
'manager.hostInUse': '该服务器仍有 %d 个工作区,请先删除工作区',
|
|
22
|
+
'create.host': '服务器',
|
|
23
|
+
'create.hostEmpty': '未配置 SSH 主机,请先在设置页配置',
|
|
24
|
+
'create.dir': '远程目录',
|
|
25
|
+
'create.dirPlaceholder': '点击「浏览」选择远程目录,或输入绝对路径(如 /data/home/user/project)',
|
|
26
|
+
'create.browse': '浏览…',
|
|
27
|
+
'create.up': '上级',
|
|
28
|
+
'create.current': '当前:',
|
|
29
|
+
'create.titleLabel': '工作区名称',
|
|
30
|
+
'create.titlePlaceholder': '如 zebrafish、atac-pipeline',
|
|
31
|
+
'create.submit': '创建',
|
|
32
|
+
'create.cancel': '取消',
|
|
33
|
+
'create.connecting': '正在连接…',
|
|
34
|
+
'create.needHost': '请先选择服务器',
|
|
35
|
+
'create.needDir': '请选择或输入远程目录',
|
|
36
|
+
'create.needTitle': '请填写工作区名称',
|
|
37
|
+
'create.needConnect': '多次交互后仍未连接成功,请检查服务器或在 SSH 面板中重试',
|
|
38
|
+
'host.add': '添加服务器…',
|
|
39
|
+
'host.save': '保存服务器',
|
|
40
|
+
'host.needFields': '别名、主机、用户名为必填项',
|
|
41
|
+
'host.needPassword': '密码为必填项(当前仅支持密码认证)',
|
|
42
|
+
'dialog.alias': '别名',
|
|
43
|
+
'dialog.host': '主机',
|
|
44
|
+
'dialog.port': '端口',
|
|
45
|
+
'dialog.user': '用户名',
|
|
46
|
+
'dialog.password': '密码',
|
|
47
|
+
'dialog.passwordHint': '可留空——首次连接或浏览目录时再输入',
|
|
48
|
+
'flow.local': '本地工作区…',
|
|
49
|
+
'flow.ssh': 'SSH 工作区…',
|
|
50
|
+
'flow.useThisDir': '使用此目录',
|
|
51
|
+
'panel.loading': '加载中…',
|
|
52
|
+
'gate.failed': '连接 SSH 服务器 %s 失败',
|
|
53
|
+
'gate.retryLimit': '多次交互后仍未连上,请稍后重试或在 SSH 面板检查服务器',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The key union (used by the LocaleNamespaceMap augmentation). */
|
|
57
|
+
export type WorkspaceKey = keyof typeof zh
|
|
58
|
+
|
|
59
|
+
/** The en dictionary (same key set). */
|
|
60
|
+
export const en: Record<WorkspaceKey, string> = {
|
|
61
|
+
'manager.label': 'SSH workspaces',
|
|
62
|
+
'manager.tooltip': 'Manage SSH workspaces: bind a server directory — sessions in a bound workspace operate remotely, all others stay local',
|
|
63
|
+
'manager.title': 'SSH Workspaces',
|
|
64
|
+
'manager.empty': 'No SSH workspaces yet',
|
|
65
|
+
'manager.delete': 'Delete',
|
|
66
|
+
'manager.editServer': 'Edit server',
|
|
67
|
+
'manager.deleteServer': 'Delete server',
|
|
68
|
+
'manager.newHost': 'New server',
|
|
69
|
+
'manager.hostInUse': 'This server still backs %d workspace(s) — delete them first',
|
|
70
|
+
'create.host': 'Server',
|
|
71
|
+
'create.hostEmpty': 'No SSH host configured — add one in Settings first',
|
|
72
|
+
'create.dir': 'Remote directory',
|
|
73
|
+
'create.dirPlaceholder': 'Click Browse to pick a remote directory, or type an absolute path (e.g. /data/home/user/project)',
|
|
74
|
+
'create.browse': 'Browse…',
|
|
75
|
+
'create.up': 'Up',
|
|
76
|
+
'create.current': 'Current: ',
|
|
77
|
+
'create.titleLabel': 'Workspace name',
|
|
78
|
+
'create.titlePlaceholder': 'e.g. zebrafish, atac-pipeline',
|
|
79
|
+
'create.submit': 'Create',
|
|
80
|
+
'create.cancel': 'Cancel',
|
|
81
|
+
'create.connecting': 'Connecting…',
|
|
82
|
+
'create.needHost': 'Pick a server first',
|
|
83
|
+
'create.needDir': 'Pick or type a remote directory',
|
|
84
|
+
'create.needTitle': 'Give the workspace a name',
|
|
85
|
+
'create.needConnect': 'Still not connected after several attempts — check the server or retry in the SSH panel',
|
|
86
|
+
'host.add': 'Add server…',
|
|
87
|
+
'host.save': 'Save server',
|
|
88
|
+
'host.needFields': 'Alias, host and user are required',
|
|
89
|
+
'host.needPassword': 'Password is required (password auth only for now)',
|
|
90
|
+
'dialog.alias': 'Alias',
|
|
91
|
+
'dialog.host': 'Host',
|
|
92
|
+
'dialog.port': 'Port',
|
|
93
|
+
'dialog.user': 'User',
|
|
94
|
+
'dialog.password': 'Password',
|
|
95
|
+
'dialog.passwordHint': 'Optional — enter when first connecting or browsing',
|
|
96
|
+
'flow.local': 'Local workspace…',
|
|
97
|
+
'flow.ssh': 'SSH workspace…',
|
|
98
|
+
'flow.useThisDir': 'Use this directory',
|
|
99
|
+
'panel.loading': 'Loading…',
|
|
100
|
+
'gate.failed': 'Failed to connect SSH server %s',
|
|
101
|
+
'gate.retryLimit': 'Still not connected after several attempts — retry later or check the server in the SSH panel',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The dictionary pair registered into the locale service. */
|
|
105
|
+
export const dictionaries = { zh, en } as const
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
|