@tiphareth/dsh-hardssh 0.1.2-alpha
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 +17794 -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 +142 -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 +99 -0
- package/src/client/workspace-gate.ts +232 -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,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory-flow occupant replacing the native-only "Add workspace…"
|
|
3
|
+
* interaction. When the operator clicks the native "Add workspace…" entry,
|
|
4
|
+
* this component opens a small chooser with two options:
|
|
5
|
+
*
|
|
6
|
+
* - "Local workspace": delegates to the host native directory chooser
|
|
7
|
+
* (`host.pickDirectory`) and hands the picked path back through the owner
|
|
8
|
+
* conversation — exactly the original behavior.
|
|
9
|
+
* - "SSH workspace": opens the SSH workspace creation dialog (pick a host,
|
|
10
|
+
* browse a remote directory, name it). On success it calls
|
|
11
|
+
* `onPicked(anchorPath)` so the host adopts the LOCAL ANCHOR directory as
|
|
12
|
+
* a normal workspace (the seams route that workspace remote by its anchor
|
|
13
|
+
* path). On cancel it calls `onCancel`.
|
|
14
|
+
*
|
|
15
|
+
* Occupancy: both directory-flow holes are `kind: 'single'`, so registering
|
|
16
|
+
* here replaces the native-only occupant. That is intended — we restore the
|
|
17
|
+
* native path ourselves via `pickDirectory`.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-hardssh/client/directory-flow
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
|
23
|
+
import { createPortal } from 'react-dom'
|
|
24
|
+
// Type-only: pulls the SlotMap augmentation (the directory-flow holes) from
|
|
25
|
+
// the ui-workspace package, plus the DirectoryFlowOwnerProps contract.
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
27
|
+
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
|
28
|
+
import type { SshWorkspaceRecord } from '../protocol.ts'
|
|
29
|
+
import { tt } from './text.ts'
|
|
30
|
+
import css from './workspace.module.css'
|
|
31
|
+
import { CloudIcon, ComputerIcon } from './icons.tsx'
|
|
32
|
+
|
|
33
|
+
/** The injected face: host native picker + our workspace manager API. */
|
|
34
|
+
export interface DirectoryFlowInjected {
|
|
35
|
+
/** Ask the local Host to open its native single-directory chooser. */
|
|
36
|
+
pickDirectory: () => Promise<string | null>
|
|
37
|
+
/** Create an SSH workspace (host API). */
|
|
38
|
+
createSshWorkspace: (input: { title: string; alias: string; remoteRoot: string }) => Promise<SshWorkspaceRecord>
|
|
39
|
+
/** List configured SSH hosts (host API). */
|
|
40
|
+
listHosts: () => Promise<Array<{ alias: string; host: string; port: number; user: string }>>
|
|
41
|
+
/** Create a new SSH host (dsh-ssh host store; auth is required). */
|
|
42
|
+
createHost: (input: {
|
|
43
|
+
alias: string
|
|
44
|
+
host: string
|
|
45
|
+
port?: number
|
|
46
|
+
user: string
|
|
47
|
+
auth: { kind: 'password'; password: string } | { kind: 'key'; keyPath: string; passphrase?: string }
|
|
48
|
+
}) => Promise<{ alias: string; host: string; port: number; user: string }>
|
|
49
|
+
/** Browse a remote directory (host API). */
|
|
50
|
+
listRemoteDir: (alias: string, path?: string) => Promise<{ path: string; entries: Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }> }>
|
|
51
|
+
/** Interactive SSH connection gate: host-key TOFU + session-password
|
|
52
|
+
* dialogs. Resolves true when the alias is connected (or the user
|
|
53
|
+
* cancelled the prompts). Used to retry browsing. */
|
|
54
|
+
ensureConnected: (alias: string) => Promise<boolean>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The two entry choices after "Add workspace…". */
|
|
58
|
+
type Choice = 'menu' | 'ssh' | 'browsing' | 'picking-local'
|
|
59
|
+
|
|
60
|
+
/** A resolved anchor rect for the dropdown. */
|
|
61
|
+
interface AnchorRect {
|
|
62
|
+
top: number
|
|
63
|
+
left: number
|
|
64
|
+
bottom: number
|
|
65
|
+
width: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Labels the host "Add workspace…" entry can carry (zh / en). */
|
|
69
|
+
const ADD_WORKSPACE_LABELS = ['添加工作区', 'Add workspace']
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Find the host "Add workspace…" trigger element. It is a button/menu item
|
|
73
|
+
* rendered by ui-workspace; match by visible text (case-insensitive, label
|
|
74
|
+
* may include "…"/suffix). Prefer the LEFT sidebar container
|
|
75
|
+
* (`[data-pane="sidebar"]` or a class mentioning the workspace browser) —
|
|
76
|
+
* the workspace list lives there — and fall back to a whole-document scan so
|
|
77
|
+
* the empty-state hero trigger works too. To avoid anchoring under a
|
|
78
|
+
* RIGHT-side surface (another plugin's panel), the document scan prefers a
|
|
79
|
+
* match whose horizontal center is in the LEFT half of the viewport.
|
|
80
|
+
*/
|
|
81
|
+
function findAddWorkspaceTrigger(): AnchorRect | undefined {
|
|
82
|
+
const matchText = (el: HTMLElement): boolean => {
|
|
83
|
+
const text = el.textContent?.trim() ?? ''
|
|
84
|
+
return ADD_WORKSPACE_LABELS.some((label) => text.includes(label))
|
|
85
|
+
}
|
|
86
|
+
const rectOf = (el: HTMLElement): AnchorRect | undefined => {
|
|
87
|
+
const rect = el.getBoundingClientRect()
|
|
88
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
89
|
+
return { top: rect.top, left: rect.left, bottom: rect.bottom, width: rect.width }
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 1) Left sidebar container preferred.
|
|
95
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-pane="sidebar"], [class*="workspaceBrowser"], [class*="sidebar"]')) {
|
|
96
|
+
for (const el of root.querySelectorAll<HTMLElement>('button, [role="menuitem"], [role="option"], li')) {
|
|
97
|
+
if (matchText(el)) {
|
|
98
|
+
const rect = rectOf(el)
|
|
99
|
+
if (rect !== undefined) return rect
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2) Whole-document fallback; prefer a LEFT-half match.
|
|
105
|
+
let fallback: AnchorRect | undefined
|
|
106
|
+
for (const el of document.querySelectorAll<HTMLElement>('button, [role="menuitem"], [role="option"], li')) {
|
|
107
|
+
if (!matchText(el)) continue
|
|
108
|
+
const rect = rectOf(el)
|
|
109
|
+
if (rect === undefined) continue
|
|
110
|
+
const centerX = rect.left + rect.width / 2
|
|
111
|
+
if (centerX < window.innerWidth / 2) return rect
|
|
112
|
+
if (fallback === undefined) fallback = rect
|
|
113
|
+
}
|
|
114
|
+
return fallback
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The flow occupant. Each rising `open` edge shows the menu; the ref arms
|
|
119
|
+
* once per open so re-renders (and an adoption keeping `open` true while
|
|
120
|
+
* `busy`) never launch a second flow.
|
|
121
|
+
*/
|
|
122
|
+
export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInjected): ReactElement | null {
|
|
123
|
+
const { open, busy, onPicked, onCancel, onError } = props
|
|
124
|
+
const outcome = useRef(props)
|
|
125
|
+
outcome.current = props
|
|
126
|
+
const [choice, setChoice] = useState<Choice>('menu')
|
|
127
|
+
const [host, setHost] = useState('')
|
|
128
|
+
const [dirPath, setDirPath] = useState('')
|
|
129
|
+
const [title, setTitle] = useState('')
|
|
130
|
+
const [hosts, setHosts] = useState<Array<{ alias: string; host: string; port: number; user: string }>>([])
|
|
131
|
+
const [dirEntries, setDirEntries] = useState<Array<{ name: string; type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number }>>([])
|
|
132
|
+
const [loading, setLoading] = useState(false)
|
|
133
|
+
const [creating, setCreating] = useState(false)
|
|
134
|
+
const [error, setError] = useState<string | null>(null)
|
|
135
|
+
const [anchor, setAnchor] = useState<AnchorRect | undefined>(undefined)
|
|
136
|
+
// "Add server…" mini-form inside the SSH branch.
|
|
137
|
+
const [addingHost, setAddingHost] = useState(false)
|
|
138
|
+
const [newHost, setNewHost] = useState({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
139
|
+
const [savingHost, setSavingHost] = useState(false)
|
|
140
|
+
|
|
141
|
+
// Reset and re-locate on each open edge.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!open) return
|
|
144
|
+
setChoice('menu')
|
|
145
|
+
setHost('')
|
|
146
|
+
setDirPath('')
|
|
147
|
+
setTitle('')
|
|
148
|
+
setDirEntries([])
|
|
149
|
+
setError(null)
|
|
150
|
+
setAddingHost(false)
|
|
151
|
+
setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
152
|
+
setAnchorRect()
|
|
153
|
+
// Lazy-load the host list for the SSH branch.
|
|
154
|
+
props.listHosts().then((list) => setHosts(list)).catch(() => setHosts([]))
|
|
155
|
+
}, [open])
|
|
156
|
+
|
|
157
|
+
/** Locate the trigger element that just opened the flow and anchor the menu
|
|
158
|
+
* directly under it. Priority: the focused element (the button the operator
|
|
159
|
+
* just clicked → document.activeElement), then a text-scan for the "Add
|
|
160
|
+
* workspace…" entry. Falls back to top-left of the viewport. */
|
|
161
|
+
const setAnchorRect = (): void => {
|
|
162
|
+
// 1) The element that had focus when `open` flipped true is almost
|
|
163
|
+
// always the trigger button (the owner sets open on click).
|
|
164
|
+
const active = document.activeElement as HTMLElement | null
|
|
165
|
+
if (active !== null && active !== document.body) {
|
|
166
|
+
const rect = active.getBoundingClientRect()
|
|
167
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
168
|
+
setAnchor({ top: rect.top, left: rect.left, bottom: rect.bottom, width: rect.width })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 2) Text-scan fallback.
|
|
173
|
+
const trigger = findAddWorkspaceTrigger()
|
|
174
|
+
if (trigger !== undefined) {
|
|
175
|
+
setAnchor(trigger)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// 3) Last resort: top-left of the viewport.
|
|
179
|
+
setAnchor({ top: 56, left: 16, bottom: 56, width: 200 })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Close when a click lands OUTSIDE the dropdown (no full-screen capture
|
|
183
|
+
// layer, so the trigger button and the rest of the UI stay clickable).
|
|
184
|
+
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!open) return
|
|
187
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
188
|
+
if (menuRef.current !== null && menuRef.current.contains(event.target as Node)) return
|
|
189
|
+
if (!busy) outcome.current.onCancel()
|
|
190
|
+
}
|
|
191
|
+
document.addEventListener('mousedown', onMouseDown)
|
|
192
|
+
return () => document.removeEventListener('mousedown', onMouseDown)
|
|
193
|
+
}, [open, busy])
|
|
194
|
+
|
|
195
|
+
if (!open) return null
|
|
196
|
+
|
|
197
|
+
/** Choose "Local workspace": drive the native chooser once. */
|
|
198
|
+
const pickLocal = (): void => {
|
|
199
|
+
setChoice('picking-local')
|
|
200
|
+
props.pickDirectory().then(
|
|
201
|
+
(path) => {
|
|
202
|
+
if (path === null) {
|
|
203
|
+
outcome.current.onCancel()
|
|
204
|
+
} else {
|
|
205
|
+
outcome.current.onPicked(path)
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
(reason) => outcome.current.onError(reason instanceof Error ? reason.message : String(reason)),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Browse a remote dir level for the SSH branch. When the connection needs
|
|
213
|
+
* a credential (host key untrusted / session password), run the interactive
|
|
214
|
+
* gate first, then retry — the operator never sees a raw "connect" error. */
|
|
215
|
+
const browse = async (alias: string, path?: string): Promise<void> => {
|
|
216
|
+
if (alias === '') {
|
|
217
|
+
setError(tt('create.needHost'))
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
221
|
+
setLoading(true)
|
|
222
|
+
setError(null)
|
|
223
|
+
try {
|
|
224
|
+
const result = await props.listRemoteDir(alias, path)
|
|
225
|
+
setDirPath(result.path)
|
|
226
|
+
setDirEntries(result.entries)
|
|
227
|
+
setChoice('browsing')
|
|
228
|
+
setLoading(false)
|
|
229
|
+
return
|
|
230
|
+
} catch (e: unknown) {
|
|
231
|
+
const code = (e as { code?: string })?.code
|
|
232
|
+
const interactive = code === 'NEEDS_PASSWORD' || code === 'HOST_KEY_UNKNOWN' || code === 'HOST_KEY_MISMATCH'
|
|
233
|
+
setLoading(false)
|
|
234
|
+
if (!interactive) {
|
|
235
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (!await props.ensureConnected(alias)) return // user cancelled
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
setError(tt('create.needConnect'))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Create the SSH workspace; on success hand the anchor to the owner. */
|
|
245
|
+
const createSsh = async (): Promise<void> => {
|
|
246
|
+
if (host === '' || dirPath === '' || title.trim() === '') {
|
|
247
|
+
setError(host === '' ? tt('create.needHost') : dirPath === '' ? tt('create.needDir') : tt('create.needTitle'))
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
setCreating(true)
|
|
251
|
+
setError(null)
|
|
252
|
+
try {
|
|
253
|
+
const record = await props.createSshWorkspace({ title: title.trim(), alias: host, remoteRoot: dirPath })
|
|
254
|
+
// The anchor is a REAL local dir (host workspace registration happens on
|
|
255
|
+
// the server); adopt it so the host workspace list refreshes.
|
|
256
|
+
outcome.current.onPicked(record.anchorPath)
|
|
257
|
+
} catch (e: unknown) {
|
|
258
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
259
|
+
} finally {
|
|
260
|
+
// Always reset: the owner may keep the flow open after onPicked (e.g.
|
|
261
|
+
// a host-side adoption error), and a stuck `creating=true` would lock
|
|
262
|
+
// the button forever.
|
|
263
|
+
setCreating(false)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Save a newly-added SSH host, refresh the list, select it, and close the
|
|
268
|
+
* add-server form. The password is OPTIONAL at creation — adding never
|
|
269
|
+
* connects; the credential is asked when browsing/connecting starts. */
|
|
270
|
+
const saveHost = async (): Promise<void> => {
|
|
271
|
+
if (newHost.alias.trim() === '' || newHost.host.trim() === '' || newHost.user.trim() === '') {
|
|
272
|
+
setError(tt('host.needFields'))
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
setSavingHost(true)
|
|
276
|
+
setError(null)
|
|
277
|
+
try {
|
|
278
|
+
const created = await props.createHost({
|
|
279
|
+
alias: newHost.alias.trim(),
|
|
280
|
+
host: newHost.host.trim(),
|
|
281
|
+
port: Number.parseInt(newHost.port, 10) || 22,
|
|
282
|
+
user: newHost.user.trim(),
|
|
283
|
+
auth: { kind: 'password', password: newHost.password },
|
|
284
|
+
})
|
|
285
|
+
const list = await props.listHosts()
|
|
286
|
+
setHosts(list)
|
|
287
|
+
setHost(created.alias)
|
|
288
|
+
setAddingHost(false)
|
|
289
|
+
setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
290
|
+
} catch (e: unknown) {
|
|
291
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
292
|
+
} finally {
|
|
293
|
+
setSavingHost(false)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Position the dropdown under the trigger, clamped inside the viewport.
|
|
298
|
+
// The "add server" mini-form is taller — give it room so nothing clips.
|
|
299
|
+
const menuWidth = choice === 'menu' ? 200 : 360
|
|
300
|
+
const menuHeight = addingHost ? Math.min(560, window.innerHeight - 16) : 320
|
|
301
|
+
const rawTop = anchor !== undefined ? anchor.bottom + 4 : 56
|
|
302
|
+
const rawLeft = anchor !== undefined ? anchor.left : Math.max(8, window.innerWidth - menuWidth - 8)
|
|
303
|
+
const top = Math.max(8, Math.min(rawTop, window.innerHeight - menuHeight - 8))
|
|
304
|
+
const left = Math.max(8, Math.min(rawLeft, window.innerWidth - menuWidth - 8))
|
|
305
|
+
|
|
306
|
+
return createPortal(
|
|
307
|
+
<div
|
|
308
|
+
ref={menuRef}
|
|
309
|
+
className={css.menuDropdown}
|
|
310
|
+
data-ssh-workspace-flow-menu=""
|
|
311
|
+
role="menu"
|
|
312
|
+
style={{
|
|
313
|
+
top,
|
|
314
|
+
left,
|
|
315
|
+
width: menuWidth,
|
|
316
|
+
maxHeight: menuHeight,
|
|
317
|
+
overflowY: 'auto',
|
|
318
|
+
}}
|
|
319
|
+
onClick={(event) => event.stopPropagation()}
|
|
320
|
+
>
|
|
321
|
+
{error !== null && <div className={css.dialogFail}>{error}</div>}
|
|
322
|
+
|
|
323
|
+
{choice === 'menu' && (
|
|
324
|
+
<>
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
className={css.menuItem}
|
|
328
|
+
role="menuitem"
|
|
329
|
+
disabled={busy}
|
|
330
|
+
onClick={pickLocal}
|
|
331
|
+
>
|
|
332
|
+
<span className={css.menuItemIcon}><ComputerIcon size={14} /></span>
|
|
333
|
+
<span>{tt('flow.local')}</span>
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
className={css.menuItem}
|
|
338
|
+
role="menuitem"
|
|
339
|
+
disabled={busy}
|
|
340
|
+
onClick={() => setChoice('ssh')}
|
|
341
|
+
>
|
|
342
|
+
<span className={css.menuItemIcon}><CloudIcon size={14} /></span>
|
|
343
|
+
<span>{tt('flow.ssh')}</span>
|
|
344
|
+
</button>
|
|
345
|
+
<div className={css.menuDivider} />
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className={css.menuItem}
|
|
349
|
+
role="menuitem"
|
|
350
|
+
onClick={() => outcome.current.onCancel()}
|
|
351
|
+
>
|
|
352
|
+
<span>{tt('create.cancel')}</span>
|
|
353
|
+
</button>
|
|
354
|
+
</>
|
|
355
|
+
)}
|
|
356
|
+
|
|
357
|
+
{choice === 'ssh' && (
|
|
358
|
+
<div className={css.dialogGrid}>
|
|
359
|
+
{addingHost ? (
|
|
360
|
+
<>
|
|
361
|
+
<div style={{ gridColumn: '1 / -1', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
362
|
+
<label className={css.field}>
|
|
363
|
+
<span>{tt('dialog.alias')}</span>
|
|
364
|
+
<input value={newHost.alias} onChange={(e) => setNewHost((p) => ({ ...p, alias: e.target.value }))} placeholder="my-server" spellCheck={false} />
|
|
365
|
+
</label>
|
|
366
|
+
<label className={css.field}>
|
|
367
|
+
<span>{tt('dialog.host')}</span>
|
|
368
|
+
<input value={newHost.host} onChange={(e) => setNewHost((p) => ({ ...p, host: e.target.value }))} placeholder="1.2.3.4" spellCheck={false} />
|
|
369
|
+
</label>
|
|
370
|
+
<label className={css.field}>
|
|
371
|
+
<span>{tt('dialog.port')}</span>
|
|
372
|
+
<input value={newHost.port} onChange={(e) => setNewHost((p) => ({ ...p, port: e.target.value }))} inputMode="numeric" spellCheck={false} />
|
|
373
|
+
</label>
|
|
374
|
+
<label className={css.field}>
|
|
375
|
+
<span>{tt('dialog.user')}</span>
|
|
376
|
+
<input value={newHost.user} onChange={(e) => setNewHost((p) => ({ ...p, user: e.target.value }))} placeholder="root" spellCheck={false} />
|
|
377
|
+
</label>
|
|
378
|
+
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
379
|
+
<span>{tt('dialog.password')}</span>
|
|
380
|
+
<input type="password" value={newHost.password} onChange={(e) => setNewHost((p) => ({ ...p, password: e.target.value }))} spellCheck={false} />
|
|
381
|
+
<span className={css.hostMeta}>{tt('dialog.passwordHint')}</span>
|
|
382
|
+
</label>
|
|
383
|
+
</div>
|
|
384
|
+
<div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
|
|
385
|
+
<button type="button" className={css.button} onClick={() => setAddingHost(false)} disabled={savingHost}>{tt('create.cancel')}</button>
|
|
386
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={() => void saveHost()} disabled={savingHost}>
|
|
387
|
+
{savingHost ? tt('create.connecting') : tt('host.save')}
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
</>
|
|
391
|
+
) : (
|
|
392
|
+
<>
|
|
393
|
+
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
394
|
+
<span>{tt('create.host')}</span>
|
|
395
|
+
<select value={host} onChange={(event) => setHost(event.target.value)}>
|
|
396
|
+
<option value="">{hosts.length === 0 ? tt('create.hostEmpty') : '—'}</option>
|
|
397
|
+
{hosts.map((item) => (
|
|
398
|
+
<option key={item.alias} value={item.alias}>{item.alias} ({item.user}@{item.host}:{item.port})</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
</label>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
className={css.menuItem}
|
|
405
|
+
style={{ gridColumn: '1 / -1', padding: '5px 8px' }}
|
|
406
|
+
onClick={() => setAddingHost(true)}
|
|
407
|
+
>
|
|
408
|
+
<span className={css.menuItemIcon}>+</span>
|
|
409
|
+
<span>{tt('host.add')}</span>
|
|
410
|
+
</button>
|
|
411
|
+
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
412
|
+
<span>{tt('create.dir')}</span>
|
|
413
|
+
<input value={dirPath} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
|
|
414
|
+
</label>
|
|
415
|
+
<div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
|
|
416
|
+
<button type="button" className={css.button} disabled={busy || loading} onClick={() => void browse(host)}>
|
|
417
|
+
{loading ? tt('create.connecting') : tt('create.browse')}
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
421
|
+
<span>{tt('create.titleLabel')}</span>
|
|
422
|
+
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder={tt('create.titlePlaceholder')} spellCheck={false} />
|
|
423
|
+
</label>
|
|
424
|
+
</>
|
|
425
|
+
)}
|
|
426
|
+
{!addingHost && (
|
|
427
|
+
<div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
|
|
428
|
+
<button type="button" className={css.button} onClick={() => setChoice('menu')} disabled={busy || creating}>{tt('create.cancel')}</button>
|
|
429
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || creating} onClick={() => void createSsh()}>
|
|
430
|
+
{creating ? tt('create.connecting') : tt('create.submit')}
|
|
431
|
+
</button>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{choice === 'browsing' && (
|
|
438
|
+
<div className={css.dialogGrid} style={{ gridTemplateColumns: '1fr' }}>
|
|
439
|
+
<div className={css.field} style={{ gridColumn: '1' }}>
|
|
440
|
+
<span>{tt('create.browse')} · {host}</span>
|
|
441
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
className={css.button}
|
|
445
|
+
disabled={loading || dirPath === '/'}
|
|
446
|
+
onClick={() => {
|
|
447
|
+
const parent = dirPath.split('/').slice(0, -1).join('/') || '/'
|
|
448
|
+
void browse(host, parent)
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
{tt('create.up')}
|
|
452
|
+
</button>
|
|
453
|
+
<span className={css.hostMeta} style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{tt('create.current')}{dirPath}</span>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
<div style={{ gridColumn: '1', maxHeight: 240, overflow: 'auto', border: '1px solid var(--aion-border, rgba(128,128,128,0.25))', borderRadius: 8, padding: 6 }}>
|
|
457
|
+
{loading && <div className={css.dialogInfo}>{tt('create.connecting')}</div>}
|
|
458
|
+
{dirEntries.filter((entry) => entry.type === 'dir').map((entry) => (
|
|
459
|
+
<button
|
|
460
|
+
key={entry.name}
|
|
461
|
+
type="button"
|
|
462
|
+
className={css.hostRow}
|
|
463
|
+
style={{ width: '100%', textAlign: 'left', display: 'flex', gap: 6, alignItems: 'center', border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px 6px' }}
|
|
464
|
+
onClick={() => { void browse(host, `${dirPath.replace(/\/+$/, '')}/${entry.name}`) }}
|
|
465
|
+
>
|
|
466
|
+
<span>📁</span>
|
|
467
|
+
<span className={css.hostAlias}>{entry.name}</span>
|
|
468
|
+
</button>
|
|
469
|
+
))}
|
|
470
|
+
</div>
|
|
471
|
+
<div className={css.dialogActions} style={{ gridColumn: '1' }}>
|
|
472
|
+
<button type="button" className={css.button} onClick={() => setChoice('ssh')}>{tt('create.cancel')}</button>
|
|
473
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={() => setChoice('ssh')}>
|
|
474
|
+
{tt('flow.useThisDir')}
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>,
|
|
480
|
+
document.body,
|
|
481
|
+
)
|
|
482
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace icons for the SSH workspace surfaces: a cloud with a return arrow
|
|
3
|
+
* (SSH/remote workspace) and a desktop monitor (local workspace). Inlined SVG
|
|
4
|
+
* (1024 viewBox, solid fill) — no icon library dependency. Both inherit the
|
|
5
|
+
* theme text color via `currentColor` so they stay legible in light/dark
|
|
6
|
+
* themes.
|
|
7
|
+
* @module dsh-hardssh/client/icons
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReactElement } from 'react'
|
|
11
|
+
|
|
12
|
+
interface IconProps {
|
|
13
|
+
size?: number
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Desktop-monitor icon: a local workstation (monitor + stand). */
|
|
18
|
+
export function ComputerIcon({ size = 14, className }: IconProps): ReactElement {
|
|
19
|
+
return (
|
|
20
|
+
<svg
|
|
21
|
+
viewBox="0 0 1024 1024"
|
|
22
|
+
width={size}
|
|
23
|
+
height={size}
|
|
24
|
+
fill="currentColor"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
className={className}
|
|
27
|
+
>
|
|
28
|
+
<path d="M873.6 788.48H150.4a91.52 91.52 0 0 1-92.16-91.52V256a91.52 91.52 0 0 1 92.16-90.88h723.2A91.52 91.52 0 0 1 965.76 256v440.32a91.52 91.52 0 0 1-92.16 92.16zM150.4 229.12a28.16 28.16 0 0 0-28.16 26.88v440.32a28.16 28.16 0 0 0 28.16 27.52h723.2a27.52 27.52 0 0 0 28.16-27.52V256a27.52 27.52 0 0 0-28.16-27.52z" />
|
|
29
|
+
<path d="M99.84 593.28h833.92v64H99.84zM762.24 931.84H261.12a32 32 0 0 1 0-64h501.12a32 32 0 0 1 32 32 32 32 0 0 1-32 32z" />
|
|
30
|
+
<path d="M383.36 771.2h64v110.08h-64zM588.16 771.2h64v110.08h-64z" />
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cloud-with-return-arrow icon: a remote/SSH workspace. */
|
|
36
|
+
export function CloudIcon({ size = 14, className }: IconProps): ReactElement {
|
|
37
|
+
return (
|
|
38
|
+
<svg
|
|
39
|
+
viewBox="0 0 1024 1024"
|
|
40
|
+
width={size}
|
|
41
|
+
height={size}
|
|
42
|
+
fill="currentColor"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className={className}
|
|
45
|
+
>
|
|
46
|
+
<path d="M743.936 758.499556h-273.066667c-17.066667 0-34.133333-17.066667-34.133333-34.133334s17.066667-34.133333 34.133333-34.133333h273.066667c28.444444 0 153.6-5.688889 153.6-130.844445 0-142.222222-153.6-164.977778-159.288889-164.977777-17.066667 0-28.444444-11.377778-28.444444-22.755556-22.755556-85.333333-108.088889-142.222222-193.422223-130.844444-136.533333 0-176.355556 79.644444-199.111111 164.977777-5.688889 5.688889-22.755556 17.066667-39.822222 17.066667 0 0-153.6 5.688889-153.6 142.222222 0 119.466667 130.844444 125.155556 153.6 125.155556 17.066667 0 34.133333 17.066667 34.133333 34.133333s-17.066667 34.133333-34.133333 34.133334c-113.777778 0-227.555556-62.577778-227.555555-193.422223 0-142.222222 119.466667-204.8 199.111111-210.488889 22.755556-68.266667 79.644444-193.422222 261.688889-193.422222 113.777778-11.377778 216.177778 56.888889 256 164.977778 79.644444 17.066667 199.111111 79.644444 199.111111 233.244444 5.688889 136.533333-108.088889 199.111111-221.866667 199.111112z" />
|
|
47
|
+
<path d="M573.269333 866.588444c-11.377778 0-17.066667-5.688889-22.755555-11.377777l-108.088889-108.088889c-5.688889-5.688889-11.377778-17.066667-11.377778-22.755556s5.688889-17.066667 11.377778-22.755555l108.088889-108.088889c11.377778-11.377778 34.133333-11.377778 51.2 0 11.377778 11.377778 11.377778 34.133333 0 51.2l-79.644445 79.644444 79.644445 79.644445c11.377778 11.377778 11.377778 34.133333 0 51.2-5.688889 5.688889-17.066667 11.377778-28.444445 11.377777z" />
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
}
|