@electerm/electerm-react 5.3.15 → 5.5.6

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 (149) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +23 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +14 -0
  6. package/client/common/init-setting-item.js +5 -0
  7. package/client/common/platform.js +24 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +10 -2
  9. package/client/components/ai/agent.js +4 -1
  10. package/client/components/ai/ai-attachments.js +87 -0
  11. package/client/components/ai/ai-chat-empty.jsx +43 -0
  12. package/client/components/ai/ai-chat-entry.jsx +13 -4
  13. package/client/components/ai/ai-chat-history-item.jsx +28 -4
  14. package/client/components/ai/ai-chat-history.jsx +2 -1
  15. package/client/components/ai/ai-chat.jsx +104 -13
  16. package/client/components/ai/ai-config-props.js +4 -130
  17. package/client/components/ai/ai-config.jsx +135 -15
  18. package/client/components/ai/ai-presets.js +173 -0
  19. package/client/components/ai/ai.styl +83 -0
  20. package/client/components/bg/css-overwrite.jsx +11 -0
  21. package/client/components/bookmark-form/bookmark-schema.js +4 -0
  22. package/client/components/bookmark-form/common/fields.jsx +3 -0
  23. package/client/components/bookmark-form/config/common-fields.js +12 -0
  24. package/client/components/bookmark-form/config/local.js +3 -2
  25. package/client/components/bookmark-form/config/serial.js +3 -2
  26. package/client/components/bookmark-form/config/ssh.js +2 -1
  27. package/client/components/bookmark-form/config/telnet.js +3 -2
  28. package/client/components/bookmark-form/index.jsx +10 -8
  29. package/client/components/common/item-filter.jsx +76 -0
  30. package/client/components/common/item-filter.styl +54 -0
  31. package/client/components/common/lazy-boundary.jsx +34 -0
  32. package/client/components/common/opacity.jsx +3 -1
  33. package/client/components/footer/footer-entry.jsx +35 -5
  34. package/client/components/footer/footer.styl +8 -1
  35. package/client/components/layout/layout.jsx +23 -31
  36. package/client/components/layout/pixed.js +3 -0
  37. package/client/components/main/main.jsx +80 -28
  38. package/client/components/main/term-fullscreen.styl +9 -3
  39. package/client/components/quick-commands/qm.styl +3 -2
  40. package/client/components/rdp/file-transfer.js +4 -4
  41. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  42. package/client/components/remote-monitor/monitor-details.styl +87 -0
  43. package/client/components/remote-monitor/monitor-model.js +764 -0
  44. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  45. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  46. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  47. package/client/components/remote-monitor/session-monitor.js +361 -0
  48. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  49. package/client/components/remote-monitor/visibility.js +18 -0
  50. package/client/components/session/session-control.jsx +29 -2
  51. package/client/components/session/session.jsx +40 -18
  52. package/client/components/session/sessions.jsx +5 -2
  53. package/client/components/setting-panel/col.jsx +32 -6
  54. package/client/components/setting-panel/list.styl +12 -0
  55. package/client/components/setting-panel/setting-common.jsx +25 -56
  56. package/client/components/setting-panel/setting-mobile-breadcrumb.jsx +29 -0
  57. package/client/components/setting-panel/setting-modal.jsx +64 -44
  58. package/client/components/setting-panel/setting-terminal.jsx +7 -2
  59. package/client/components/setting-panel/setting-wrap.jsx +0 -3
  60. package/client/components/setting-panel/setting-wrap.styl +80 -11
  61. package/client/components/setting-panel/setting.styl +1 -1
  62. package/client/components/setting-panel/tab-triggers.jsx +71 -0
  63. package/client/components/sftp/file-compare-modal.jsx +6 -3
  64. package/client/components/sftp/file-item.jsx +5 -3
  65. package/client/components/sftp/owner-list.js +11 -2
  66. package/client/components/side-panel-r/side-panel-r.jsx +2 -2
  67. package/client/components/sidebar/index.jsx +28 -21
  68. package/client/components/sidebar/left-sidebar-icons.jsx +1 -1
  69. package/client/components/sidebar/sidebar.styl +16 -0
  70. package/client/components/sys-menu/menu-btn.jsx +30 -1
  71. package/client/components/tabs/index.jsx +2 -2
  72. package/client/components/tabs/tab-title.jsx +3 -2
  73. package/client/components/tabs/tab.jsx +5 -1
  74. package/client/components/tabs/tabs.styl +4 -0
  75. package/client/components/terminal/attach-addon-custom.js +295 -49
  76. package/client/components/terminal/automation/index.js +64 -0
  77. package/client/components/terminal/automation/keys.js +49 -0
  78. package/client/components/terminal/automation/stream-matcher.js +82 -0
  79. package/client/components/terminal/automation/strip-ansi.js +19 -0
  80. package/client/components/terminal/automation/trigger-engine.js +233 -0
  81. package/client/components/terminal/automation/trigger-presets.js +110 -0
  82. package/client/components/terminal/command-tracker-addon.js +31 -1
  83. package/client/components/terminal/mixins/index.js +27 -0
  84. package/client/components/terminal/mixins/term-attach.js +123 -0
  85. package/client/components/terminal/mixins/term-config.jsx +84 -0
  86. package/client/components/terminal/mixins/term-content.js +88 -0
  87. package/client/components/terminal/mixins/term-context-menu.jsx +291 -0
  88. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  89. package/client/components/terminal/mixins/term-init.js +122 -0
  90. package/client/components/terminal/mixins/term-log.jsx +96 -0
  91. package/client/components/terminal/mixins/term-resize.js +87 -0
  92. package/client/components/terminal/mixins/term-socket.js +433 -0
  93. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  94. package/client/components/terminal/mixins/term-theme.js +190 -0
  95. package/client/components/terminal/mixins/term-touch.js +136 -0
  96. package/client/components/terminal/shell-detect.js +37 -0
  97. package/client/components/terminal/shell.js +0 -28
  98. package/client/components/terminal/shortcut-bar-entry.jsx +32 -27
  99. package/client/components/terminal/shortcut-bar.jsx +4 -6
  100. package/client/components/terminal/shortcut-bar.styl +4 -0
  101. package/client/components/terminal/ssh-reload-state.js +56 -0
  102. package/client/components/terminal/startup-queue.js +379 -0
  103. package/client/components/terminal/terminal-interactive.jsx +32 -0
  104. package/client/components/terminal/terminal.jsx +102 -2007
  105. package/client/components/terminal/transfer-client-base.js +5 -29
  106. package/client/components/terminal/trzsz-client.js +1 -3
  107. package/client/components/terminal/xmodem-client.js +1 -3
  108. package/client/components/terminal/xterm-loader.js +7 -0
  109. package/client/components/terminal/zmodem-client.js +1 -3
  110. package/client/components/terminal-info/base.jsx +18 -70
  111. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  112. package/client/components/terminal-info/terminal-info.jsx +50 -59
  113. package/client/components/terminal-info/terminal-info.styl +6 -49
  114. package/client/components/text-editor/open-text-editor.js +30 -0
  115. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  116. package/client/components/text-editor/text-editor.jsx +14 -0
  117. package/client/components/theme/theme-form.jsx +22 -0
  118. package/client/components/tree-list/bookmark-transport.jsx +7 -31
  119. package/client/components/tree-list/tree-list.styl +13 -0
  120. package/client/components/triggers/trigger-editor.jsx +246 -0
  121. package/client/components/triggers/trigger-form.jsx +200 -0
  122. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  123. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  124. package/client/components/triggers/trigger-transport.jsx +60 -0
  125. package/client/components/triggers/triggers-field.jsx +19 -0
  126. package/client/components/widgets/widgets-list.jsx +11 -1
  127. package/client/css/mobile.styl +9 -1
  128. package/client/store/common.js +12 -1
  129. package/client/store/init-state.js +14 -0
  130. package/client/store/mcp-handler.js +158 -30
  131. package/client/store/setting.js +20 -0
  132. package/client/store/sidebar.js +12 -0
  133. package/client/store/store.js +2 -0
  134. package/client/store/sync.js +45 -9
  135. package/client/store/system-menu.js +2 -0
  136. package/client/store/tab.js +41 -3
  137. package/client/store/trigger.js +118 -0
  138. package/client/store/watch.js +13 -5
  139. package/client/views/index.pug +3 -0
  140. package/package.json +1 -1
  141. package/client/components/terminal-info/activity.jsx +0 -57
  142. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  143. package/client/components/terminal-info/disk.jsx +0 -45
  144. package/client/components/terminal-info/format-disks.js +0 -45
  145. package/client/components/terminal-info/network.jsx +0 -121
  146. package/client/components/terminal-info/resource.jsx +0 -99
  147. package/client/components/terminal-info/run-cmd.jsx +0 -263
  148. package/client/components/terminal-info/sys-info.jsx +0 -56
  149. package/client/components/terminal-info/up.jsx +0 -17
@@ -37,6 +37,8 @@
37
37
  */
38
38
 
39
39
  import { lazy, Suspense, useEffect, useState } from 'react'
40
+ import { auto } from 'manate/react'
41
+ import LazyBoundary from '../common/lazy-boundary'
40
42
 
41
43
  const ShortcutBar = lazy(() => import('./shortcut-bar'))
42
44
 
@@ -52,38 +54,37 @@ export const KEYBOARD_MIN = 120
52
54
  // keyboard from a mouse-driven window resize on touch-capable desktops.
53
55
  const TOUCH_GRACE_MS = 3000
54
56
 
55
- // kept as a coarse capability fallback for engines without visualViewport.
56
- // Modern Chromium (Electron) always exposes window.visualViewport, so this is
57
- // effectively only reached on very old runtimes — where remote-control
58
- // misreporting is not a concern.
59
- export function isTouchDevice () {
60
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
61
- return false
62
- }
63
- const coarse = typeof window.matchMedia === 'function' &&
64
- window.matchMedia('(pointer: coarse)').matches
65
- const hasTouch = (navigator.maxTouchPoints || 0) > 0 ||
66
- ('ontouchstart' in window)
67
- return Boolean(coarse || hasTouch)
68
- }
69
-
70
- export default function ShortcutBarEntry (props) {
71
- // mount the lazy bar only after a soft keyboard has been seen at least once,
72
- // so its code (and antd/icon deps) stays out of the main bundle on devices
73
- // that never show one (desktops, remote-control clients mis-reported as
74
- // touch, etc.).
75
- //
57
+ function ShortcutBarEntry (props) {
58
+ const { store } = props
76
59
  // opt out entirely when the user has disabled the shortcut bar in common
77
60
  // settings — bail before any listener is attached or component imported.
78
- const { store } = props
79
61
  const disabled = store.config.disableShortcutBar
62
+ // follow the store's live touch state (flipped by main.jsx from real
63
+ // pointer input — mouse → false, touch/pen → true) instead of probing screen
64
+ // capability, which only says the screen *can* be touched, not whether the
65
+ // user is actually using touch, so the bar reacts when a touch-capable
66
+ // machine starts being used by touch.
67
+ const isTouch = store.isTouchDevice
68
+ // very old runtimes expose no visualViewport — the lazy "wait for a real
69
+ // keyboard" signal below can never fire there, so fall back straight to the
70
+ // touch state.
71
+ const noVisualViewport = typeof window === 'undefined' || !window.visualViewport
80
72
  const [keyboardSeen, setKeyboardSeen] = useState(() => {
81
- if (disabled || typeof window === 'undefined' || !window.visualViewport) {
82
- return disabled ? false : isTouchDevice()
73
+ if (noVisualViewport) {
74
+ return !disabled && isTouch
83
75
  }
84
76
  return false
85
77
  })
86
78
 
79
+ // keep the no-visualViewport fallback current as the store's touch state
80
+ // flips at runtime; on modern engines keyboardSeen is driven by the resize
81
+ // listener below and this never runs.
82
+ useEffect(() => {
83
+ if (noVisualViewport) {
84
+ setKeyboardSeen(!disabled && isTouch)
85
+ }
86
+ }, [disabled, isTouch, noVisualViewport])
87
+
87
88
  useEffect(() => {
88
89
  const vv = window.visualViewport
89
90
  if (!vv) {
@@ -166,8 +167,12 @@ export default function ShortcutBarEntry (props) {
166
167
  return null
167
168
  }
168
169
  return (
169
- <Suspense fallback={null}>
170
- <ShortcutBar {...props} />
171
- </Suspense>
170
+ <LazyBoundary>
171
+ <Suspense fallback={null}>
172
+ <ShortcutBar {...props} />
173
+ </Suspense>
174
+ </LazyBoundary>
172
175
  )
173
176
  }
177
+
178
+ export default auto(ShortcutBarEntry)
@@ -135,12 +135,10 @@ function ShortcutBar (props) {
135
135
  // keep the offset in a CSS var consumed by shortcut-bar.styl, and expose the
136
136
  // lifted height so layout.jsx can shrink the terminal accordingly.
137
137
  useEffect(() => {
138
- document.documentElement.style.setProperty(
139
- '--shortcut-bar-kb-offset',
140
- kbOffset ? kbOffset + 'px' : '0px'
141
- )
142
- store.shortcutBarKbOffset = kbOffset
143
- }, [kbOffset])
138
+ const offset = store.shortcutBarVisible ? kbOffset : 0
139
+ document.documentElement.style.setProperty('--shortcut-bar-kb-offset', offset + 'px')
140
+ store.shortcutBarKbOffset = offset
141
+ }, [kbOffset, store.shortcutBarVisible])
144
142
 
145
143
  // reserve layout space + lift the footer while the bar is shown.
146
144
  // expose the bar height as a CSS var so the .styl + footer-lift rule stay
@@ -79,6 +79,10 @@
79
79
  .shortcut-bar-on
80
80
  .main-footer
81
81
  bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
82
+ .remote-monitor-bar
83
+ bottom calc(var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px) + 36px)
84
+ .qm-wrap-tooltip
85
+ bottom calc(var(--footer-stack-height, 36px) + var(--shortcut-bar-h, 44px) + var(--shortcut-bar-kb-offset, 0px))
82
86
 
83
87
  // --- edit modal -----------------------------------------------------------
84
88
 
@@ -0,0 +1,56 @@
1
+ const maxCwdLength = 4096
2
+
3
+ export function isSshTab (tab = {}) {
4
+ return !!tab.host && (!tab.type || tab.type === 'ssh')
5
+ }
6
+
7
+ export function sanitizeSshCwd (cwd) {
8
+ if (typeof cwd !== 'string' || !cwd || cwd.length > maxCwdLength) {
9
+ return ''
10
+ }
11
+ if (/[\0\r\n]/.test(cwd)) {
12
+ return ''
13
+ }
14
+ return cwd
15
+ }
16
+
17
+ export function quotePosixShellArg (value) {
18
+ const quote = '\''
19
+ const escapedQuote = quote + '"' + quote + '"' + quote
20
+ return quote + value.replace(/'/g, escapedQuote) + quote
21
+ }
22
+
23
+ export function createRestoreCwdCommand (cwd) {
24
+ const safeCwd = sanitizeSshCwd(cwd)
25
+ return safeCwd ? `cd -- ${quotePosixShellArg(safeCwd)}` : ''
26
+ }
27
+
28
+ export function shouldCaptureSshReloadState (tab, config = {}) {
29
+ return !!config.restoreTerminalSessionOnReload && isSshTab(tab)
30
+ }
31
+
32
+ export function createSshReloadState ({ cwd, screen } = {}) {
33
+ const safeCwd = sanitizeSshCwd(cwd)
34
+ const safeScreen = typeof screen === 'string' ? screen : ''
35
+ if (!safeCwd && !safeScreen) {
36
+ return undefined
37
+ }
38
+ return {
39
+ cwd: safeCwd,
40
+ screen: safeScreen
41
+ }
42
+ }
43
+
44
+ export function getAlternateBufferSnapshot (buffer) {
45
+ if (!buffer || buffer.type !== 'alternate') {
46
+ return ''
47
+ }
48
+ const lines = []
49
+ for (let i = 0; i < buffer.length; i++) {
50
+ lines.push(buffer.getLine(i)?.translateToString(true) || '')
51
+ }
52
+ while (lines.length && !lines[lines.length - 1]) {
53
+ lines.pop()
54
+ }
55
+ return lines.join('\r\n')
56
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Startup queue for a terminal session.
3
+ *
4
+ * Everything that has to be typed into a freshly connected shell goes through
5
+ * here, strictly one item at a time:
6
+ *
7
+ * shell integration injection -> cd/startDirectory -> runScripts
8
+ *
9
+ * Two rules keep it reliable:
10
+ *
11
+ * 1. Only one drain loop owns the queue at a time, and every queue generation
12
+ * carries a token. A reconnect (or a config toggle that rebuilds the queue)
13
+ * bumps the token, so a loop still waiting on the *old* connection stops
14
+ * instead of stealing items from the new queue - that used to silently drop
15
+ * the remaining run scripts.
16
+ * 2. After each script we wait for the shell to settle (a fresh prompt, or a
17
+ * quiet period) before typing the next one, so nothing is ever typed into a
18
+ * shell that is still busy. Every wait is bounded, so a hung shell can only
19
+ * delay the queue, never stop it.
20
+ */
21
+ import {
22
+ getShellIntegrationCommand,
23
+ detectShellType
24
+ } from './shell.js'
25
+ import { createRestoreCwdCommand } from './ssh-reload-state.js'
26
+ import { isMac, isWin } from '../../common/platform.js'
27
+
28
+ // Pacing: `settleIdleMs` of silence counts as "the shell is done",
29
+ // `settleTimeoutMs` caps the wait so a script that never returns can not
30
+ // stall the queue.
31
+ const settleIdleMs = 400
32
+ const settleTimeoutMs = 3000
33
+ // Ceiling for the best-effort remote shell probe. detectRemoteShell opens an
34
+ // extra exec channel and runCmd has no timeout of its own, so a server that
35
+ // never answers would otherwise block the whole queue forever.
36
+ const remoteShellDetectTimeoutMs = 3000
37
+
38
+ export class StartupQueue {
39
+ /**
40
+ * @param {object} term - the terminal component (host)
41
+ * @param {object} [deps] - `detectRemoteShell` needs the network, so it is
42
+ * injected: it keeps this module free of the app runtime and makes the
43
+ * queue testable on its own.
44
+ */
45
+ constructor (term, deps = {}) {
46
+ this.term = term
47
+ this.detectRemoteShell = deps.detectRemoteShell || (() => Promise.resolve('sh'))
48
+ this.queue = []
49
+ this.token = 0
50
+ this.running = false
51
+ this.shellInjected = false
52
+ this.shellType = null
53
+ this.timers = new Set()
54
+ }
55
+
56
+ dispose = () => {
57
+ for (const t of this.timers) {
58
+ clearTimeout(t)
59
+ }
60
+ this.timers.clear()
61
+ this.queue = []
62
+ this.running = false
63
+ }
64
+
65
+ // ---- host accessors ----
66
+
67
+ get addon () {
68
+ return this.term.attachAddon
69
+ }
70
+
71
+ get cmdAddon () {
72
+ return this.term.cmdAddon
73
+ }
74
+
75
+ isClosed = () => {
76
+ return !!this.term.onClose
77
+ }
78
+
79
+ isSsh = () => {
80
+ return this.term.isSsh()
81
+ }
82
+
83
+ isLocal = () => {
84
+ return this.term.isLocal()
85
+ }
86
+
87
+ sleep = (ms) => {
88
+ if (ms <= 0) {
89
+ return Promise.resolve()
90
+ }
91
+ return new Promise(resolve => {
92
+ const timer = setTimeout(() => {
93
+ this.timers.delete(timer)
94
+ resolve()
95
+ }, ms)
96
+ this.timers.add(timer)
97
+ })
98
+ }
99
+
100
+ // ---- queue ----
101
+
102
+ runInitScript = () => {
103
+ const { term } = this
104
+ window.store.triggerResize()
105
+ const {
106
+ startDirectory,
107
+ runScripts
108
+ } = term.props.tab
109
+
110
+ const scripts = runScripts ? [...runScripts] : []
111
+ const reloadCwd = term.props.config.restoreTerminalSessionOnReload && this.isSsh()
112
+ ? term.props.tab._reloadState?.cwd
113
+ : ''
114
+ const startFolder = reloadCwd || startDirectory || window.initFolder
115
+ const cwdCommand = this.isSsh()
116
+ ? createRestoreCwdCommand(startFolder)
117
+ : startFolder ? `cd "${startFolder}"` : ''
118
+ if (cwdCommand) {
119
+ scripts.unshift({ script: cwdCommand, delay: 0 })
120
+ }
121
+
122
+ const token = ++this.token
123
+ this.queue = []
124
+
125
+ if (this.canInjectShellIntegration()) {
126
+ this.queue.push({
127
+ type: 'shell_integration',
128
+ execute: async () => {
129
+ await this.injectShellIntegration()
130
+ }
131
+ })
132
+ }
133
+
134
+ scripts.forEach((script, i) => {
135
+ this.queue.push({
136
+ type: 'delayed_script',
137
+ script: script.script,
138
+ delay: script.delay || 0,
139
+ // Nothing follows the last one, so waiting for the shell to settle
140
+ // would only add latency.
141
+ isLast: i === scripts.length - 1,
142
+ execute: () => {
143
+ if (script.script && this.addon) {
144
+ this.addon._sendData(script.script + '\r')
145
+ }
146
+ }
147
+ })
148
+ })
149
+
150
+ this.start(token)
151
+ }
152
+
153
+ /**
154
+ * Put a shell integration injection at the head of the queue.
155
+ * Inserting - instead of firing it off immediately - keeps it serialized
156
+ * against anything still queued and guarantees a single drain loop.
157
+ */
158
+ enqueueShellIntegration = (sendEnter) => {
159
+ this.queue.unshift({
160
+ type: 'shell_integration',
161
+ execute: async () => {
162
+ await this.injectShellIntegration()
163
+ // A fresh prompt makes the shell emit OSC 633 ;P Cwd= right away, so
164
+ // the file manager picks the current path up without waiting for the
165
+ // user's next command.
166
+ if (sendEnter && this.addon) {
167
+ this.addon._sendData('\r')
168
+ }
169
+ }
170
+ })
171
+ this.start(this.token)
172
+ }
173
+
174
+ /**
175
+ * Drain the queue one item at a time. Re-entrant calls only push items and
176
+ * return, so two loops can never interleave or race for the same array.
177
+ */
178
+ start = async (token) => {
179
+ if (this.running) {
180
+ return
181
+ }
182
+ this.running = true
183
+ try {
184
+ while (
185
+ !this.isClosed() &&
186
+ token === this.token &&
187
+ this.queue.length > 0
188
+ ) {
189
+ const item = this.queue.shift()
190
+ try {
191
+ await this.runItem(item)
192
+ } catch (error) {
193
+ console.error('[Shell Integration] Error processing queue item:', item.type, error)
194
+ }
195
+ }
196
+ } finally {
197
+ this.running = false
198
+ // A newer queue was built while this loop was still draining (reconnect,
199
+ // config toggle). Pick it up here, otherwise the fresh items would sit
200
+ // in the array with nobody left to run them.
201
+ if (
202
+ !this.isClosed() &&
203
+ token !== this.token &&
204
+ this.queue.length > 0
205
+ ) {
206
+ this.start(this.token)
207
+ }
208
+ }
209
+ }
210
+
211
+ runItem = async (item) => {
212
+ if (this.isClosed() || !this.addon) {
213
+ return
214
+ }
215
+ if (item.type === 'shell_integration') {
216
+ await item.execute()
217
+ // Give the freshly injected prompt a moment to finish drawing before
218
+ // anything is typed into it.
219
+ await this.waitForSettle({ usePrompt: false })
220
+ return
221
+ }
222
+ if (item.type !== 'delayed_script') {
223
+ return
224
+ }
225
+ item.execute()
226
+ if (item.isLast) {
227
+ return
228
+ }
229
+ await this.sleep(item.delay)
230
+ // Never type the next script into a shell that is still busy. Bounded, so
231
+ // a script that never returns can not block the rest of the queue.
232
+ await this.waitForSettle()
233
+ }
234
+
235
+ /**
236
+ * Wait until the terminal stops producing output before typing the next
237
+ * queued item. Resolves on whichever comes first:
238
+ * - a fresh prompt (OSC 633 ;A), when shell integration is active - the
239
+ * exact "previous command finished" signal
240
+ * - no output for `idleMs`
241
+ * Always resolves; `timeoutMs` caps it.
242
+ */
243
+ waitForSettle = ({ timeoutMs = settleTimeoutMs, idleMs = settleIdleMs, usePrompt = true } = {}) => {
244
+ const addon = this.addon
245
+ if (this.isClosed() || !addon || !addon.waitForOutputIdle || addon.disposed) {
246
+ return Promise.resolve(false)
247
+ }
248
+ const idle = addon.waitForOutputIdle({ idleMs, timeoutMs })
249
+ if (!usePrompt || !this.cmdAddon || !this.cmdAddon.onPrompt) {
250
+ return idle
251
+ }
252
+ return new Promise((resolve) => {
253
+ let done = false
254
+ let unsubscribe = null
255
+ const finish = (result) => {
256
+ if (done) {
257
+ return
258
+ }
259
+ done = true
260
+ clearTimeout(capTimer)
261
+ if (unsubscribe) {
262
+ unsubscribe()
263
+ }
264
+ resolve(result)
265
+ }
266
+ unsubscribe = this.cmdAddon.onPrompt(() => finish('prompt'))
267
+ const capTimer = setTimeout(() => finish(false), timeoutMs + 200)
268
+ this.timers.add(capTimer)
269
+ idle.then(quiet => finish(quiet ? 'idle' : false))
270
+ })
271
+ }
272
+
273
+ // ---- shell integration ----
274
+
275
+ canInjectShellIntegration = () => {
276
+ const { config, sftpPathFollowSsh } = this.term.props
277
+ return (
278
+ config.showCmdSuggestions ||
279
+ sftpPathFollowSsh ||
280
+ (config.restoreTerminalSessionOnReload && this.isSsh())
281
+ ) &&
282
+ (
283
+ this.isSsh() ||
284
+ (this.isLocal() && !isWin)
285
+ )
286
+ }
287
+
288
+ /**
289
+ * Inject shell integration commands from client-side.
290
+ * Replaces the server-side `source xxx.xxx` approach; output suppression
291
+ * hides the injection command. Resolves when injection is complete - or
292
+ * when it is given up on, never hangs.
293
+ */
294
+ injectShellIntegration = async () => {
295
+ if (this.shellInjected) {
296
+ return
297
+ }
298
+
299
+ const addon = this.addon
300
+ if (!addon) {
301
+ return
302
+ }
303
+
304
+ let shellType
305
+ if (this.isLocal()) {
306
+ const { config } = this.term.props
307
+ const localShell = isMac ? config.execMac : config.execLinux
308
+ shellType = detectShellType(localShell)
309
+ } else if (this.isSsh()) {
310
+ shellType = await Promise.race([
311
+ this.detectRemoteShell(this.term.pid),
312
+ new Promise(resolve => {
313
+ setTimeout(() => resolve('sh'), remoteShellDetectTimeoutMs)
314
+ })
315
+ ])
316
+ }
317
+
318
+ this.shellType = shellType
319
+
320
+ // Don't inject for sh type shells unless sftpPathFollowSsh is true
321
+ if (shellType === 'sh' && !this.term.props.sftpPathFollowSsh) {
322
+ return
323
+ }
324
+
325
+ // The connection (or the whole terminal) can go away while
326
+ // detectRemoteShell is in flight. Bail instead of registering callbacks
327
+ // on a dead socket - that is what used to leave the queue, and every run
328
+ // script behind it, waiting forever.
329
+ if (this.isClosed() || this.addon !== addon || addon.disposed) {
330
+ return
331
+ }
332
+
333
+ const integrationCmd = getShellIntegrationCommand(shellType)
334
+ const suppressionTimeout = this.isSsh() ? 5000 : 3000
335
+
336
+ return new Promise((resolve) => {
337
+ let done = false
338
+ const finish = () => {
339
+ if (done) {
340
+ return
341
+ }
342
+ done = true
343
+ this.timers.delete(safetyTimer)
344
+ clearTimeout(safetyTimer)
345
+ resolve()
346
+ }
347
+ // Hard ceiling for the whole injection. onInitialData only fires once
348
+ // the shell produces output; when it never does (dead pty, restricted
349
+ // shell, zmodem in progress) the promise - and the run scripts queued
350
+ // behind it - would otherwise never be released.
351
+ const safetyTimer = setTimeout(() => {
352
+ if (addon.outputSuppressed) {
353
+ // Unfreeze the screen rather than keep hiding output.
354
+ addon.stopOutputSuppression(false)
355
+ }
356
+ finish()
357
+ }, suppressionTimeout + 2000)
358
+ this.timers.add(safetyTimer)
359
+
360
+ // Wait for initial data (prompt/banner) to arrive before injecting
361
+ addon.onInitialData(() => {
362
+ if (done || this.addon !== addon || addon.disposed) {
363
+ return finish()
364
+ }
365
+ // Start suppressing output before sending the integration command.
366
+ // This hides the command and its output until OSC 633 is detected.
367
+ addon.startOutputSuppression(suppressionTimeout, () => {
368
+ if (!addon.disposed) {
369
+ this.shellInjected = true
370
+ }
371
+ finish()
372
+ })
373
+ addon._sendData(integrationCmd)
374
+ })
375
+ })
376
+ }
377
+ }
378
+
379
+ export default StartupQueue
@@ -8,6 +8,9 @@ import TermInteractiveUI from './terminal-interactive-ui'
8
8
  import ExternalLink from '../common/external-link.jsx'
9
9
  import { notification } from '../common/notification'
10
10
 
11
+ // x11 warnings already shown, keyed by message text
12
+ const shownX11Warnings = new Set()
13
+
11
14
  export default function TermInteractive () {
12
15
  const [current, setCurrent] = useState(null)
13
16
  const queueRef = useRef([])
@@ -43,6 +46,28 @@ export default function TermInteractive () {
43
46
  })
44
47
  }
45
48
 
49
+ function notifyX11Warning (data) {
50
+ // one notification per distinct message, several tabs may report it
51
+ if (shownX11Warnings.has(data.message)) {
52
+ return
53
+ }
54
+ shownX11Warnings.add(data.message)
55
+ notification.warning({
56
+ message: 'X11 forwarding',
57
+ description: (
58
+ <div className='pd0 mg0'>
59
+ <div>{data.message}</div>
60
+ {
61
+ data.url
62
+ ? <ExternalLink to={data.url}>{data.url}</ExternalLink>
63
+ : null
64
+ }
65
+ </div>
66
+ ),
67
+ duration: 30
68
+ })
69
+ }
70
+
46
71
  function processNext () {
47
72
  const next = queueRef.current.shift()
48
73
  if (next) {
@@ -82,6 +107,13 @@ export default function TermInteractive () {
82
107
  e.data.includes('ssh-proxy-command-message')
83
108
  ) {
84
109
  notifyProxyCommandMessage(JSON.parse(e.data))
110
+ } else if (
111
+ e &&
112
+ e.data &&
113
+ typeof e.data === 'string' &&
114
+ e.data.includes('x11-warning')
115
+ ) {
116
+ notifyX11Warning(JSON.parse(e.data))
85
117
  }
86
118
  }
87
119