@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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +58 -25
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Cluster tab: run one command across many hosts (aliases / environment /
3
- * tags filters) and inspect per-host results. Stdout/stderr render in
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: aliasList.length > 0 ? aliasList : undefined,
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.clusterFilters}>
62
- <input className={css.input} placeholder={tt('cluster.aliases')} value={aliases} onChange={event => { setAliases(event.target.value) }} />
63
- <input className={css.input} placeholder={tt('cluster.environment')} value={environment} onChange={event => { setEnvironment(event.target.value) }} />
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
- * The SSH operations panel shell: a header with a close control, a five-tab
3
- * bar, and the active tab's content. Tab state lives here (browser session
4
- * state); inactive tabs unmount, so each tab fetches its own data on
5
- * activation. The hosts tab's connect action switches here to the terminal
6
- * tab with the chosen alias preselected.
7
- */
8
- import { useState } from 'react'
9
- import type { SshApi } from '../api.ts'
10
- import type { PanelController } from './controller.ts'
11
- import { tt } from './helpers.ts'
12
- import { ClusterTab } from './ClusterTab.tsx'
13
- import { HostsTab } from './HostsTab.tsx'
14
- import { TerminalTab } from './TerminalTab.tsx'
15
- import { TransferTab } from './TransferTab.tsx'
16
- import { TunnelsTab } from './TunnelsTab.tsx'
17
- import css from './panel.module.css'
18
-
19
- /** The panel's tab identifiers. */
20
- export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
21
-
22
- /** Panel shell props. */
23
- export interface SshPanelProps {
24
- /** The panel state owner (open/close/toggle). */
25
- controller: PanelController
26
- /** The SSH API client every tab operates through. */
27
- api: SshApi
28
- }
29
-
30
- /** The tab bar definition (labels resolved at render time). */
31
- const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
32
- { id: 'hosts', label: () => tt('tab.hosts') },
33
- { id: 'terminal', label: () => tt('tab.terminal') },
34
- { id: 'transfer', label: () => tt('tab.transfer') },
35
- { id: 'tunnels', label: () => tt('tab.tunnels') },
36
- { id: 'cluster', label: () => tt('tab.cluster') },
37
- ]
38
-
39
- /** A pending "connect this host" request handed to the terminal tab. */
40
- interface ConnectRequest {
41
- alias: string
42
- nonce: number
43
- }
44
-
45
- /** The tabbed SSH panel. */
46
- export function SshPanel({ controller, api }: SshPanelProps) {
47
- const [activeTab, setActiveTab] = useState<SshTab>('hosts')
48
- const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
49
-
50
- const handleConnect = (alias: string): void => {
51
- setActiveTab('terminal')
52
- setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
53
- }
54
-
55
- return (
56
- <div className={css.panel}>
57
- <div className={css.panelHeader}>
58
- <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
59
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
60
- </div>
61
- <div className={css.tabBar} role="tablist">
62
- {TABS.map(tab => (
63
- <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) }}>
64
- {tab.label()}
65
- </button>
66
- ))}
67
- </div>
68
- <div className={css.panelContent}>
69
- {activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
70
- {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
71
- {activeTab === 'transfer' && <TransferTab api={api} />}
72
- {activeTab === 'tunnels' && <TunnelsTab api={api} />}
73
- {activeTab === 'cluster' && <ClusterTab api={api} />}
74
- </div>
75
- </div>
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
- * A host <select> plus connect/disconnect controls; the terminal container is
4
- * sized by FitAddon (default 80x24 before first fit). On remote exit the last
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 { errorMessage, tt } from './helpers.ts'
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
- /** Alias preselected by a "connect" action from the hosts tab. */
21
- presetAlias?: string
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, presetAlias, requestId }: TerminalTabProps) {
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 = () => { setStatus({ kind: 'connected', alias: target }) }
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
- <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
156
- <option value="">{tt('terminal.selectHost')}</option>
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}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
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, SshHostSummary, TransferProgress } from '../../../ssh/protocol.ts'
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 [alias, setAlias] = useState('')
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
- let disposed = false
68
- void (async () => {
69
- try {
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() === '' ? '/' : 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
- setStatus({ kind: 'ok', bytes: outcome.transferredBytes })
119
+ }, controller.signal)
120
+ if (transferAbortRef.current === controller && !controller.signal.aborted) {
121
+ setStatus({ kind: 'ok', bytes: outcome.transferredBytes })
122
+ }
124
123
  } catch (cause) {
125
- setStatus({ kind: 'error', error: errorMessage(cause) })
124
+ if (transferAbortRef.current === controller && !controller.signal.aborted) {
125
+ setStatus({ kind: 'error', error: errorMessage(cause) })
126
+ }
126
127
  } finally {
127
- setTransfer(null)
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
- setStatus({ kind: 'error', error: errorMessage(cause) })
168
+ if (transferAbortRef.current === controller && !controller.signal.aborted) {
169
+ setStatus({ kind: 'error', error: errorMessage(cause) })
170
+ }
161
171
  } finally {
162
- setTransfer(null)
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 = alias !== '' && remotePath.trim() !== '' && transfer === null
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
- <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
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} disabled={alias === ''} onClick={openBrowse}>{tt('transfer.browseRemote')}</button>
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>