@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
@@ -1,117 +1,66 @@
1
1
  import { Component, createRef } from 'react'
2
- import { isEqual, pick, debounce, throttle } from 'lodash-es'
3
- import clone from '../../common/to-simple-obj.js'
4
- import resolve from '../../common/resolve.js'
2
+ import { isEqual, pick } from 'lodash-es'
5
3
  import {
6
4
  Spin,
7
5
  Dropdown,
8
6
  Button
9
7
  } from 'antd'
10
- import message from '../common/message'
11
- import { notification } from '../common/notification'
12
- import ShowItem from '../common/show-item.jsx'
13
- import Modal from '../common/modal'
14
8
  import classnames from 'classnames'
15
9
  import './terminal.styl'
16
10
  import {
17
- statusMap,
18
11
  paneMap,
19
- typeMap,
20
- isWin,
21
- rendererTypes,
22
- isMac,
23
- isMacJs,
24
- connectionMap,
25
- terminalSerialType
12
+ connectionMap
26
13
  } from '../../common/constants.js'
27
- import deepCopy from 'json-deep-copy'
28
- import { readClipboardAsync, readClipboard, copy } from '../../common/clipboard.js'
29
- import AttachAddon from './attach-addon-custom.js'
30
- import getProxy from '../../common/get-proxy.js'
31
- import { ZmodemClient } from './zmodem-client.js'
32
- import { TrzszClient } from './trzsz-client.js'
33
- import { XmodemClient } from './xmodem-client.js'
34
- import DropFileModal from './drop-file-modal.jsx'
35
- import keyControlPressed from '../../common/key-control-pressed.js'
36
- import NormalBuffer from './normal-buffer.jsx'
37
- import { createTerm, resizeTerm, startTerminalLogFile, toggleTerminalLog } from './terminal-apis.js'
38
- import { shortcutExtend, shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
39
- import { KeywordHighlighterAddon } from './highlight-addon.js'
40
- import { getFilePath, isUnsafeFilename } from '../../common/file-drop-utils.js'
41
- import { getFolderFromFilePath } from '../sftp/file-read.js'
42
- import { CommandTrackerAddon } from './command-tracker-addon.js'
43
- import { Osc52Addon } from './osc52-addon.js'
44
- import AIIcon from '../icons/ai-icon.jsx'
45
- import { isAIDisabled } from '../../common/ai-feature.js'
46
14
  import {
47
15
  AimOutlined
48
16
  } from '@ant-design/icons'
49
- import {
50
- getShellIntegrationCommand,
51
- detectRemoteShell,
52
- detectShellType
53
- } from './shell.js'
54
- import iconsMap from '../sys-menu/icons-map.jsx'
55
- import { refs, refsStatic } from '../common/ref.js'
56
- import ExternalLink from '../common/external-link.jsx'
17
+ import { refs } from '../common/ref.js'
18
+ import NormalBuffer from './normal-buffer.jsx'
57
19
  import createDefaultLogPath from '../../common/default-log-path.js'
58
20
  import SearchResultBar from './terminal-search-bar'
59
21
  import RemoteFloatControl from '../common/remote-float-control'
60
22
  import ReconnectOverlay from './reconnect-overlay.jsx'
61
23
  import TerminalErrorHandle from './terminal-error-handle.jsx'
62
- import {
63
- loadTerminal,
64
- loadFitAddon,
65
- loadWebLinksAddon,
66
- loadWebglAddon,
67
- loadSearchAddon,
68
- loadLigaturesAddon,
69
- loadUnicode11Addon,
70
- loadImageAddon
71
- } from './xterm-loader.js'
72
- import {
73
- createRendererThemeConfig,
74
- handleTerminalColorQuery,
75
- blendSelectionOverBackground
76
- } from './terminal-color-query.mjs'
24
+ import DropFileModal from './drop-file-modal.jsx'
25
+ import { StartupQueue } from './startup-queue.js'
26
+ import { detectRemoteShell } from './shell-detect.js'
27
+ import { shortcutExtend, shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
28
+ import { applyMixins } from './mixins/index.js'
29
+ import { initMixin } from './mixins/term-init.js'
30
+ import { resizeMixin } from './mixins/term-resize.js'
31
+ import { themeMixin } from './mixins/term-theme.js'
32
+ import { attachMixin } from './mixins/term-attach.js'
33
+ import { socketMixin } from './mixins/term-socket.js'
34
+ import { configMixin } from './mixins/term-config.jsx'
35
+ import { contextMenuMixin } from './mixins/term-context-menu.jsx'
36
+ import { contentMixin } from './mixins/term-content.js'
37
+ import { logMixin } from './mixins/term-log.jsx'
38
+ import { touchMixin } from './mixins/term-touch.js'
39
+ import { fileDropMixin } from './mixins/term-file-drop.js'
40
+ import { suggestionsMixin } from './mixins/term-suggestions.js'
77
41
 
78
42
  const e = window.translate
79
43
 
80
- // Expands \n \t \r \\ and \xHH hex byte escapes, used to let users type
81
- // control bytes (e.g. \x01 = Ctrl+A) in the serial "closeSequence" field.
82
- function expandCloseSequence (text) {
83
- let result = ''
84
- for (let i = 0; i < text.length; i++) {
85
- if (text[i] === '\\' && i + 1 < text.length) {
86
- const next = text[i + 1]
87
- if (next === 'n') {
88
- result += '\n'
89
- i++
90
- } else if (next === 't') {
91
- result += '\t'
92
- i++
93
- } else if (next === 'r') {
94
- result += '\r'
95
- i++
96
- } else if (next === '\\') {
97
- result += '\\'
98
- i++
99
- } else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
100
- result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
101
- i += 3
102
- } else {
103
- result += text[i]
104
- }
105
- } else {
106
- result += text[i]
107
- }
108
- }
109
- return result
110
- }
44
+ // The terminal behaviour, grouped by concern - see ./mixins/index.js
45
+ const mixins = [
46
+ initMixin,
47
+ resizeMixin,
48
+ themeMixin,
49
+ attachMixin,
50
+ socketMixin,
51
+ configMixin,
52
+ contextMenuMixin,
53
+ contentMixin,
54
+ logMixin,
55
+ touchMixin,
56
+ fileDropMixin,
57
+ suggestionsMixin
58
+ ]
111
59
 
112
60
  class Term extends Component {
113
61
  constructor (props) {
114
62
  super(props)
63
+ applyMixins(this, mixins)
115
64
  this.state = {
116
65
  loading: false,
117
66
  hasSelection: false,
@@ -135,14 +84,48 @@ class Term extends Component {
135
84
  this.id = `term-${this.props.tab.id}`
136
85
  refs.add(this.id, this)
137
86
  this.currentInput = ''
138
- this.shellInjected = false
139
- this.shellType = null
87
+ // Owns the shell integration injection + cd + runScripts sequence.
88
+ this.startupQueue = new StartupQueue(this, { detectRemoteShell })
140
89
  }
141
90
 
142
91
  domRef = createRef()
143
92
 
93
+ timers = {}
94
+
95
+ // Config keys that cannot be applied by a re-render - they are pushed into
96
+ // the live xterm options instead.
97
+ terminalConfigProps = [
98
+ {
99
+ name: 'rightClickSelectsWord',
100
+ type: 'glob'
101
+ },
102
+ {
103
+ name: 'fontSize',
104
+ type: 'glob_local'
105
+ },
106
+ {
107
+ name: 'fontFamily',
108
+ type: 'glob_local'
109
+ }
110
+ ]
111
+
112
+ // ---- Mobile touch support ----
113
+ longPressTimer = null
114
+ touchStartPos = null
115
+ longPressFired = false
116
+ longPressThreshold = 500 // ms
117
+ longPressMoveTolerance = 10 // px
118
+
119
+ terminalColorQueryDisposables = []
120
+
144
121
  componentDidMount () {
145
- this.initTerminal()
122
+ // xterm and every addon are dynamic imports: a failed chunk fetch would
123
+ // otherwise end as a silent unhandled rejection with a permanently blank
124
+ // terminal pane
125
+ this.initTerminal().catch(e => {
126
+ console.error(e)
127
+ this.handleError({ message: e.message })
128
+ })
146
129
  if (this.props.tab.enableSsh === false) {
147
130
  this.props.tab.pane = paneMap.fileManager
148
131
  }
@@ -187,6 +170,12 @@ class Term extends Component {
187
170
  prevProps,
188
171
  this.props
189
172
  )
173
+ if (
174
+ prevProps.tab?.triggers !== this.props.tab?.triggers &&
175
+ this.triggerManager
176
+ ) {
177
+ this.refreshTriggers()
178
+ }
190
179
  const themeChanged = !isEqual(
191
180
  this.props.themeConfig,
192
181
  prevProps.themeConfig
@@ -214,6 +203,7 @@ class Term extends Component {
214
203
  this.term.parent = null
215
204
  }
216
205
  this.disposeTerminalColorQueryHandlers()
206
+ this.startupQueue?.dispose()
217
207
  window.cancelAnimationFrame(this.timers.themeRaf)
218
208
  this.timers.themeRaf = null
219
209
  Object.keys(this.timers).forEach(k => {
@@ -230,6 +220,7 @@ class Term extends Component {
230
220
  this.term = null
231
221
  }
232
222
  this.attachAddon = null
223
+ this.disposeTriggerManager?.()
233
224
  this.fitAddon = null
234
225
  this.zmodemClient = null
235
226
  this.trzszClient = null
@@ -237,6 +228,7 @@ class Term extends Component {
237
228
  this.searchAddon = null
238
229
  this.fitAddon = null
239
230
  this.cmdAddon = null
231
+ this.serializeAddon = null
240
232
  this.imageAddon = null
241
233
  this.webglContextLossDisposable?.dispose?.()
242
234
  this.webglContextLossDisposable = null
@@ -244,1920 +236,42 @@ class Term extends Component {
244
236
  this.webglRecovering = false
245
237
  }
246
238
 
247
- terminalConfigProps = [
248
- {
249
- name: 'rightClickSelectsWord',
250
- type: 'glob'
251
- },
252
- {
253
- name: 'fontSize',
254
- type: 'glob_local'
255
- },
256
- {
257
- name: 'fontFamily',
258
- type: 'glob_local'
259
- }
260
- ]
261
-
262
- initAttachAddon = async () => {
263
- this.attachAddon = new AttachAddon(
264
- this.term,
265
- this.socket,
266
- isWin && !this.isRemote()
267
- )
268
- this.attachAddon.decoder = new TextDecoder(
269
- this.encode || this.props.tab.encode || 'utf-8'
270
- )
271
- await this.attachAddon.activate(this.term)
272
- if (this.osc52Addon) {
273
- this.osc52Addon.setSendData(this.attachAddon._sendData.bind(this.attachAddon))
274
- }
275
- }
276
-
277
- getValue = (props, type, name) => {
278
- return type === 'glob'
279
- ? props.config[name]
280
- : props.tab[name] || props.config[name]
281
- }
282
-
283
- checkConfigChange = (prevProps, props) => {
284
- for (const k of this.terminalConfigProps) {
285
- const { name, type } = k
286
- const prev = this.getValue(prevProps, type, name)
287
- const curr = this.getValue(props, type, name)
288
- if (
289
- prev !== curr
290
- ) {
291
- this.term.options[name] = curr
292
- if (['fontFamily', 'fontSize'].includes(name)) {
293
- this.onResize()
294
- }
295
- if (name === 'fontSize') {
296
- this.originalFontSize = curr
297
- this.setState({ fontSizeChanged: false })
298
- }
299
- }
300
- }
301
-
302
- // Handle renderer type changes (dom <-> webGL) by reloading the
303
- // renderer and refreshing the theme so the background color is
304
- // correct for the new renderer.
305
- if (
306
- prevProps.config.rendererType !== props.config.rendererType &&
307
- this.term
308
- ) {
309
- this.reloadWebglRenderer('renderer type change')
310
- .then(() => this.applyTerminalTheme())
311
- .catch(e => console.error('renderer type change failed', e))
312
- }
313
-
314
- // Check for shell integration related config changes
315
- const prevShowSuggestions = prevProps.config.showCmdSuggestions
316
- const currShowSuggestions = props.config.showCmdSuggestions
317
- const prevSftpFollow = prevProps.sftpPathFollowSsh
318
- const currSftpFollow = props.sftpPathFollowSsh
319
-
320
- if (
321
- (!prevShowSuggestions && currShowSuggestions) ||
322
- (!prevSftpFollow && currSftpFollow)
323
- ) {
324
- // Config was toggled to true, try to inject shell integration if not already done
325
- if (this.canInjectShellIntegration() && !this.shellInjected) {
326
- // If there's an active execution queue, add to it
327
- if (this.executionQueue && this.executionQueue.length > 0) {
328
- this.executionQueue.unshift({
329
- type: 'shell_integration',
330
- execute: async () => {
331
- await this.injectShellIntegration()
332
- if (currSftpFollow) {
333
- this.attachAddon._sendData('\r')
334
- }
335
- }
336
- })
337
- } else {
338
- // No active queue, inject directly
339
- this.injectShellIntegration().then(() => {
340
- if (currSftpFollow) {
341
- this.attachAddon._sendData('\r')
342
- }
343
- })
344
- }
345
- } else if (this.shellInjected && currSftpFollow) {
346
- this.getCwd()
347
- }
348
- }
349
- if (
350
- !prevSftpFollow &&
351
- currSftpFollow &&
352
- this.isLocal() &&
353
- isWin
354
- ) {
355
- return this.warnSftpFollowUnsupported()
356
- }
357
- }
358
-
359
- timers = {}
239
+ // ---- small helpers other mixins build on ----
360
240
 
361
- getDomId = () => {
241
+ getDomId () {
362
242
  return `term-${this.props.tab.id}`
363
243
  }
364
244
 
365
- zoom = (v) => {
366
- const { term } = this
367
- if (!term) {
368
- return
369
- }
370
- term.options.fontSize = term.options.fontSize + v
371
- window.store.triggerResize()
372
- if (this.originalFontSize == null) {
373
- this.originalFontSize = term.options.fontSize - v
374
- }
375
- this.setState({
376
- fontSizeChanged: term.options.fontSize !== this.originalFontSize
377
- })
378
- }
379
-
380
- handleResetFontSize = () => {
381
- const { term } = this
382
- if (!term || this.originalFontSize == null) {
383
- return
384
- }
385
- term.options.fontSize = this.originalFontSize
386
- window.store.triggerResize()
387
- this.setState({ fontSizeChanged: false })
388
- term.focus()
389
- }
390
-
391
- isActiveTerminal = () => {
245
+ isActiveTerminal () {
392
246
  return this.props.tab.id === this.props.activeTabId &&
393
247
  this.props.tab.pane === paneMap.terminal
394
248
  }
395
249
 
396
- clearShortcut = (e) => {
397
- e.stopPropagation()
398
- this.onClear()
399
- }
400
-
401
- // selectAllShortcut = (e) => {
402
- // e.stopPropagation()
403
- // this.term.selectAll()
404
- // }
405
-
406
- copyShortcut = (e) => {
407
- const sel = this.term.getSelection()
408
- if (sel) {
409
- e.stopPropagation()
410
- this.copySelectionToClipboard()
411
- return false
412
- }
413
- }
414
-
415
- searchShortcut = (e) => {
416
- e.stopPropagation()
417
- this.toggleSearch()
418
- }
419
-
420
- pasteSelectedShortcut = (e) => {
421
- e.stopPropagation()
422
- this.tryInsertSelected()
423
- }
424
-
425
- pasteTextTooLong = () => {
426
- if (window.et.isWebApp) {
427
- return false
428
- }
429
- const text = readClipboard()
430
- return text.length > 500
431
- }
432
-
433
- askUserConfirm = () => {
434
- Modal.confirm({
435
- title: e('paste'),
436
- content: (
437
- <div>
438
- <p>{e('paste')}:</p>
439
- <div className='paste-text'>
440
- <pre>
441
- <code>{readClipboard()}</code>
442
- </pre>
443
- </div>
444
- </div>
445
- ),
446
- okText: e('ok'),
447
- cancelText: e('cancel'),
448
- onOk: () => this.onPaste(true)
449
- })
450
- }
451
-
452
- warnSftpFollowUnsupported = () => {
453
- message.warning(
454
- <span>
455
- Fish shell/windows shell is not supported for SFTP follow SSH path feature. See: <ExternalLink to='https://github.com/electerm/electerm/wiki/Warning-about-sftp-follow-ssh-path-function'>wiki</ExternalLink>
456
- </span>
457
- , 7)
458
- }
459
-
460
- pasteShortcut = (e) => {
461
- if (this.pasteTextTooLong()) {
462
- this.askUserConfirm()
463
- e.preventDefault()
464
- e.stopPropagation()
465
- return false
466
- }
467
- if (isMac) {
468
- return true
469
- }
470
- if (!this.isRemote()) {
471
- return true
472
- }
473
- if (this.term.buffer.active.type !== 'alternate') {
474
- return false
475
- }
476
- return true
477
- }
478
-
479
- showNormalBufferShortcut = (e) => {
480
- e.stopPropagation()
481
- this.openNormalBuffer()
482
- }
483
-
484
- runQuickCommand = (cmd, inputOnly = false) => {
485
- if (this.term && this.attachAddon) {
486
- this.attachAddon._sendData(cmd + (inputOnly ? '' : '\r'))
487
- this.term.focus()
488
- }
489
- }
490
-
491
- cd = (p) => {
492
- if (isUnsafeFilename(p)) {
493
- return message.error('File name contains unsafe characters')
494
- }
495
- const isWinPath = /^[a-zA-Z]:\\/.test(p)
496
- this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
497
- }
498
-
499
- onDrop = e => {
500
- const dt = e.dataTransfer
501
- const fromFile = dt.getData('fromFile')
502
- const notSafeMsg = 'File name contains unsafe characters'
503
- const isSshTerminal = this.props.tab.type === connectionMap.ssh
504
- const isSerialTerminal = this.props.tab.type === connectionMap.serial
505
-
506
- if (fromFile) {
507
- try {
508
- const fileData = JSON.parse(fromFile)
509
- const filePath = resolve(fileData.path, fileData.name)
510
- if (isUnsafeFilename(filePath)) {
511
- message.error(notSafeMsg)
512
- return
513
- }
514
- if (isSshTerminal) {
515
- const behavior = this.props.config.dragDropBehavior || 'ask'
516
- if (behavior === 'ask') {
517
- this.setState({
518
- dropFileModalVisible: true,
519
- droppedFiles: [{ path: filePath, isRemote: true }]
520
- })
521
- } else {
522
- this.handleDropFileAction(behavior, [{ path: filePath, isRemote: true }])
523
- }
524
- return
525
- }
526
- if (isSerialTerminal) {
527
- this.setState({
528
- dropFileModalVisible: true,
529
- droppedFiles: [{ path: filePath, isRemote: false }]
530
- })
531
- return
532
- }
533
- this.attachAddon._sendData(`"${filePath}" `)
534
- return
535
- } catch (e) {
536
- console.error('Failed to parse fromFile data:', e)
537
- }
538
- }
539
-
540
- const files = dt.files
541
- if (files && files.length) {
542
- const arr = Array.from(files)
543
- const filePaths = arr.map(f => getFilePath(f))
544
-
545
- const hasUnsafeFilename = filePaths.some(path => isUnsafeFilename(path))
546
- if (hasUnsafeFilename) {
547
- message.error(notSafeMsg)
548
- return
549
- }
550
-
551
- if (isSshTerminal) {
552
- const behavior = this.props.config.dragDropBehavior || 'ask'
553
- if (behavior === 'ask') {
554
- this.setState({
555
- dropFileModalVisible: true,
556
- droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
557
- })
558
- } else {
559
- this.handleDropFileAction(behavior, filePaths.map(path => ({ path, isRemote: false })))
560
- }
561
- return
562
- }
563
-
564
- if (isSerialTerminal) {
565
- this.setState({
566
- dropFileModalVisible: true,
567
- droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
568
- })
569
- return
570
- }
571
-
572
- const filesAll = filePaths.map(path => `"${path}"`).join(' ')
573
- this.attachAddon._sendData(filesAll)
574
- }
575
- }
576
-
577
- handleDropFileModalCancel = () => {
578
- this.setState({
579
- dropFileModalVisible: false,
580
- droppedFiles: []
581
- })
582
- }
583
-
584
- handleDropFileAction = (action, filesOverride) => {
585
- const droppedFiles = filesOverride || this.state.droppedFiles
586
- if (!droppedFiles || !droppedFiles.length) {
587
- this.handleDropFileModalCancel()
588
- return
589
- }
590
-
591
- const filePaths = droppedFiles.map(f => f.path)
592
-
593
- switch (action) {
594
- case 'trz': {
595
- if (this.trzszClient && this.trzszClient.isActive) {
596
- message.warning('A transfer is already in progress')
597
- this.handleDropFileModalCancel()
598
- return
599
- }
600
- window._apiControlSelectFile = filePaths
601
- this.attachAddon._sendData('trz\r')
602
- break
603
- }
604
- case 'rz':{
605
- if (this.zmodemClient && this.zmodemClient.isActive) {
606
- message.warning('A transfer is already in progress')
607
- this.handleDropFileModalCancel()
608
- return
609
- }
610
- window._apiControlSelectFile = filePaths
611
- this.attachAddon._sendData('rz\r')
612
- break
613
- }
614
- case 'xmodem': {
615
- if (this.xmodemClient && this.xmodemClient.isActive) {
616
- message.warning('A transfer is already in progress')
617
- this.handleDropFileModalCancel()
618
- return
619
- }
620
- // Use XMODEM send with the dropped files
621
- window._apiControlSelectFile = filePaths
622
- if (this.xmodemClient) {
623
- this.xmodemClient.initiateSend()
624
- }
625
- break
626
- }
627
- case 'inputOnly':
628
- default: {
629
- const filesAll = filePaths.map(path => `"${path}"`).join(' ')
630
- this.attachAddon._sendData(filesAll)
631
- break
632
- }
633
- }
634
-
635
- this.handleDropFileModalCancel()
636
- }
637
-
638
- onSelection = () => {
639
- if (
640
- !this.props.config.copyWhenSelect ||
641
- window.store.onOperation
642
- ) {
643
- return false
644
- }
645
- this.copySelectionToClipboard()
646
- }
647
-
648
- copySelectionToClipboard = () => {
649
- const txt = this.term.getSelection()
650
- if (txt) {
651
- copy(txt)
652
- }
653
- }
654
-
655
- tryInsertSelected = () => {
656
- const txt = this.term.getSelection()
657
- if (txt) {
658
- this.attachAddon._sendData(txt)
659
- }
660
- }
661
-
662
- webLinkHandler = (event, url) => {
663
- if (event?.button === 2) {
664
- return false
665
- }
666
- if (!this.props.config.ctrlOrMetaOpenTerminalLink) {
667
- return window.openLink(url, '_blank')
668
- }
669
- if (keyControlPressed(event)) {
670
- window.openLink(url, '_blank')
671
- }
672
- }
673
-
674
- // ---- Mobile touch support ----
675
- // On touch devices, long-press should (1) select the word under the finger
676
- // and (2) open the context menu — mirroring desktop right-click behaviour.
677
- // xterm.js only uses touch events for scrolling, so we add explicit
678
- // long-press detection here.
679
- longPressTimer = null
680
- touchStartPos = null
681
- longPressFired = false
682
- longPressThreshold = 500 // ms
683
- longPressMoveTolerance = 10 // px
684
-
685
- onTouchStart = (e) => {
686
- if (e.touches.length !== 1) {
687
- return
688
- }
689
- const touch = e.touches[0]
690
- this.touchStartPos = {
691
- clientX: touch.clientX,
692
- clientY: touch.clientY,
693
- target: e.currentTarget
694
- }
695
- this.longPressFired = false
696
- clearTimeout(this.longPressTimer)
697
- this.longPressTimer = setTimeout(() => {
698
- this.handleLongPress()
699
- }, this.longPressThreshold)
250
+ isRemote () {
251
+ return this.props.tab?.host
700
252
  }
701
253
 
702
- onTouchMove = (e) => {
703
- if (!this.touchStartPos) {
704
- return
705
- }
706
- const touch = e.touches[0]
707
- const dx = touch.clientX - this.touchStartPos.clientX
708
- const dy = touch.clientY - this.touchStartPos.clientY
709
- if (Math.sqrt(dx * dx + dy * dy) > this.longPressMoveTolerance) {
710
- clearTimeout(this.longPressTimer)
711
- this.longPressTimer = null
712
- this.touchStartPos = null
713
- }
254
+ isSsh () {
255
+ const { host, type } = this.props.tab
256
+ return host && (type === 'ssh' || type === undefined)
714
257
  }
715
258
 
716
- onTouchEnd = () => {
717
- const wasTap = this.touchStartPos && !this.longPressFired
718
- clearTimeout(this.longPressTimer)
719
- this.longPressTimer = null
720
- this.touchStartPos = null
721
- // xterm's own touch (Gesture) handler calls preventDefault()/stopPropagation()
722
- // on touchstart/touchend, which suppresses the synthesised mousedown xterm
723
- // relies on to focus its hidden helper textarea. As a result a tap never
724
- // focuses the terminal on touch devices, so the soft keyboard never opens
725
- // and you cannot type. Focus explicitly on a clean tap (not a long-press,
726
- // not a scroll) so mobile input works. The focus() runs synchronously inside
727
- // this user-gesture handler, so iOS/Android will show the keyboard.
728
- if (wasTap && this.term) {
729
- this.term.focus()
730
- }
259
+ isLocal () {
260
+ const { host, type } = this.props.tab
261
+ return !host &&
262
+ (type === 'local' || type === undefined)
731
263
  }
732
264
 
733
- handleLongPress = () => {
734
- if (!this.touchStartPos || this.state.loading) {
735
- return
736
- }
737
- this.longPressFired = true
738
- const { clientX, clientY, target } = this.touchStartPos
739
-
740
- // Select the word at the touch position (same as desktop right-click word
741
- // select) so the user can immediately copy or act on it.
742
- this.selectWordAt(clientX, clientY)
743
-
744
- // Respect pasteWhenContextMenu: when enabled, long-press pastes instead
745
- // of showing the menu (same as desktop right-click).
746
- if (this.props.config.pasteWhenContextMenu) {
747
- this.onPaste()
748
- return
749
- }
750
-
751
- // Dispatch a synthetic contextmenu event so antd Dropdown's contextMenu
752
- // trigger opens the menu at the exact finger position.
753
- const event = new MouseEvent('contextmenu', {
754
- bubbles: true,
755
- cancelable: true,
756
- clientX,
757
- clientY
265
+ setStatus = status => {
266
+ const id = this.props.tab?.id
267
+ this.props.editTab(id, {
268
+ status
758
269
  })
759
- target.dispatchEvent(event)
760
- }
761
-
762
- selectWordAt = (clientX, clientY) => {
763
- if (!this.term) {
764
- return
765
- }
766
- const termElement = this.term.element
767
- if (!termElement) {
768
- return
769
- }
770
- const rect = termElement.getBoundingClientRect()
771
- const x = clientX - rect.left
772
- const y = clientY - rect.top
773
- if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
774
- return
775
- }
776
-
777
- const cellWidth = rect.width / this.term.cols
778
- const cellHeight = rect.height / this.term.rows
779
- const col = Math.floor(x / cellWidth)
780
- const row = Math.floor(y / cellHeight)
781
-
782
- const buffer = this.term.buffer.active
783
- const line = buffer.getLine(row)
784
- if (!line) {
785
- return
786
- }
787
-
788
- const text = line.translateToString(true)
789
- const wordSeparator = this.props.config.terminalWordSeparator ||
790
- ' ./\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?'
791
-
792
- // If the touched cell is empty or a separator, nothing to select
793
- if (col >= text.length || wordSeparator.includes(text[col])) {
794
- return
795
- }
796
-
797
- // Find word start
798
- let start = col
799
- while (start > 0 && !wordSeparator.includes(text[start - 1])) {
800
- start--
801
- }
802
- // Find word end
803
- let end = col
804
- while (end < text.length && !wordSeparator.includes(text[end])) {
805
- end++
806
- }
807
-
808
- if (end > start) {
809
- this.term.select(start, row, end - start)
810
- }
811
- }
812
-
813
- onContextMenuInner = e => {
814
- e.preventDefault()
815
- if (this.state.loading) {
816
- return
817
- }
818
- if (this.props.config.pasteWhenContextMenu) {
819
- return this.onPaste()
820
- }
821
- }
822
-
823
- onCopy = () => {
824
- const selected = this.term.getSelection()
825
- copy(selected)
826
- this.term.focus()
827
- }
828
-
829
- onSelectAll = () => {
830
- this.term.selectAll()
831
- }
832
-
833
- onClear = () => {
834
- const shouldClear = this.searchAddon &&
835
- window.store.termSearchOpen &&
836
- window.store.termSearch
837
- if (
838
- shouldClear
839
- ) {
840
- this.searchAddon.clearDecorations()
841
- }
842
- this.term.clear()
843
- this.term.focus()
844
- if (shouldClear) {
845
- this.searchAddon._lineCache.clear()
846
- this.timers.clearSearchTimer = setTimeout(() => {
847
- refsStatic.get('term-search')?.next()
848
- }, 100)
849
- }
850
270
  }
851
271
 
852
- isRemote = () => {
853
- return this.props.tab?.host
854
- }
855
-
856
- onPaste = async (skipTextLengthCheck) => {
857
- let selected = await readClipboardAsync()
858
- if (!skipTextLengthCheck && selected.length > 500) {
859
- return this.askUserConfirm()
860
- }
861
- if (isWin && this.isRemote()) {
862
- selected = selected.replace(/\r\n/g, '\n')
863
- }
864
- this.term.paste(selected || '')
865
- this.term.focus()
866
- }
867
-
868
- onPasteSelected = () => {
869
- const selected = this.term.getSelection()
870
- this.term.paste(selected || '')
871
- this.term.focus()
872
- }
873
-
874
- toggleSearch = () => {
875
- window.store.toggleTerminalSearch()
876
- }
877
-
878
- toggleKeepalive = () => {
879
- if (!this.attachAddon) {
880
- return false
881
- }
882
- this._keepaliveEnabled = !this._keepaliveEnabled
883
- this.attachAddon.setKeepalive(this._keepaliveEnabled)
884
- return this._keepaliveEnabled
885
- }
886
-
887
- onSearchResultsChange = ({ resultIndex, resultCount }) => {
888
- window.store.storeAssign({
889
- termSearchMatchCount: resultCount,
890
- termSearchMatchIndex: resultIndex
891
- })
892
-
893
- this.updateSearchResults(resultIndex)
894
- }
895
-
896
- updateSearchResults = (resultIndex) => {
897
- const matches = this.searchAddon._resultTracker.searchResults.map((result, i) => {
898
- return result.row
899
- })
900
-
901
- this.setState({
902
- searchResults: matches,
903
- matchIndex: resultIndex,
904
- totalLines: this.term.buffer.active.length
905
- })
906
- }
907
-
908
- searchPrev = (searchInput, options) => {
909
- this.searchAddon.findPrevious(
910
- searchInput, options
911
- )
912
- }
913
-
914
- searchNext = (searchInput, options) => {
915
- this.searchAddon.findNext(
916
- searchInput, options
917
- )
918
- }
919
-
920
- explainWithAi = () => {
921
- window.store.explainWithAi(
922
- this.term.getSelection()
923
- )
924
- }
925
-
926
- getTerminalBufferText = () => {
927
- const { addTimeStampToTermLog } = this.state
928
- const buffer = this.term.buffer.active
929
- const len = buffer.length
930
- const rawLines = []
931
- for (let i = 0; i < len; i++) {
932
- const line = buffer.getLine(i)
933
- rawLines.push(line ? line.translateToString(false) : '')
934
- }
935
- // trim trailing blank lines before applying timestamps
936
- while (rawLines.length && !rawLines[rawLines.length - 1].trim()) {
937
- rawLines.pop()
938
- }
939
- if (!addTimeStampToTermLog) {
940
- return rawLines.join('\n')
941
- }
942
- return rawLines.map(text => {
943
- const now = new Date()
944
- const ts = `[${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}] `
945
- return ts + text
946
- }).join('\n')
947
- }
948
-
949
- syncTermInfo = (stateUpdate) => {
950
- this.setState(stateUpdate)
951
- const infoUpdate = pick(stateUpdate, ['saveTerminalLogToFile', 'addTimeStampToTermLog', 'logPath', 'logFileName'])
952
- if (Object.keys(infoUpdate).length) {
953
- refs.get('term-info-' + this.props.tab.id)?.setState(infoUpdate)
954
- }
955
- }
956
-
957
- openLogSaveDialog = async (titleKey) => {
958
- const { logName } = this.props
959
- const result = await window.api.saveDialog({
960
- title: e(titleKey),
961
- defaultPath: logName + '.log',
962
- filters: [
963
- { name: 'Log files', extensions: ['log'] }
964
- ],
965
- properties: ['createDirectory', 'showOverwriteConfirmation']
966
- })
967
- if (result.canceled || !result.filePath) {
968
- return null
969
- }
970
- return result.filePath
971
- }
972
-
973
- onSaveTerminalLog = async () => {
974
- const filePath = await this.openLogSaveDialog('saveTerminalLogToFile')
975
- if (!filePath) {
976
- return
977
- }
978
- const content = this.getTerminalBufferText()
979
- await window.fs.writeFile(filePath, content).catch(window.store.onError)
980
- const { addTimeStampToTermLog } = this.state
981
- startTerminalLogFile(this.pid, filePath, addTimeStampToTermLog).catch(window.store.onError)
982
- const { path: logPath, name: logFileName } = getFolderFromFilePath(filePath, false)
983
- this.syncTermInfo({ saveTerminalLogToFile: true, logPath, logFileName })
984
- notification.success({
985
- message: e('saveTerminalLogToFile'),
986
- description: <ShowItem to={filePath}>{filePath}</ShowItem>,
987
- duration: 5
988
- })
989
- }
990
-
991
- onRecord = async () => {
992
- const filePath = await this.openLogSaveDialog('record')
993
- if (!filePath) {
994
- return
995
- }
996
- const { addTimeStampToTermLog } = this.state
997
- startTerminalLogFile(this.pid, filePath, addTimeStampToTermLog).catch(window.store.onError)
998
- const { path: logPath, name: logFileName } = getFolderFromFilePath(filePath, false)
999
- this.syncTermInfo({ saveTerminalLogToFile: true, logPath, logFileName })
1000
- this.setState({ recording: true, recordingFilePath: filePath })
1001
- notification.success({
1002
- message: e('record'),
1003
- description: <ShowItem to={filePath}>{filePath}</ShowItem>,
1004
- duration: 5
1005
- })
1006
- }
1007
-
1008
- onStopRecord = () => {
1009
- const { recordingFilePath } = this.state
1010
- toggleTerminalLog(this.pid).catch(window.store.onError)
1011
- this.syncTermInfo({ saveTerminalLogToFile: false })
1012
- this.setState({ recording: false, recordingFilePath: '' })
1013
- notification.success({
1014
- message: e('stopRecord'),
1015
- description: <ShowItem to={recordingFilePath}>{recordingFilePath}</ShowItem>
1016
- })
1017
- }
1018
-
1019
- renderContextMenu = () => {
1020
- const { hasSelection, recording } = this.state
1021
- const copyed = true
1022
- const copyShortcut = this.getShortcut('terminal_copy')
1023
- const pasteShortcut = this.getShortcut('terminal_paste')
1024
- const clearShortcut = this.getShortcut('terminal_clear')
1025
- const searchShortcut = this.getShortcut('terminal_search')
1026
- const selectAllShortcut = isMacJs ? 'meta+a' : 'ctrl+shift+a'
1027
- const isSerial = this.props.tab?.type === connectionMap.serial
1028
- const items = [
1029
- {
1030
- key: 'onCopy',
1031
- icon: <iconsMap.CopyOutlined />,
1032
- label: e('copy'),
1033
- disabled: !hasSelection,
1034
- extra: copyShortcut
1035
- },
1036
- {
1037
- key: 'onPaste',
1038
- icon: <iconsMap.SwitcherOutlined />,
1039
- label: e('paste'),
1040
- disabled: !copyed,
1041
- extra: pasteShortcut
1042
- },
1043
- {
1044
- key: 'onPasteSelected',
1045
- icon: <iconsMap.SwitcherOutlined />,
1046
- label: e('pasteSelected'),
1047
- disabled: !hasSelection
1048
- },
1049
- {
1050
-
1051
- key: 'onSelectAll',
1052
- icon: <iconsMap.CheckSquareOutlined />,
1053
- label: e('selectall'),
1054
- extra: selectAllShortcut
1055
- },
1056
- ...(
1057
- isAIDisabled()
1058
- ? []
1059
- : [{
1060
- key: 'explainWithAi',
1061
- icon: <AIIcon />,
1062
- label: e('explainWithAi'),
1063
- disabled: !hasSelection
1064
- }]
1065
- ),
1066
- {
1067
- key: 'onClear',
1068
- icon: <iconsMap.ReloadOutlined />,
1069
- label: e('clear'),
1070
- extra: clearShortcut
1071
- },
1072
- {
1073
- key: 'toggleSearch',
1074
- icon: <iconsMap.SearchOutlined />,
1075
- label: e('search'),
1076
- extra: searchShortcut
1077
- },
1078
- {
1079
- key: 'onSaveTerminalLog',
1080
- icon: <iconsMap.SaveOutlined />,
1081
- label: e('saveTerminalLogToFile')
1082
- },
1083
- {
1084
- key: recording ? 'onStopRecord' : 'onRecord',
1085
- icon: recording ? <iconsMap.StopOutlined /> : <iconsMap.PlayCircleFilled />,
1086
- label: e(recording ? 'stopRecord' : 'record')
1087
- }
1088
- ]
1089
- if (isSerial) {
1090
- items.push(
1091
- {
1092
- type: 'divider'
1093
- },
1094
- {
1095
- key: 'onXmodemSend',
1096
- icon: <iconsMap.CloudUploadOutlined />,
1097
- label: 'XMODEM Send'
1098
- },
1099
- {
1100
- key: 'onXmodemReceive',
1101
- icon: <iconsMap.CloudDownloadOutlined />,
1102
- label: 'XMODEM Receive'
1103
- }
1104
- )
1105
- }
1106
- return items
1107
- }
1108
-
1109
- onContextMenu = ({ key }) => {
1110
- this[key]()
1111
- }
1112
-
1113
- onXmodemSend = () => {
1114
- if (this.xmodemClient) {
1115
- this.xmodemClient.initiateSend()
1116
- }
1117
- this.term.focus()
1118
- }
1119
-
1120
- onXmodemReceive = () => {
1121
- if (this.xmodemClient) {
1122
- this.xmodemClient.initiateReceive()
1123
- }
1124
- this.term.focus()
1125
- }
1126
-
1127
- notifyOnData = debounce(() => {
1128
- window.store.notifyTabOnData(this.props.tab.id)
1129
- }, 1000)
1130
-
1131
- parse (rawText) {
1132
- let result = ''
1133
- const len = rawText.length
1134
- for (let i = 0; i < len; i++) {
1135
- if (rawText[i] === '\b') {
1136
- result = result.slice(0, -1)
1137
- } else {
1138
- result += rawText[i]
1139
- }
1140
- }
1141
- return result
1142
- }
1143
-
1144
- getCmd = () => {
1145
- return this.cmdAddon.getCurrentCommand()
1146
- }
1147
-
1148
- getCwd = () => {
1149
- // Use shell integration CWD if available
1150
- if (this.cmdAddon && this.cmdAddon.hasShellIntegration()) {
1151
- const cwd = this.cmdAddon.getCwd()
1152
- if (cwd) {
1153
- this.setCwd(cwd)
1154
- return cwd
1155
- }
1156
- }
1157
- // Fallback: no longer needed with shell integration
1158
- return ''
1159
- }
1160
-
1161
- setCwd = (cwd) => {
1162
- this.props.setCwd(cwd, this.state.id)
1163
- }
1164
-
1165
- getCursorPosition = () => {
1166
- if (!this.term) return null
1167
-
1168
- // Get the active buffer and cursor position
1169
- const buffer = this.term.buffer.active
1170
- const cursorRow = buffer.cursorY
1171
- const cursorCol = buffer.cursorX
1172
-
1173
- // Get dimensions from term element
1174
- const termElement = this.term.element
1175
- if (!termElement) return null
1176
-
1177
- // Get the exact position of the terminal element
1178
- const termRect = termElement.getBoundingClientRect()
1179
-
1180
- // Calculate cell dimensions
1181
- const cellWidth = termRect.width / this.term.cols
1182
- const cellHeight = termRect.height / this.term.rows
1183
-
1184
- // Calculate absolute position relative to terminal element
1185
- const left = Math.floor(termRect.left + (cursorCol * cellWidth))
1186
- const top = Math.floor(termRect.top + ((cursorRow + 1) * cellHeight))
1187
-
1188
- return {
1189
- cellWidth,
1190
- cellHeight,
1191
- left,
1192
- top
1193
- }
1194
- }
1195
-
1196
- closeSuggestions = () => {
1197
- refsStatic
1198
- .get('terminal-suggestions')
1199
- ?.closeSuggestions()
1200
- }
1201
-
1202
- openSuggestions = (cursorPos, data) => {
1203
- refsStatic
1204
- .get('terminal-suggestions')
1205
- ?.openSuggestions(cursorPos, data)
1206
- }
1207
-
1208
- /**
1209
- * Read current input directly from terminal buffer
1210
- * This is more reliable than tracking character-by-character
1211
- */
1212
- getCurrentInput = () => {
1213
- if (!this.term) return ''
1214
-
1215
- const buffer = this.term.buffer.active
1216
- const cursorY = buffer.cursorY
1217
- const cursorX = buffer.cursorX
1218
-
1219
- // Get the current line from buffer (baseY + cursorY gives absolute position)
1220
- const absoluteY = buffer.baseY + cursorY
1221
- const line = buffer.getLine(absoluteY)
1222
- if (!line) return ''
1223
-
1224
- // Get text from start of line up to cursor position
1225
- const lineText = line.translateToString(true, 0, cursorX)
1226
-
1227
- // Try to extract command after prompt
1228
- // Common prompt endings with trailing space
1229
- const promptEndings = ['$ ', '# ', '> ', '% ', '] ', ') ']
1230
-
1231
- let commandStart = 0
1232
- for (const ending of promptEndings) {
1233
- const idx = lineText.lastIndexOf(ending)
1234
- if (idx !== -1 && idx + ending.length > commandStart) {
1235
- commandStart = idx + ending.length
1236
- }
1237
- }
1238
-
1239
- return lineText.slice(commandStart)
1240
- }
1241
-
1242
- setCurrentInput = (value) => {
1243
- this.currentInput = value
1244
- }
1245
-
1246
- /**
1247
- * Handle special input events for command history tracking
1248
- * The actual input reading is done via getCurrentInput from buffer
1249
- */
1250
- handleInputEvent = (d) => {
1251
- // Handle Enter - add command to history
1252
- if (d === '\r' || d === '\n') {
1253
- const currentCmd = this.getCurrentInput()
1254
- if (currentCmd && currentCmd.trim() && this.shouldUseManualHistory()) {
1255
- window.store.addCmdHistory(currentCmd.trim())
1256
- }
1257
- if (currentCmd && currentCmd.trim() === 'exit') {
1258
- this.userTypeExit = true
1259
- }
1260
- this.closeSuggestions()
1261
- }
1262
- }
1263
-
1264
- onPasswordPromptDetected = () => {
1265
- window.store.notifyTabPasswordPrompt(this.props.tab.id)
1266
- if (!this.props.config.showCmdSuggestions) {
1267
- return
1268
- }
1269
- const cursorPos = this.getCursorPosition()
1270
- if (cursorPos) {
1271
- refsStatic
1272
- .get('terminal-suggestions')
1273
- ?.openPasswordSuggestions(cursorPos)
1274
- }
1275
- }
1276
-
1277
- onPasswordPromptCancelled = () => {
1278
- window.store.clearTabPasswordPrompt(this.props.tab.id)
1279
- const suggestions = refsStatic.get('terminal-suggestions')
1280
- if (suggestions?.state?.passwordMode) {
1281
- suggestions.closeSuggestions()
1282
- }
1283
- }
1284
-
1285
- onData = (d) => {
1286
- this.handleInputEvent(d)
1287
- // Skip normal suggestion logic when in password mode
1288
- const suggestions = refsStatic.get('terminal-suggestions')
1289
- if (suggestions?.state?.passwordMode) {
1290
- if (d === '\r' || d === '\n') {
1291
- this.closeSuggestions()
1292
- }
1293
- return
1294
- }
1295
- if (this.props.config.showCmdSuggestions) {
1296
- if (d === '\r' || d === '\n') {
1297
- this.closeSuggestions()
1298
- return
1299
- }
1300
- // Debounce the suggestion opening to avoid expensive work
1301
- // (buffer read + getBoundingClientRect + React re-render) on every keystroke
1302
- this._debouncedOpenSuggestions()
1303
- } else {
1304
- this.closeSuggestions()
1305
- }
1306
- }
1307
-
1308
- _debouncedOpenSuggestions = debounce(function () {
1309
- const data = this.getCurrentInput()
1310
- if (!data) {
1311
- this.closeSuggestions()
1312
- return
1313
- }
1314
- const cursorPos = this.getCursorPosition()
1315
- this.openSuggestions(cursorPos, data)
1316
- }, 80)
1317
-
1318
- /**
1319
- * Called by AttachAddonCustom after data is written to the terminal buffer.
1320
- * This fires after server echo arrives, so getCurrentInput() reflects the
1321
- * latest state. We trigger a debounced suggestion refresh so the dropdown
1322
- * updates correctly after backspace, delete, and other edits that rely on
1323
- * server-side echo to update the buffer.
1324
- */
1325
- onTerminalWrite = () => {
1326
- if (!this.props.config.showCmdSuggestions) {
1327
- return
1328
- }
1329
- const suggestions = refsStatic.get('terminal-suggestions')
1330
- if (suggestions?.state?.showSuggestions && !suggestions?.state?.passwordMode) {
1331
- this._debouncedOpenSuggestions()
1332
- }
1333
- }
1334
-
1335
- loadRenderer = async (term, config) => {
1336
- // xterm 6.x: only the built-in DOM renderer and the WebGL addon exist
1337
- // (the canvas renderer addon was removed in 6.x). 'dom' = no addon loaded
1338
- // (built-in DOM renderer). Legacy 'canvas' settings fall back to DOM.
1339
- if (config.rendererType === rendererTypes.webGL) {
1340
- try {
1341
- const WebglAddon = await loadWebglAddon()
1342
- const webglAddon = new WebglAddon()
1343
- this.webglAddon = webglAddon
1344
- // On macOS native fullscreen the GPU/WebGL context can be lost when
1345
- // the window migrates across Spaces. Without a listener xterm keeps
1346
- // drawing into a dead context and every terminal goes black while the
1347
- // rest of the UI stays alive. Rebuild the addon to recover.
1348
- this.webglContextLossDisposable = webglAddon.onContextLoss(this.handleWebglContextLoss)
1349
- term.loadAddon(webglAddon)
1350
- } catch (e) {
1351
- console.error('render with webgl failed, fallback to dom renderer')
1352
- console.error(e)
1353
- // built-in DOM renderer is used (no addon loaded)
1354
- this.webglAddon = null
1355
- }
1356
- }
1357
- }
1358
-
1359
- reloadWebglRenderer = (reason = 'reload') => {
1360
- console.warn(`webgl renderer ${reason}, rebuilding`)
1361
- try {
1362
- this.webglContextLossDisposable?.dispose?.()
1363
- this.webglContextLossDisposable = null
1364
- } catch (e) {
1365
- console.error(e)
1366
- }
1367
- try {
1368
- this.webglAddon?.dispose?.()
1369
- } catch (e) {
1370
- console.error(e)
1371
- }
1372
- this.webglAddon = null
1373
- const { term } = this
1374
- const { config } = this.props
1375
- return this.loadRenderer(term, config)
1376
- .then(() => {
1377
- term.refresh(0, term.rows - 1)
1378
- })
1379
- .catch(e => {
1380
- console.error(`webgl renderer ${reason} failed`, e)
1381
- })
1382
- }
1383
-
1384
- handleWebglContextLoss = (webglAddon = this.webglAddon) => {
1385
- if (this.webglRecovering || !webglAddon) {
1386
- return
1387
- }
1388
- this.webglRecovering = true
1389
- this.reloadWebglRenderer('context loss')
1390
- .finally(() => {
1391
- this.webglRecovering = false
1392
- })
1393
- }
1394
-
1395
- terminalColorQueryDisposables = []
1396
-
1397
- disposeTerminalColorQueryHandlers = () => {
1398
- this.terminalColorQueryDisposables.forEach(disposable => disposable?.dispose?.())
1399
- this.terminalColorQueryDisposables.length = 0
1400
- }
1401
-
1402
- getVisibleTerminalBackground = () => {
1403
- const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1404
- // The store value (uiThemeConfig.main) is always immediately up-to-date
1405
- // when the theme changes, because it reads directly from store.config.theme.
1406
- // The CSS --main variable lags behind because UiTheme's useEffect runs
1407
- // asynchronously after componentDidUpdate. So we prioritise the store
1408
- // value, and only fall back to CSS (for custom-CSS edge cases) or the
1409
- // terminal theme background (last resort).
1410
- const root = document.documentElement
1411
- const cssMain = root && window.getComputedStyle
1412
- ? window.getComputedStyle(root).getPropertyValue('--main').trim()
1413
- : ''
1414
- return uiThemeConfig.main || cssMain || this.props.themeConfig.background
1415
- }
1416
-
1417
- getVisibleTerminalForeground = () => {
1418
- const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1419
- return uiThemeConfig.text
1420
- }
1421
-
1422
- registerTerminalColorQueryHandlers = (term, themeConfig = {}) => {
1423
- this.disposeTerminalColorQueryHandlers()
1424
- if (!term?.parser?.registerOscHandler) {
1425
- return
1426
- }
1427
- const background = this.getVisibleTerminalBackground()
1428
- const foregroundFallback = this.getVisibleTerminalForeground()
1429
- this.terminalColorQueryDisposables.push(
1430
- term.parser.registerOscHandler(10, data => {
1431
- return handleTerminalColorQuery(term, 10, themeConfig.foreground, foregroundFallback, data)
1432
- }),
1433
- term.parser.registerOscHandler(11, data => {
1434
- return handleTerminalColorQuery(term, 11, background, themeConfig.background, data)
1435
- })
1436
- )
1437
- }
1438
-
1439
- getRendererThemeConfig = (themeConfig = this.props.themeConfig) => {
1440
- return createRendererThemeConfig(
1441
- deepCopy(themeConfig),
1442
- this.props.config.rendererType,
1443
- this.getVisibleTerminalBackground()
1444
- )
1445
- }
1446
-
1447
- /**
1448
- * Apply the current renderer theme to the terminal and trigger a repaint.
1449
- * When `deferred` is true (WebGL mode), a second repaint is scheduled on
1450
- * the next animation frame so the theme picks up CSS --main changes that
1451
- * UiTheme's useEffect applies asynchronously after componentDidUpdate.
1452
- * The optional `term` parameter is used during initTerminal, where
1453
- * this.term hasn't been assigned yet.
1454
- */
1455
- applyTerminalTheme = (deferred = false, term = this.term) => {
1456
- if (!term || this.onClose) {
1457
- return
1458
- }
1459
- term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
1460
- this.fixSelectionColors(term)
1461
- term.refresh(0, term.rows - 1)
1462
- if (deferred && this.props.config.rendererType === rendererTypes.webGL) {
1463
- window.cancelAnimationFrame(this.timers.themeRaf)
1464
- this.timers.themeRaf = window.requestAnimationFrame(() => {
1465
- if (!this.term || this.onClose) {
1466
- return
1467
- }
1468
- this.term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
1469
- this.fixSelectionColors(this.term)
1470
- this.term.refresh(0, this.term.rows - 1)
1471
- })
1472
- }
1473
- }
1474
-
1475
- /**
1476
- * xterm computes its DOM selection colour as blend(theme.background,
1477
- * selectionBackground). The terminal background is forced transparent
1478
- * (rgba(0,0,0,0)) so electerm's own CSS background / image shows through,
1479
- * which makes the blend run over transparent-black. Recompute the selection
1480
- * colours over the real visible background, from the *configured* theme
1481
- * colours — xterm's internal selectionBackgroundTransparent forces opaque
1482
- * colours down to 0.3 alpha (xterm#2737), which would make every selection
1483
- * translucent even when the user configured an opaque one.
1484
- */
1485
- fixSelectionColors = (term) => {
1486
- const themeConfig = this.props.themeConfig || {}
1487
- const themeService = term?._core?._themeService
1488
- if (!themeService?.modifyColors) {
1489
- return
1490
- }
1491
- const visibleBackground = this.getVisibleTerminalBackground()
1492
- // xterm's DEFAULT_SELECTION, used when the theme omits a selection color
1493
- const selectionFallback = 'rgba(255, 255, 255, 0.3)'
1494
- const inactiveFallback = themeConfig.selectionBackground || selectionFallback
1495
- themeService.modifyColors((c) => {
1496
- const active = blendSelectionOverBackground(
1497
- visibleBackground,
1498
- themeConfig.selectionBackground || selectionFallback
1499
- )
1500
- if (active) {
1501
- c.selectionBackgroundOpaque = active
1502
- }
1503
- const inactive = blendSelectionOverBackground(
1504
- visibleBackground,
1505
- themeConfig.selectionInactiveBackground || inactiveFallback
1506
- )
1507
- if (inactive) {
1508
- c.selectionInactiveBackgroundOpaque = inactive
1509
- }
1510
- })
1511
- }
1512
-
1513
- initTerminal = async () => {
1514
- const { themeConfig, tab = {}, config = {} } = this.props
1515
- const tc = this.getRendererThemeConfig(themeConfig)
1516
- const Terminal = await loadTerminal()
1517
- const term = new Terminal({
1518
- allowProposedApi: true,
1519
- scrollback: config.scrollback,
1520
- rightClickSelectsWord: config.rightClickSelectsWord || false,
1521
- fontFamily: tab.fontFamily || config.fontFamily,
1522
- theme: tc,
1523
- allowTransparency: true,
1524
- wordSeparator: config.terminalWordSeparator,
1525
- cursorStyle: config.cursorStyle,
1526
- cursorBlink: config.cursorBlink,
1527
- fontSize: tab.fontSize || config.fontSize,
1528
- screenReaderMode: config.screenReaderMode
1529
- })
1530
-
1531
- term.parent = this
1532
- term.onSelectionChange(this.onSelection)
1533
- term.open(this.domRef.current, true)
1534
- this.registerTerminalColorQueryHandlers(term, themeConfig)
1535
- await this.loadRenderer(term, config)
1536
- this.fixSelectionColors(term)
1537
- // Re-apply the theme after the renderer is loaded. The Terminal was
1538
- // constructed before UiTheme's useEffect ran, so the initial theme
1539
- // may have a stale background. Pass `term` directly because this.term
1540
- // is not assigned yet. Use deferred=true so a second repaint picks up
1541
- // any CSS --main changes applied by UiTheme's useEffect.
1542
- if (config.rendererType === rendererTypes.webGL) {
1543
- this.applyTerminalTheme(true, term)
1544
- }
1545
-
1546
- const FitAddon = await loadFitAddon()
1547
- this.fitAddon = new FitAddon()
1548
- this.cmdAddon = new CommandTrackerAddon()
1549
- this.cmdAddon.onCommandExecuted((cmd) => {
1550
- if (cmd && cmd.trim()) {
1551
- window.store.addCmdHistory(cmd.trim())
1552
- }
1553
- })
1554
- this.cmdAddon.onCwdChanged((cwd) => {
1555
- this.setCwd(cwd)
1556
- })
1557
- const SearchAddon = await loadSearchAddon()
1558
- this.searchAddon = new SearchAddon()
1559
- const LigaturesAddon = await loadLigaturesAddon()
1560
- const ligtureAddon = new LigaturesAddon()
1561
- this.searchAddon.onDidChangeResults(this.onSearchResultsChange)
1562
- const Unicode11Addon = await loadUnicode11Addon()
1563
- const unicode11Addon = new Unicode11Addon()
1564
- term.loadAddon(unicode11Addon)
1565
- term.loadAddon(ligtureAddon)
1566
- term.unicode.activeVersion = '11'
1567
- term.loadAddon(this.fitAddon)
1568
- term.loadAddon(this.searchAddon)
1569
- term.loadAddon(this.cmdAddon)
1570
- this.osc52Addon = new Osc52Addon()
1571
- term.loadAddon(this.osc52Addon)
1572
- if (tab.enableTerminalImage) {
1573
- const ImageAddon = await loadImageAddon()
1574
- this.imageAddon = new ImageAddon({
1575
- pixelLimit: 33554432
1576
- })
1577
- term.loadAddon(this.imageAddon)
1578
- }
1579
- term.onData(this.onData)
1580
- this.term = term
1581
- term.onSelectionChange(this.onSelectionChange)
1582
- term.attachCustomKeyEventHandler(this.handleKeyboardEvent.bind(this))
1583
- // 容器不可见(隐藏标签 display:none)时不 fit,避免算出 0 列把 shell
1584
- // 提示符逐字符错误换行;待标签激活可见后由 fitAndRefresh 重新适配。
1585
- if (this.isElementVisible()) {
1586
- this.fitAddon.fit()
1587
- }
1588
- await this.remoteInit(term)
1589
- }
1590
-
1591
- onSelectionChange = () => {
1592
- const hasSelection = this.term.hasSelection()
1593
- const txt = hasSelection ? this.term.getSelection().trim() : ''
1594
- this.setState({ hasSelection })
1595
- refsStatic.get('unix-timestamp-tooltip')?.onSelection(txt)
1596
- }
1597
-
1598
- // setActive = () => {
1599
- // const name = `activeTabId${this.props.batch}`
1600
- // const tabId = this.props.tab.id
1601
- // window.store.storeAssign({
1602
- // activeTabId: tabId,
1603
- // [name]: tabId
1604
- // })
1605
- // }
1606
-
1607
- runInitScript = async () => {
1608
- window.store.triggerResize()
1609
- const {
1610
- startDirectory,
1611
- runScripts
1612
- } = this.props.tab
1613
-
1614
- const scripts = runScripts ? [...runScripts] : []
1615
- const startFolder = startDirectory || window.initFolder
1616
- if (startFolder) {
1617
- scripts.unshift({ script: `cd "${startFolder}"`, delay: 0 })
1618
- }
1619
-
1620
- // Create unified execution queue
1621
- this.executionQueue = []
1622
-
1623
- // Add shell integration injection to queue if needed
1624
- if (this.canInjectShellIntegration()) {
1625
- this.executionQueue.push({
1626
- type: 'shell_integration',
1627
- execute: async () => {
1628
- await this.injectShellIntegration()
1629
- }
1630
- })
1631
- }
1632
-
1633
- // Add delayed scripts to queue
1634
- scripts.forEach(script => {
1635
- this.executionQueue.push({
1636
- type: 'delayed_script',
1637
- script: script.script,
1638
- delay: script.delay || 0,
1639
- execute: () => {
1640
- if (script.script) {
1641
- this.attachAddon._sendData(script.script + '\r')
1642
- }
1643
- }
1644
- })
1645
- })
1646
-
1647
- this.processExecutionQueue()
1648
- }
1649
-
1650
- shouldUseManualHistory = () => {
1651
- return !this.cmdAddon || !this.cmdAddon.hasShellIntegration()
1652
- }
1653
-
1654
- canInjectShellIntegration = () => {
1655
- const { config } = this.props
1656
- const canInject = (config.showCmdSuggestions || this.props.sftpPathFollowSsh) &&
1657
- (
1658
- this.isSsh() ||
1659
- (this.isLocal() && !isWin)
1660
- )
1661
- return canInject
1662
- }
1663
-
1664
- isSsh = () => {
1665
- const { host, type } = this.props.tab
1666
- return host && (type === 'ssh' || type === undefined)
1667
- }
1668
-
1669
- isLocal = () => {
1670
- const { host, type } = this.props.tab
1671
- return !host &&
1672
- (type === 'local' || type === undefined)
1673
- }
1674
-
1675
- /**
1676
- * Process the unified execution queue one item at a time
1677
- */
1678
- processExecutionQueue = async () => {
1679
- if (!this.executionQueue || this.executionQueue.length === 0) {
1680
- return
1681
- }
1682
-
1683
- const item = this.executionQueue.shift()
1684
-
1685
- try {
1686
- if (item.type === 'shell_integration') {
1687
- await item.execute()
1688
- } else if (item.type === 'delayed_script') {
1689
- item.execute()
1690
- // Wait for the specified delay before processing next item
1691
- if (item.delay > 0) {
1692
- await new Promise(resolve => {
1693
- this.timers.timerDelay = setTimeout(resolve, item.delay)
1694
- })
1695
- }
1696
- }
1697
- } catch (error) {
1698
- console.error('[Shell Integration] Error processing queue item:', item.type, error)
1699
- }
1700
-
1701
- // Process next item
1702
- this.processExecutionQueue()
1703
- }
1704
-
1705
- /**
1706
- * Inject shell integration commands from client-side
1707
- * This replaces the server-side source xxx.xxx approach
1708
- * Uses output suppression to hide the injection command
1709
- * Returns a promise that resolves when injection is complete
1710
- */
1711
- injectShellIntegration = async () => {
1712
- if (this.shellInjected) {
1713
- return Promise.resolve()
1714
- }
1715
-
1716
- let shellType
1717
- if (this.isLocal()) {
1718
- const { config } = this.props
1719
- const localShell = isMac ? config.execMac : config.execLinux
1720
- shellType = detectShellType(localShell)
1721
- } else if (this.isSsh()) {
1722
- shellType = await detectRemoteShell(this.pid)
1723
- }
1724
-
1725
- this.shellType = shellType
1726
-
1727
- // Don't inject for sh type shells unless sftpPathFollowSsh is true
1728
- if (shellType === 'sh' && !this.props.sftpPathFollowSsh) {
1729
- return Promise.resolve()
1730
- }
1731
-
1732
- const integrationCmd = getShellIntegrationCommand(shellType)
1733
-
1734
- return new Promise((resolve) => {
1735
- // Wait for initial data (prompt/banner) to arrive before injecting
1736
- this.attachAddon.onInitialData(() => {
1737
- if (this.attachAddon) {
1738
- // Start suppressing output before sending the integration command
1739
- // This hides the command and its output until OSC 633 is detected
1740
- const suppressionTimeout = this.isSsh() ? 5000 : 3000
1741
- // Pass callback to resolve the promise after suppression ends
1742
- this.attachAddon.startOutputSuppression(suppressionTimeout, () => {
1743
- this.shellInjected = true
1744
- resolve()
1745
- })
1746
- this.attachAddon._sendData(integrationCmd)
1747
- } else {
1748
- resolve()
1749
- }
1750
- })
1751
- })
1752
- }
1753
-
1754
- setStatus = status => {
1755
- const id = this.props.tab?.id
1756
- this.props.editTab(id, {
1757
- status
1758
- })
1759
- }
1760
-
1761
- openNormalBuffer = () => {
1762
- const normal = this.term.buffer.normal
1763
- const len = normal.length
1764
- const lines = new Array(len).fill('').map((x, i) => {
1765
- return normal.getLine(i).translateToString(false)
1766
- })
1767
- this.setState({
1768
- lines
1769
- })
1770
- }
1771
-
1772
- closeNormalBuffer = () => {
1773
- this.setState({
1774
- lines: []
1775
- })
1776
- this.term.focus()
1777
- }
1778
-
1779
- onBufferChange = buf => {
1780
- this.bufferMode = buf.type
1781
- }
1782
-
1783
- buildWsUrl = (port) => {
1784
- const { host, tokenElecterm } = this.props.config
1785
- const { id } = this.props.tab
1786
- if (window.et.buildWsUrl) {
1787
- return window.et.buildWsUrl(
1788
- host,
1789
- port,
1790
- tokenElecterm,
1791
- id
1792
- )
1793
- }
1794
- return `ws://${host}:${port}/terminals/${id}?token=${tokenElecterm}`
1795
- }
1796
-
1797
- remoteInit = async (term = this.term) => {
1798
- this.setState({
1799
- loading: true,
1800
- terminalError: null
1801
- })
1802
- const { cols, rows } = term
1803
- const { config } = this.props
1804
- const {
1805
- keywords = []
1806
- } = config
1807
- const { logName } = this.props
1808
- const tab = window.store.applyProfileToTabs(deepCopy(this.props.tab || {}))
1809
- const {
1810
- srcId, from = 'bookmarks',
1811
- type,
1812
- term: terminalType,
1813
- displayRaw,
1814
- id
1815
- } = tab
1816
- const { savePassword } = this.state
1817
- const termType = type
1818
- const extra = this.props.sessionOptions
1819
- // Determine if this is a local terminal (no host)
1820
- const isLocalType = !tab.host
1821
- // Build exec settings: only for local type, prefer tab settings over config
1822
- let execOpts = {}
1823
- let execPropName = 'execLinux'
1824
- if (isWin) {
1825
- execPropName = 'execWindows'
1826
- } else if (isMac) {
1827
- execPropName = 'execMac'
1828
- }
1829
- if (isLocalType) {
1830
- // Check flat properties on tab first (bookmark data), then fall back to config
1831
- if (tab[execPropName]) {
1832
- // Use bookmark's exec setting directly
1833
- execOpts = {
1834
- [execPropName]: tab[execPropName],
1835
- [`${execPropName}Args`]: tab[`${execPropName}Args`] || []
1836
- }
1837
- } else if (config[execPropName]) {
1838
- // Use global config exec settings
1839
- execOpts = {
1840
- [execPropName]: config[execPropName],
1841
- [`${execPropName}Args`]: config[`${execPropName}Args`] || []
1842
- }
1843
- }
1844
- }
1845
- const keepaliveInterval = tab.keepaliveInterval || config.keepaliveInterval
1846
- const opts = clone({
1847
- cols,
1848
- rows,
1849
- term: terminalType || config.terminalType,
1850
- saveTerminalLogToFile: config.saveTerminalLogToFile,
1851
- ...tab,
1852
- ...extra,
1853
- ...execOpts,
1854
- logName,
1855
- sessionLogPath: this.state.logPath,
1856
- ...pick(config, [
1857
- 'addTimeStampToTermLog',
1858
- 'keepaliveCountMax',
1859
- 'keyword2FA',
1860
- 'debug'
1861
- ]),
1862
- keepaliveInterval,
1863
- tabId: id,
1864
- uid: id,
1865
- srcTabId: tab.id,
1866
- termType,
1867
- readyTimeout: config.sshReadyTimeout,
1868
- proxy: getProxy(tab, config),
1869
- type: tab.host
1870
- ? typeMap.remote
1871
- : typeMap.local
1872
- })
1873
- const isAutoReconnect = !!(tab.autoReConnect && this.props.config.autoReconnectTerminal)
1874
- const r = await createTerm(opts)
1875
- .catch(err => {
1876
- if (!isAutoReconnect) {
1877
- const text = err.message
1878
- this.handleError({ message: text, from, srcId })
1879
- }
1880
- })
1881
- // Guard: component was unmounted while createTerm was pending.
1882
- // The child process is already running; connect briefly to trigger its cleanup.
1883
- if (this.onClose) {
1884
- if (r && r.port) {
1885
- try {
1886
- const tmpSock = new WebSocket(this.buildWsUrl(r.port))
1887
- tmpSock.onopen = () => tmpSock.close()
1888
- } catch (_e) {}
1889
- }
1890
- return
1891
- }
1892
- if (typeof r === 'string' && r.includes('fail')) {
1893
- return this.promote()
1894
- }
1895
- if (savePassword) {
1896
- window.store.editItem(srcId, extra, from)
1897
- }
1898
- this.setState({
1899
- loading: false
1900
- })
1901
- if (!r) {
1902
- if (isAutoReconnect) {
1903
- this.scheduleAutoReconnect(3000)
1904
- return
1905
- }
1906
- this.setStatus(statusMap.error)
1907
- return
1908
- }
1909
- this.port = r.port
1910
- this.setStatus(statusMap.success)
1911
- refs.get('sftp-' + id)?.initData(id, r.port)
1912
- term.pid = id
1913
- this.pid = id
1914
- const wsUrl = this.buildWsUrl(r.port)
1915
- const socket = new WebSocket(wsUrl)
1916
- socket.onclose = this.oncloseSocket
1917
- socket.onerror = this.onerrorSocket
1918
- this.socket = socket
1919
- this.initSocketEvents()
1920
- this.term = term
1921
- socket.onopen = async () => {
1922
- await this.initAttachAddon()
1923
- this.runInitScript()
1924
- }
1925
- // term.onRrefresh(this.onRefresh)
1926
- term.onResize(this.onResizeTerminal)
1927
- // xterm 6.x exposes buffer change as a public event (IBufferNamespace.onBufferChange).
1928
- // Previously this reached into the private _onBufferChange._listeners array.
1929
- term.buffer.onBufferChange(this.onBufferChange)
1930
- const WebLinksAddon = await loadWebLinksAddon()
1931
- term.loadAddon(new WebLinksAddon(this.webLinkHandler))
1932
- term.focus()
1933
- this.zmodemClient = new ZmodemClient(this)
1934
- this.zmodemClient.init(socket)
1935
- this.trzszClient = new TrzszClient(this)
1936
- this.trzszClient.init(socket)
1937
- this.xmodemClient = new XmodemClient(this)
1938
- this.xmodemClient.init(socket)
1939
- // 仅在可见时 fit,隐藏标签跳过,避免 0 列损坏提示符。
1940
- if (this.isElementVisible()) {
1941
- this.fitAddon.fit()
1942
- }
1943
- // Force-send the current size now that `this.pid` is finally valid.
1944
- // A resize may already have fired earlier (e.g. from the tab-becomes-
1945
- // visible fit in componentDidUpdate, which can run before createTerm()
1946
- // resolves) while this.pid was still undefined - the backend silently
1947
- // drops resize calls for an unknown pid. If that earlier call already
1948
- // updated term.cols/rows locally, xterm's resize() no-ops on the next
1949
- // fit() (dims unchanged) and never fires onResize again, so the
1950
- // backend pty would otherwise be stuck at its initial (possibly wrong)
1951
- // size forever. Sending explicitly here, keyed off the real pid,
1952
- // guarantees the remote pty/ssh channel is told the true current size.
1953
- resizeTerm(this.pid, term.cols, term.rows)
1954
- term.displayRaw = displayRaw
1955
- term.loadAddon(
1956
- new KeywordHighlighterAddon(keywords)
1957
- )
1958
- }
1959
-
1960
- handleError = ({ message: errorMessage, from, srcId }) => {
1961
- this.setState({
1962
- terminalError: {
1963
- message: errorMessage || 'Failed to create terminal session',
1964
- from,
1965
- srcId
1966
- }
1967
- })
1968
- }
1969
-
1970
- handleEditBookmarkFromError = () => {
1971
- const error = this.state.terminalError
1972
- if (!error || error.from !== 'bookmarks' || !error.srcId) {
1973
- return
1974
- }
1975
- const item = window.store.bookmarksMap?.get(error.srcId) ||
1976
- window.store.bookmarks?.find(d => d.id === error.srcId)
1977
- if (!item) {
1978
- return
1979
- }
1980
- window.store.openBookmarkEdit(item)
1981
- }
1982
-
1983
- initSocketEvents = () => {
1984
- const originalSend = this.socket.send
1985
- this.socket.send = (data) => {
1986
- // Call original send first
1987
- originalSend.call(this.socket, data)
1988
-
1989
- // Broadcast to other terminals
1990
- this.broadcastSocketData(data)
1991
- }
1992
- }
1993
-
1994
- canReceiveBroadcast = (termRef) => {
1995
- return (
1996
- termRef.socket &&
1997
- termRef.props?.tab.pane === paneMap.terminal
1998
- )
1999
- }
2000
-
2001
- broadcastSocketData = (data) => {
2002
- if (!this.isActiveTerminal() || !this.props.broadcastInput) {
2003
- return
2004
- }
2005
-
2006
- window.refs.forEach((termRef, refId) => {
2007
- if (
2008
- refId !== this.id &&
2009
- refId.startsWith('term-') &&
2010
- this.canReceiveBroadcast(termRef)
2011
- ) {
2012
- termRef.socket.send(data)
2013
- }
2014
- })
2015
- }
2016
-
2017
- // 判断终端挂载容器当前是否真实可见(非 display:none)。
2018
- // 隐藏标签的 clientWidth/clientHeight 为 0,此时不应 fit,否则会算出 0 列。
2019
- isElementVisible = () => {
2020
- const el = this.domRef.current
2021
- if (!el) {
2022
- return false
2023
- }
2024
- return el.clientWidth > 0 && el.clientHeight > 0
2025
- }
2026
-
2027
- // 重新适配终端尺寸并强制重绘可见区域。
2028
- // 仅当容器真正可见时才 fit:隐藏标签(display:none)下 clientWidth 为 0,
2029
- // 若在此时 fit 会算出 0/极小列数,使 shell 提示符被逐字符错误换行,
2030
- // 出现快速连续打开多个连接时的虚假/重复提示符。因此隐藏态跳过 fit,
2031
- // 保留 xterm 默认 80x24,待标签激活可见后再 fit 重绘,从根上避免换行损坏。
2032
- fitAndRefresh = () => {
2033
- if (!this.term || !this.fitAddon || this.onClose) {
2034
- return
2035
- }
2036
- if (!this.isElementVisible()) {
2037
- return
2038
- }
2039
- try {
2040
- this.fitAddon.fit()
2041
- this.term.refresh(0, this.term.rows - 1)
2042
- } catch (e) {
2043
- console.info('resize failed', e)
2044
- }
2045
- }
2046
-
2047
- onResize = throttle(() => {
2048
- this.fitAndRefresh()
2049
- }, 200)
2050
-
2051
- onerrorSocket = err => {
2052
- console.error('onerrorSocket', err)
2053
- }
2054
-
2055
- oncloseSocket = () => {
2056
- if (this.onClose || this.props.tab.enableSsh === false) {
2057
- return
2058
- }
2059
- this.setStatus(
2060
- statusMap.error
2061
- )
2062
- if (this.userTypeExit) {
2063
- return this.props.delTab(this.props.tab.id)
2064
- }
2065
- const { autoReconnectTerminal } = this.props.config
2066
- if (autoReconnectTerminal) {
2067
- this.scheduleAutoReconnect(3000)
2068
- }
2069
- }
2070
-
2071
- scheduleAutoReconnect = (delay = 3000) => {
2072
- clearTimeout(this.timers.reconnectTimer)
2073
- clearInterval(this.timers.reconnectCountdown)
2074
- const seconds = Math.round(delay / 1000)
2075
- this.setState({ reconnectCountdown: seconds })
2076
- let remaining = seconds
2077
- this.timers.reconnectCountdown = setInterval(() => {
2078
- remaining -= 1
2079
- if (remaining <= 0) {
2080
- clearInterval(this.timers.reconnectCountdown)
2081
- this.timers.reconnectCountdown = null
2082
- }
2083
- this.setState({ reconnectCountdown: remaining <= 0 ? null : remaining })
2084
- }, 1000)
2085
- this.timers.reconnectTimer = setTimeout(() => {
2086
- clearInterval(this.timers.reconnectCountdown)
2087
- this.timers.reconnectCountdown = null
2088
- this.setState({ reconnectCountdown: null })
2089
- if (this.onClose || !this.props.config.autoReconnectTerminal) {
2090
- return
2091
- }
2092
- const reconnectCount = (this.props.tab.autoReConnect || 0) + 1
2093
- this.props.reloadTab({ ...this.props.tab, autoReConnect: reconnectCount })
2094
- }, delay)
2095
- }
2096
-
2097
- handleCancelAutoReconnect = () => {
2098
- clearTimeout(this.timers.reconnectTimer)
2099
- clearInterval(this.timers.reconnectCountdown)
2100
- this.timers.reconnectTimer = null
2101
- this.timers.reconnectCountdown = null
2102
- this.setState({ reconnectCountdown: null })
2103
- }
2104
-
2105
- batchInput = (cmd) => {
2106
- this.attachAddon._sendData(cmd + '\r')
2107
- }
2108
-
2109
- onResizeTerminal = size => {
2110
- const { cols, rows } = size
2111
- resizeTerm(this.pid, cols, rows)
2112
- }
2113
-
2114
- handleCancel = () => {
2115
- const { id } = this.props.tab
2116
- this.props.delTab(id)
2117
- }
2118
-
2119
- /**
2120
- * Manually triggered from the "exit gracefully" control in
2121
- * session-control.jsx (serial tabs only). Writes the configured key
2122
- * sequence (default \x01ky = Ctrl+A, k, y to kill a GNU screen window) to
2123
- * the socket, waits a bit so the remote end (e.g. a Bluetooth console
2124
- * adapter) can release cleanly, then closes the tab.
2125
- */
2126
- exitGracefully = async () => {
2127
- const { tab } = this.props
2128
- if (tab.type !== terminalSerialType) {
2129
- return
2130
- }
2131
- if (!this.onClose && this.attachAddon?._sendData) {
2132
- try {
2133
- const data = expandCloseSequence(tab.closeSequence || '\\x01ky')
2134
- if (data) {
2135
- this.attachAddon._sendData(data)
2136
- }
2137
- } catch (err) {
2138
- console.error('send close sequence failed', err)
2139
- }
2140
- const delay = Number(tab.closeSequenceDelay)
2141
- await new Promise(resolve => setTimeout(resolve, Number.isFinite(delay) && delay >= 0 ? delay : 500))
2142
- }
2143
- this.props.delTab(tab.id)
2144
- }
2145
-
2146
- handleShowInfo = () => {
2147
- const { logName, tab } = this.props
2148
- const infoProps = {
2149
- logName,
2150
- id: tab.id,
2151
- pid: tab.id,
2152
- isRemote: this.isRemote(),
2153
- isActive: this.isActiveTerminal()
2154
- }
2155
- Object.assign(window.store.terminalInfoProps, infoProps)
2156
- }
2157
-
2158
- renderResetFontSizeButton = () => {
2159
- if (!this.state.fontSizeChanged) {
2160
- return null
272
+ renderResetFontSizeButton () {
273
+ if (!this.state.fontSizeChanged) {
274
+ return null
2161
275
  }
2162
276
  const txt = `${e('reset')} ${e('fontSize')}`
2163
277
  return (
@@ -2172,25 +286,6 @@ class Term extends Component {
2172
286
  )
2173
287
  }
2174
288
 
2175
- // getPwd = async () => {
2176
- // const { sessionId, config } = this.props
2177
- // const { pid } = this.state
2178
- // const prps = {
2179
- // host: config.host,
2180
- // port: config.port,
2181
- // pid,
2182
- // sessionId
2183
- // }
2184
- // const result = await runCmds(prps, ['pwd'])
2185
- // .catch(window.store.onError)
2186
- // return result ? result[0].trim() : ''
2187
- // }
2188
-
2189
- switchEncoding = encode => {
2190
- this.encode = encode
2191
- this.attachAddon.decoder = new TextDecoder(encode)
2192
- }
2193
-
2194
289
  render () {
2195
290
  const { loading } = this.state
2196
291
  const { height, width, left, top, fullscreen } = this.props