@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cluster tab: run one command
|
|
3
|
-
*
|
|
2
|
+
* Cluster tab: run one command against the selected Session's fixed server and
|
|
3
|
+
* inspect its result. Stdout/stderr render in
|
|
4
4
|
* collapsed <details> blocks; status renders as a colored badge.
|
|
5
5
|
*/
|
|
6
6
|
import { useState } from 'react'
|
|
@@ -12,19 +12,13 @@ import css from './panel.module.css'
|
|
|
12
12
|
/** Cluster tab props. */
|
|
13
13
|
export interface ClusterTabProps {
|
|
14
14
|
api: SshApi
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Split a comma-separated input into a trimmed, non-empty string list. */
|
|
18
|
-
function splitList(text: string): string[] {
|
|
19
|
-
return text.split(',').map(part => part.trim()).filter(part => part !== '')
|
|
15
|
+
/** Fixed alias inherited from the selected Session. */
|
|
16
|
+
alias: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/** The cluster execution tab. */
|
|
23
|
-
export function ClusterTab({ api }: ClusterTabProps) {
|
|
20
|
+
export function ClusterTab({ api, alias }: ClusterTabProps) {
|
|
24
21
|
const [command, setCommand] = useState('')
|
|
25
|
-
const [aliases, setAliases] = useState('')
|
|
26
|
-
const [environment, setEnvironment] = useState('')
|
|
27
|
-
const [tags, setTags] = useState('')
|
|
28
22
|
const [running, setRunning] = useState(false)
|
|
29
23
|
const [results, setResults] = useState<ClusterResult[] | null>(null)
|
|
30
24
|
const [error, setError] = useState<string | null>(null)
|
|
@@ -35,13 +29,9 @@ export function ClusterTab({ api }: ClusterTabProps) {
|
|
|
35
29
|
setRunning(true)
|
|
36
30
|
setError(null)
|
|
37
31
|
try {
|
|
38
|
-
const aliasList = splitList(aliases)
|
|
39
|
-
const tagList = splitList(tags)
|
|
40
32
|
const outcome = await api.cluster({
|
|
41
33
|
command: command.trim(),
|
|
42
|
-
aliases:
|
|
43
|
-
environment: environment.trim() === '' ? undefined : environment.trim(),
|
|
44
|
-
tags: tagList.length > 0 ? tagList : undefined,
|
|
34
|
+
aliases: [alias],
|
|
45
35
|
})
|
|
46
36
|
setResults(outcome)
|
|
47
37
|
} catch (cause) {
|
|
@@ -58,10 +48,9 @@ export function ClusterTab({ api }: ClusterTabProps) {
|
|
|
58
48
|
<span className={css.fieldLabel}>{tt('cluster.command')}</span>
|
|
59
49
|
<textarea className={css.input + ' ' + css.commandInput} value={command} onChange={event => { setCommand(event.target.value) }} />
|
|
60
50
|
</label>
|
|
61
|
-
<div className={css.
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<input className={css.input} placeholder={tt('cluster.tags')} value={tags} onChange={event => { setTags(event.target.value) }} />
|
|
51
|
+
<div className={css.controls}>
|
|
52
|
+
<span className={css.fieldLabel}>{tt('session.server')}</span>
|
|
53
|
+
<span className={css.targetBadge}>{alias}</span>
|
|
65
54
|
</div>
|
|
66
55
|
<div>
|
|
67
56
|
<button type="button" className={css.primaryButton} disabled={running || command.trim() === ''} onClick={() => { void run() }}>{tt('cluster.run')}</button>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** User-visible terminal connection failure surfaced by the Session connect gate. */
|
|
2
|
+
import { tt } from './helpers.ts'
|
|
3
|
+
import css from './panel.module.css'
|
|
4
|
+
|
|
5
|
+
export interface ConnectionErrorDialogProps {
|
|
6
|
+
alias: string
|
|
7
|
+
detail: string
|
|
8
|
+
onClose: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A small acknowledgement dialog for non-interactive SSH connection failures. */
|
|
12
|
+
export function ConnectionErrorDialog({ alias, detail, onClose }: ConnectionErrorDialogProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className={css.modalBackdrop} onClick={onClose}>
|
|
15
|
+
<div className={css.modal} role="alertdialog" aria-modal="true" aria-labelledby="dsh-ssh-connection-error-title" onClick={event => { event.stopPropagation() }}>
|
|
16
|
+
<div className={css.modalHeader}>
|
|
17
|
+
<h3 id="dsh-ssh-connection-error-title" className={css.modalTitle}>
|
|
18
|
+
{tt('connectionError.title', { alias })}
|
|
19
|
+
</h3>
|
|
20
|
+
</div>
|
|
21
|
+
<div className={css.modalBody}>
|
|
22
|
+
<p>{tt('connectionError.intro')}</p>
|
|
23
|
+
<p className={css.formError}>{detail}</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div className={css.modalFooter}>
|
|
26
|
+
<button type="button" className={css.modalPrimary} autoFocus onClick={onClose}>
|
|
27
|
+
{tt('connectionError.close')}
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -19,13 +19,16 @@ export interface SessionSecretDialogProps {
|
|
|
19
19
|
user?: string
|
|
20
20
|
/** Which secret the connection needs. */
|
|
21
21
|
secret: 'password' | 'passphrase'
|
|
22
|
+
/** A previous attempt's failure to show at the top (re-prompt with a
|
|
23
|
+
* reason — wrong password, unreachable host, …), VSCode-style. */
|
|
24
|
+
reason?: string
|
|
22
25
|
onClose: () => void
|
|
23
26
|
/** Called after the secret was provided (retry the operation). */
|
|
24
27
|
onProvided: () => void
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** Prompt for a session-only credential. */
|
|
28
|
-
export function SessionSecretDialog({ api, alias, user, secret, onClose, onProvided }: SessionSecretDialogProps) {
|
|
31
|
+
export function SessionSecretDialog({ api, alias, user, secret, reason, onClose, onProvided }: SessionSecretDialogProps) {
|
|
29
32
|
const [value, setValue] = useState('')
|
|
30
33
|
const [busy, setBusy] = useState(false)
|
|
31
34
|
const [error, setError] = useState<string | null>(null)
|
|
@@ -57,6 +60,7 @@ export function SessionSecretDialog({ api, alias, user, secret, onClose, onProvi
|
|
|
57
60
|
</h3>
|
|
58
61
|
</div>
|
|
59
62
|
<div className={css.modalBody}>
|
|
63
|
+
{reason !== undefined && reason !== '' && <p className={css.formError}>{reason}</p>}
|
|
60
64
|
<input
|
|
61
65
|
className={css.input}
|
|
62
66
|
type="password"
|
|
@@ -1,77 +1,81 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import type {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{ id: '
|
|
33
|
-
{ id: '
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped SSH operations console. The right sidebar follows the selected
|
|
3
|
+
* Session, so every operation inherits that Session's SSH workspace target.
|
|
4
|
+
* Individual tabs never offer a host picker. A local Session mounts no operation
|
|
5
|
+
* body and shows a blurred unavailable mask instead.
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useSyncExternalStore } from 'react'
|
|
8
|
+
import type { SshApi } from '../api.ts'
|
|
9
|
+
import type { SessionSshTargetSource } from '../session-target.ts'
|
|
10
|
+
import { tt } from './helpers.ts'
|
|
11
|
+
import { ClusterTab } from './ClusterTab.tsx'
|
|
12
|
+
import { TerminalTab } from './TerminalTab.tsx'
|
|
13
|
+
import { TransferTab } from './TransferTab.tsx'
|
|
14
|
+
import { TunnelsTab } from './TunnelsTab.tsx'
|
|
15
|
+
import css from './panel.module.css'
|
|
16
|
+
|
|
17
|
+
/** The console's tab identifiers. */
|
|
18
|
+
export type SshTab = 'terminal' | 'transfer' | 'tunnels' | 'cluster'
|
|
19
|
+
|
|
20
|
+
/** Console props. */
|
|
21
|
+
export interface SshPanelProps {
|
|
22
|
+
/** The SSH API client every tab operates through. */
|
|
23
|
+
api: SshApi
|
|
24
|
+
/** Selected Session's fixed SSH target; null means a local Session. */
|
|
25
|
+
target: SessionSshTargetSource
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tab bar definition (labels resolved at render time). */
|
|
29
|
+
const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
|
|
30
|
+
{ id: 'terminal', label: () => tt('tab.terminal') },
|
|
31
|
+
{ id: 'transfer', label: () => tt('tab.transfer') },
|
|
32
|
+
{ id: 'tunnels', label: () => tt('tab.tunnels') },
|
|
33
|
+
{ id: 'cluster', label: () => tt('tab.cluster') },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/** The tabbed SSH operations console. */
|
|
37
|
+
export function SshPanel({ api, target }: SshPanelProps) {
|
|
38
|
+
const [activeTab, setActiveTab] = useState<SshTab>('terminal')
|
|
39
|
+
const sessionTarget = useSyncExternalStore(target.subscribe, target.getSnapshot, target.getSnapshot)
|
|
40
|
+
|
|
41
|
+
if (sessionTarget === null) {
|
|
42
|
+
return (
|
|
43
|
+
<div className={css.panel} data-session-mode="local">
|
|
44
|
+
<div className={css.disabledSurface} aria-hidden="true">
|
|
45
|
+
<div className={css.tabBar} role="presentation">
|
|
46
|
+
{TABS.map(tab => <span key={tab.id} className={css.tab}>{tab.label()}</span>)}
|
|
47
|
+
</div>
|
|
48
|
+
<div className={css.panelContent}>
|
|
49
|
+
<div className={css.disabledPlaceholder} />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div className={css.localSessionMask} role="status">
|
|
53
|
+
<strong>{tt('session.localTitle')}</strong>
|
|
54
|
+
<span>{tt('session.localUnavailable')}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const targetKey = `${sessionTarget.sessionId}:${sessionTarget.workspaceId}:${sessionTarget.alias}`
|
|
61
|
+
return (
|
|
62
|
+
<div className={css.panel} data-session-mode="ssh" data-session-alias={sessionTarget.alias}>
|
|
63
|
+
<div className={css.tabBar} role="tablist">
|
|
64
|
+
{TABS.map(tab => (
|
|
65
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
66
|
+
{tab.label()}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
<div className={css.sessionTarget} title={sessionTarget.remoteRoot}>
|
|
71
|
+
{tt('session.target', { alias: sessionTarget.alias, remoteRoot: sessionTarget.remoteRoot })}
|
|
72
|
+
</div>
|
|
73
|
+
<div key={targetKey} className={css.panelContent}>
|
|
74
|
+
{activeTab === 'terminal' && <TerminalTab api={api} alias={sessionTarget.alias} />}
|
|
75
|
+
{activeTab === 'transfer' && <TransferTab api={api} alias={sessionTarget.alias} remoteRoot={sessionTarget.remoteRoot} />}
|
|
76
|
+
{activeTab === 'tunnels' && <TunnelsTab api={api} alias={sessionTarget.alias} />}
|
|
77
|
+
{activeTab === 'cluster' && <ClusterTab api={api} alias={sessionTarget.alias} />}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Terminal tab: an xterm.js PTY view over the host's WebSocket terminal route.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* The target alias is inherited from the selected Session; only connect /
|
|
4
|
+
* disconnect controls remain. The terminal container is sized by FitAddon
|
|
5
|
+
* (default 80x24 before first fit). On remote exit the last
|
|
5
6
|
* output stays visible and input is disabled. xterm's stylesheet is injected
|
|
6
7
|
* once per page load (module-level guard).
|
|
7
8
|
*/
|
|
@@ -9,18 +10,15 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
9
10
|
import { Terminal } from '@xterm/xterm'
|
|
10
11
|
import { FitAddon } from '@xterm/addon-fit'
|
|
11
12
|
import type { SshApi, TerminalConnection } from '../api.ts'
|
|
12
|
-
import type { SshHostSummary } from '../../../ssh/protocol.ts'
|
|
13
13
|
import { XTERM_CSS } from './xterm.css.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { tt } from './helpers.ts'
|
|
15
15
|
import css from './panel.module.css'
|
|
16
16
|
|
|
17
17
|
/** Terminal tab props. */
|
|
18
18
|
export interface TerminalTabProps {
|
|
19
19
|
api: SshApi
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
/** Monotonic id of the connect request (re-applies presetAlias). */
|
|
23
|
-
requestId?: number
|
|
20
|
+
/** Fixed alias inherited from the selected Session's SSH workspace. */
|
|
21
|
+
alias: string
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/** The terminal session lifecycle state shown in the status banner. */
|
|
@@ -45,9 +43,7 @@ function ensureXtermCss(): void {
|
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/** The xterm terminal view. */
|
|
48
|
-
export function TerminalTab({ api,
|
|
49
|
-
const [hosts, setHosts] = useState<SshHostSummary[]>([])
|
|
50
|
-
const [alias, setAlias] = useState(presetAlias ?? '')
|
|
46
|
+
export function TerminalTab({ api, alias }: TerminalTabProps) {
|
|
51
47
|
const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
|
|
52
48
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
53
49
|
const termRef = useRef<Terminal | null>(null)
|
|
@@ -56,25 +52,6 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
|
|
56
52
|
|
|
57
53
|
useEffect(() => { ensureXtermCss() }, [])
|
|
58
54
|
|
|
59
|
-
// Fetch the host list on tab activation.
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
let disposed = false
|
|
62
|
-
void (async () => {
|
|
63
|
-
try {
|
|
64
|
-
const list = await api.listHosts()
|
|
65
|
-
if (!disposed) setHosts(list)
|
|
66
|
-
} catch (cause) {
|
|
67
|
-
if (!disposed) setStatus({ kind: 'error', detail: errorMessage(cause) })
|
|
68
|
-
}
|
|
69
|
-
})()
|
|
70
|
-
return () => { disposed = true }
|
|
71
|
-
}, [api])
|
|
72
|
-
|
|
73
|
-
// A hosts-tab connect action preselects its alias here.
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (presetAlias !== undefined) setAlias(presetAlias)
|
|
76
|
-
}, [presetAlias, requestId])
|
|
77
|
-
|
|
78
55
|
const teardown = (): void => {
|
|
79
56
|
const connection = connRef.current
|
|
80
57
|
connRef.current = null
|
|
@@ -123,13 +100,20 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
|
|
123
100
|
term.loadAddon(fit)
|
|
124
101
|
term.open(container)
|
|
125
102
|
fit.fit()
|
|
103
|
+
// The WebSocket can be OPEN before the remote shell exists. Keep xterm
|
|
104
|
+
// input disabled until the protocol's ready frame; the API also keeps a
|
|
105
|
+
// bounded pre-ready queue as a defensive race buffer.
|
|
106
|
+
term.options.disableStdin = true
|
|
126
107
|
const connection = api.openTerminal(target, term.cols, term.rows)
|
|
127
108
|
termRef.current = term
|
|
128
109
|
fitRef.current = fit
|
|
129
110
|
connRef.current = connection
|
|
130
111
|
let settled = false
|
|
131
112
|
const dataSub = term.onData(data => { connection.send(data) })
|
|
132
|
-
connection.onReady = () => {
|
|
113
|
+
connection.onReady = () => {
|
|
114
|
+
term.options.disableStdin = false
|
|
115
|
+
setStatus({ kind: 'connected', alias: target })
|
|
116
|
+
}
|
|
133
117
|
connection.onOutput = data => { term.write(data) }
|
|
134
118
|
connection.onExit = (code, error) => {
|
|
135
119
|
if (settled) return
|
|
@@ -152,11 +136,8 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
|
|
152
136
|
return (
|
|
153
137
|
<div className={css.termBody}>
|
|
154
138
|
<div className={css.controls}>
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
{hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias} ({host.host})</option>)}
|
|
158
|
-
</select>
|
|
159
|
-
<button type="button" className={css.primaryButton} disabled={alias === '' || active} onClick={connect}>{tt('terminal.connect')}</button>
|
|
139
|
+
<span className={css.targetBadge}>{alias}</span>
|
|
140
|
+
<button type="button" className={css.primaryButton} disabled={active} onClick={connect}>{tt('terminal.connect')}</button>
|
|
160
141
|
<button type="button" className={css.ghostButton} disabled={!active} onClick={disconnect}>{tt('terminal.disconnect')}</button>
|
|
161
142
|
</div>
|
|
162
143
|
{status.kind === 'connecting' && <div className={css.banner} data-kind="info">{tt('terminal.connecting')}</div>}
|
|
@@ -168,7 +149,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
|
|
|
168
149
|
<div className={css.termWrap}>
|
|
169
150
|
<div ref={containerRef} className={css.termContainer} />
|
|
170
151
|
{status.kind === 'idle' && (
|
|
171
|
-
<div className={css.termPlaceholder}>{
|
|
152
|
+
<div className={css.termPlaceholder}>{tt('terminal.placeholder')}</div>
|
|
172
153
|
)}
|
|
173
154
|
</div>
|
|
174
155
|
</div>
|
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
|
|
7
7
|
import type { SshApi } from '../api.ts'
|
|
8
|
-
import type { RemoteDirEntry,
|
|
8
|
+
import type { RemoteDirEntry, TransferProgress } from '../../../ssh/protocol.ts'
|
|
9
9
|
import { errorMessage, tt } from './helpers.ts'
|
|
10
10
|
import css from './panel.module.css'
|
|
11
11
|
|
|
12
12
|
/** Transfer tab props. */
|
|
13
13
|
export interface TransferTabProps {
|
|
14
14
|
api: SshApi
|
|
15
|
+
/** Fixed alias inherited from the selected Session. */
|
|
16
|
+
alias: string
|
|
17
|
+
/** Initial browser/path root inherited from the Session's SSH workspace. */
|
|
18
|
+
remoteRoot: string
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/** One in-flight transfer (drives the progress bar). */
|
|
@@ -49,32 +53,23 @@ function formatBytes(bytes: number): string {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/** The upload/download tab. */
|
|
52
|
-
export function TransferTab({ api }: TransferTabProps) {
|
|
53
|
-
const [hosts, setHosts] = useState<SshHostSummary[]>([])
|
|
56
|
+
export function TransferTab({ api, alias, remoteRoot }: TransferTabProps) {
|
|
54
57
|
const [listError, setListError] = useState<string | null>(null)
|
|
55
|
-
const [
|
|
56
|
-
const [remotePath, setRemotePath] = useState('')
|
|
58
|
+
const [remotePath, setRemotePath] = useState(remoteRoot)
|
|
57
59
|
const [browseOpen, setBrowseOpen] = useState(false)
|
|
58
|
-
const [browseDir, setBrowseDir] = useState(
|
|
60
|
+
const [browseDir, setBrowseDir] = useState(remoteRoot)
|
|
59
61
|
const [entries, setEntries] = useState<RemoteDirEntry[]>([])
|
|
60
62
|
const [browsing, setBrowsing] = useState(false)
|
|
61
63
|
const [transfer, setTransfer] = useState<TransferState | null>(null)
|
|
62
64
|
const [status, setStatus] = useState<TransferStatus | null>(null)
|
|
63
65
|
const fileRef = useRef<HTMLInputElement | null>(null)
|
|
64
66
|
const seqRef = useRef(0)
|
|
67
|
+
const transferAbortRef = useRef<AbortController | null>(null)
|
|
65
68
|
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const list = await api.listHosts()
|
|
71
|
-
if (!disposed) setHosts(list)
|
|
72
|
-
} catch (cause) {
|
|
73
|
-
if (!disposed) setListError(errorMessage(cause))
|
|
74
|
-
}
|
|
75
|
-
})()
|
|
76
|
-
return () => { disposed = true }
|
|
77
|
-
}, [api])
|
|
69
|
+
useEffect(() => () => {
|
|
70
|
+
transferAbortRef.current?.abort(Object.assign(new Error('transfer tab unmounted'), { name: 'AbortError' }))
|
|
71
|
+
transferAbortRef.current = null
|
|
72
|
+
}, [])
|
|
78
73
|
|
|
79
74
|
const loadDir = async (path: string): Promise<void> => {
|
|
80
75
|
if (alias === '') return
|
|
@@ -98,9 +93,8 @@ export function TransferTab({ api }: TransferTabProps) {
|
|
|
98
93
|
}
|
|
99
94
|
|
|
100
95
|
const openBrowse = (): void => {
|
|
101
|
-
if (alias === '') return
|
|
102
96
|
setBrowseOpen(true)
|
|
103
|
-
const path = remotePath.trim() === '' ?
|
|
97
|
+
const path = remotePath.trim() === '' ? remoteRoot : remotePath.trim()
|
|
104
98
|
void loadDir(path)
|
|
105
99
|
}
|
|
106
100
|
|
|
@@ -108,10 +102,13 @@ export function TransferTab({ api }: TransferTabProps) {
|
|
|
108
102
|
const file = event.target.files?.[0]
|
|
109
103
|
event.target.value = ''
|
|
110
104
|
if (file === undefined || alias === '' || remotePath.trim() === '' || transfer !== null) return
|
|
105
|
+
const controller = new AbortController()
|
|
106
|
+
transferAbortRef.current = controller
|
|
111
107
|
setStatus(null)
|
|
112
108
|
setTransfer({ kind: 'upload', phase: 'connecting', percent: 0, file: file.name })
|
|
113
109
|
try {
|
|
114
110
|
const outcome = await api.uploadFile(file, alias, remotePath.trim(), progress => {
|
|
111
|
+
if (transferAbortRef.current !== controller || controller.signal.aborted) return
|
|
115
112
|
setTransfer(prev => prev === null ? prev : {
|
|
116
113
|
kind: 'upload',
|
|
117
114
|
phase: progress.phase,
|
|
@@ -119,21 +116,31 @@ export function TransferTab({ api }: TransferTabProps) {
|
|
|
119
116
|
speedBps: progress.speedBps,
|
|
120
117
|
file: progress.file,
|
|
121
118
|
})
|
|
122
|
-
})
|
|
123
|
-
|
|
119
|
+
}, controller.signal)
|
|
120
|
+
if (transferAbortRef.current === controller && !controller.signal.aborted) {
|
|
121
|
+
setStatus({ kind: 'ok', bytes: outcome.transferredBytes })
|
|
122
|
+
}
|
|
124
123
|
} catch (cause) {
|
|
125
|
-
|
|
124
|
+
if (transferAbortRef.current === controller && !controller.signal.aborted) {
|
|
125
|
+
setStatus({ kind: 'error', error: errorMessage(cause) })
|
|
126
|
+
}
|
|
126
127
|
} finally {
|
|
127
|
-
|
|
128
|
+
if (transferAbortRef.current === controller) {
|
|
129
|
+
transferAbortRef.current = null
|
|
130
|
+
if (!controller.signal.aborted) setTransfer(null)
|
|
131
|
+
}
|
|
128
132
|
}
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
const handleDownload = async (): Promise<void> => {
|
|
132
136
|
if (alias === '' || remotePath.trim() === '' || transfer !== null) return
|
|
137
|
+
const controller = new AbortController()
|
|
138
|
+
transferAbortRef.current = controller
|
|
133
139
|
setStatus(null)
|
|
134
140
|
setTransfer({ kind: 'download', phase: 'connecting', percent: 0, file: remotePath.trim() })
|
|
135
141
|
try {
|
|
136
142
|
const result = await api.downloadFile(alias, remotePath.trim(), progress => {
|
|
143
|
+
if (transferAbortRef.current !== controller || controller.signal.aborted) return
|
|
137
144
|
setTransfer(prev => prev === null ? prev : {
|
|
138
145
|
kind: 'download',
|
|
139
146
|
phase: progress.phase,
|
|
@@ -141,7 +148,8 @@ export function TransferTab({ api }: TransferTabProps) {
|
|
|
141
148
|
speedBps: progress.speedBps,
|
|
142
149
|
file: progress.file,
|
|
143
150
|
})
|
|
144
|
-
})
|
|
151
|
+
}, controller.signal)
|
|
152
|
+
if (transferAbortRef.current !== controller || controller.signal.aborted) return
|
|
145
153
|
// Streamed downloads (File System Access API) were already saved; the
|
|
146
154
|
// Blob fallback triggers a browser save here.
|
|
147
155
|
if (!result.streamed && result.blob !== undefined) {
|
|
@@ -157,23 +165,25 @@ export function TransferTab({ api }: TransferTabProps) {
|
|
|
157
165
|
}
|
|
158
166
|
setStatus({ kind: 'ok', bytes: result.bytes })
|
|
159
167
|
} catch (cause) {
|
|
160
|
-
|
|
168
|
+
if (transferAbortRef.current === controller && !controller.signal.aborted) {
|
|
169
|
+
setStatus({ kind: 'error', error: errorMessage(cause) })
|
|
170
|
+
}
|
|
161
171
|
} finally {
|
|
162
|
-
|
|
172
|
+
if (transferAbortRef.current === controller) {
|
|
173
|
+
transferAbortRef.current = null
|
|
174
|
+
if (!controller.signal.aborted) setTransfer(null)
|
|
175
|
+
}
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
|
|
166
|
-
const ready =
|
|
179
|
+
const ready = remotePath.trim() !== '' && transfer === null
|
|
167
180
|
|
|
168
181
|
return (
|
|
169
182
|
<div className={css.tabBody}>
|
|
170
183
|
<div className={css.controls}>
|
|
171
|
-
<
|
|
172
|
-
<option value="">{tt('transfer.selectHost')}</option>
|
|
173
|
-
{hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias} ({host.host})</option>)}
|
|
174
|
-
</select>
|
|
184
|
+
<span className={css.targetBadge}>{alias}</span>
|
|
175
185
|
<input className={css.input} placeholder={tt('transfer.remotePathHint')} value={remotePath} onChange={event => { setRemotePath(event.target.value) }} />
|
|
176
|
-
<button type="button" className={css.ghostButton}
|
|
186
|
+
<button type="button" className={css.ghostButton} onClick={openBrowse}>{tt('transfer.browseRemote')}</button>
|
|
177
187
|
<div className={css.toolbarSpacer} />
|
|
178
188
|
<button type="button" className={css.primaryButton} disabled={!ready} onClick={() => { fileRef.current?.click() }}>{tt('transfer.upload')}</button>
|
|
179
189
|
<button type="button" className={css.ghostButton} disabled={!ready} onClick={() => { void handleDownload() }}>{tt('transfer.download')}</button>
|