@electerm/electerm-react 5.3.26 → 5.5.9

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 (146) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +20 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +15 -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/common/item-filter.jsx +76 -0
  29. package/client/components/common/item-filter.styl +54 -0
  30. package/client/components/common/lazy-boundary.jsx +34 -0
  31. package/client/components/common/opacity.jsx +3 -1
  32. package/client/components/footer/cmd-history.jsx +132 -45
  33. package/client/components/footer/cmd-history.styl +42 -4
  34. package/client/components/footer/footer-entry.jsx +35 -5
  35. package/client/components/footer/footer.styl +8 -1
  36. package/client/components/footer/multi-tab-run-modal.jsx +83 -0
  37. package/client/components/footer/tab-select.jsx +34 -17
  38. package/client/components/layout/layout.jsx +23 -31
  39. package/client/components/layout/pixed.js +3 -0
  40. package/client/components/main/main.jsx +55 -28
  41. package/client/components/main/term-fullscreen.styl +9 -3
  42. package/client/components/quick-commands/qm.styl +3 -2
  43. package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
  44. package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
  45. package/client/components/rdp/file-transfer.js +4 -4
  46. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  47. package/client/components/remote-monitor/monitor-details.styl +87 -0
  48. package/client/components/remote-monitor/monitor-model.js +764 -0
  49. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  50. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  51. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  52. package/client/components/remote-monitor/session-monitor.js +361 -0
  53. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  54. package/client/components/remote-monitor/visibility.js +18 -0
  55. package/client/components/session/session-control.jsx +29 -2
  56. package/client/components/session/session.jsx +40 -18
  57. package/client/components/session/sessions.jsx +5 -2
  58. package/client/components/setting-panel/setting-common.jsx +25 -56
  59. package/client/components/setting-panel/setting-modal.jsx +57 -42
  60. package/client/components/setting-panel/setting-terminal.jsx +8 -2
  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/sftp/sftp-entry.jsx +4 -0
  67. package/client/components/side-panel-r/side-panel-r.jsx +2 -2
  68. package/client/components/sidebar/side-panel.jsx +1 -1
  69. package/client/components/sys-menu/menu-btn.jsx +30 -1
  70. package/client/components/tabs/index.jsx +2 -2
  71. package/client/components/tabs/tab-title.jsx +3 -2
  72. package/client/components/tabs/tab.jsx +5 -1
  73. package/client/components/terminal/attach-addon-custom.js +295 -49
  74. package/client/components/terminal/automation/index.js +64 -0
  75. package/client/components/terminal/automation/keys.js +49 -0
  76. package/client/components/terminal/automation/stream-matcher.js +82 -0
  77. package/client/components/terminal/automation/strip-ansi.js +19 -0
  78. package/client/components/terminal/automation/trigger-engine.js +233 -0
  79. package/client/components/terminal/automation/trigger-presets.js +110 -0
  80. package/client/components/terminal/command-tracker-addon.js +31 -1
  81. package/client/components/terminal/mixins/index.js +27 -0
  82. package/client/components/terminal/mixins/term-attach.js +123 -0
  83. package/client/components/terminal/mixins/term-config.jsx +84 -0
  84. package/client/components/terminal/mixins/term-content.js +126 -0
  85. package/client/components/terminal/mixins/term-context-menu.jsx +323 -0
  86. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  87. package/client/components/terminal/mixins/term-init.js +122 -0
  88. package/client/components/terminal/mixins/term-log.jsx +96 -0
  89. package/client/components/terminal/mixins/term-resize.js +87 -0
  90. package/client/components/terminal/mixins/term-socket.js +433 -0
  91. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  92. package/client/components/terminal/mixins/term-theme.js +190 -0
  93. package/client/components/terminal/mixins/term-touch.js +523 -0
  94. package/client/components/terminal/shell-detect.js +37 -0
  95. package/client/components/terminal/shell.js +0 -28
  96. package/client/components/terminal/shortcut-bar-defs.js +30 -4
  97. package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
  98. package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
  99. package/client/components/terminal/shortcut-bar.jsx +17 -10
  100. package/client/components/terminal/shortcut-bar.styl +4 -0
  101. package/client/components/terminal/ssh-reload-state.js +96 -0
  102. package/client/components/terminal/startup-queue.js +433 -0
  103. package/client/components/terminal/terminal-interactive.jsx +32 -0
  104. package/client/components/terminal/terminal-select-text.jsx +69 -0
  105. package/client/components/terminal/terminal.jsx +227 -2064
  106. package/client/components/terminal/terminal.styl +61 -0
  107. package/client/components/terminal/transfer-client-base.js +5 -29
  108. package/client/components/terminal/trzsz-client.js +1 -3
  109. package/client/components/terminal/xmodem-client.js +1 -3
  110. package/client/components/terminal/xterm-loader.js +7 -0
  111. package/client/components/terminal/zmodem-client.js +1 -3
  112. package/client/components/terminal-info/base.jsx +18 -70
  113. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  114. package/client/components/terminal-info/terminal-info.jsx +50 -59
  115. package/client/components/terminal-info/terminal-info.styl +6 -49
  116. package/client/components/text-editor/open-text-editor.js +30 -0
  117. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  118. package/client/components/text-editor/text-editor.jsx +14 -0
  119. package/client/components/triggers/trigger-editor.jsx +246 -0
  120. package/client/components/triggers/trigger-form.jsx +200 -0
  121. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  122. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  123. package/client/components/triggers/trigger-transport.jsx +60 -0
  124. package/client/components/triggers/triggers-field.jsx +19 -0
  125. package/client/components/widgets/widgets-list.jsx +11 -1
  126. package/client/css/mobile.styl +29 -8
  127. package/client/store/common.js +12 -1
  128. package/client/store/init-state.js +7 -0
  129. package/client/store/mcp-handler.js +158 -30
  130. package/client/store/setting.js +9 -0
  131. package/client/store/store.js +2 -0
  132. package/client/store/sync.js +2 -0
  133. package/client/store/tab.js +42 -3
  134. package/client/store/trigger.js +118 -0
  135. package/client/store/watch.js +13 -5
  136. package/client/views/index.pug +3 -0
  137. package/package.json +1 -1
  138. package/client/components/terminal-info/activity.jsx +0 -57
  139. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  140. package/client/components/terminal-info/disk.jsx +0 -45
  141. package/client/components/terminal-info/format-disks.js +0 -45
  142. package/client/components/terminal-info/network.jsx +0 -121
  143. package/client/components/terminal-info/resource.jsx +0 -99
  144. package/client/components/terminal-info/run-cmd.jsx +0 -263
  145. package/client/components/terminal-info/sys-info.jsx +0 -56
  146. package/client/components/terminal-info/up.jsx +0 -17
@@ -1,117 +1,67 @@
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 TerminalSelectText from './terminal-select-text.jsx'
26
+ import { StartupQueue } from './startup-queue.js'
27
+ import { detectRemoteShell } from './shell-detect.js'
28
+ import { shortcutExtend, shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
29
+ import { applyMixins } from './mixins/index.js'
30
+ import { initMixin } from './mixins/term-init.js'
31
+ import { resizeMixin } from './mixins/term-resize.js'
32
+ import { themeMixin } from './mixins/term-theme.js'
33
+ import { attachMixin } from './mixins/term-attach.js'
34
+ import { socketMixin } from './mixins/term-socket.js'
35
+ import { configMixin } from './mixins/term-config.jsx'
36
+ import { contextMenuMixin } from './mixins/term-context-menu.jsx'
37
+ import { contentMixin } from './mixins/term-content.js'
38
+ import { logMixin } from './mixins/term-log.jsx'
39
+ import { touchMixin } from './mixins/term-touch.js'
40
+ import { fileDropMixin } from './mixins/term-file-drop.js'
41
+ import { suggestionsMixin } from './mixins/term-suggestions.js'
77
42
 
78
43
  const e = window.translate
79
44
 
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
- }
45
+ // The terminal behaviour, grouped by concern - see ./mixins/index.js
46
+ const mixins = [
47
+ initMixin,
48
+ resizeMixin,
49
+ themeMixin,
50
+ attachMixin,
51
+ socketMixin,
52
+ configMixin,
53
+ contextMenuMixin,
54
+ contentMixin,
55
+ logMixin,
56
+ touchMixin,
57
+ fileDropMixin,
58
+ suggestionsMixin
59
+ ]
111
60
 
112
61
  class Term extends Component {
113
62
  constructor (props) {
114
63
  super(props)
64
+ applyMixins(this, mixins)
115
65
  this.state = {
116
66
  loading: false,
117
67
  hasSelection: false,
@@ -130,19 +80,72 @@ class Term extends Component {
130
80
  terminalError: null,
131
81
  dropFileModalVisible: false,
132
82
  droppedFiles: [],
133
- fontSizeChanged: false
83
+ fontSizeChanged: false,
84
+ // touch-only: the buffer snapshot shown by the select-text overlay
85
+ selectTextVisible: false,
86
+ selectTextContent: ''
134
87
  }
135
88
  this.id = `term-${this.props.tab.id}`
136
89
  refs.add(this.id, this)
137
90
  this.currentInput = ''
138
- this.shellInjected = false
139
- this.shellType = null
91
+ // Owns the shell integration injection + cd + runScripts sequence.
92
+ this.startupQueue = new StartupQueue(this, { detectRemoteShell })
140
93
  }
141
94
 
142
95
  domRef = createRef()
143
96
 
97
+ timers = {}
98
+
99
+ // Config keys that cannot be applied by a re-render - they are pushed into
100
+ // the live xterm options instead.
101
+ terminalConfigProps = [
102
+ {
103
+ name: 'rightClickSelectsWord',
104
+ type: 'glob'
105
+ },
106
+ {
107
+ name: 'fontSize',
108
+ type: 'glob_local'
109
+ },
110
+ {
111
+ name: 'fontFamily',
112
+ type: 'glob_local'
113
+ }
114
+ ]
115
+
116
+ // ---- Mobile touch support (behaviour lives in mixins/term-touch.js) ----
117
+ // Only used while store.isTouchDevice is true, so a mouse-driven desktop
118
+ // never touches any of it. Per-instance state has to stay a class field
119
+ // here — a mixin may only export functions, see ./mixins/index.js.
120
+ longPressTimer = null
121
+ touchStartPos = null
122
+ longPressFired = false
123
+ longPressThreshold = 500 // ms
124
+ // a finger drifts further than a mouse while holding still, so the tolerance
125
+ // is looser than the old 10px to stop long presses being read as scrolls
126
+ longPressMoveTolerance = 12 // px
127
+ // { anchor: {col, absRow}, moved, clientX, clientY, target } while a
128
+ // long-press selection is being dragged out
129
+ dragSelect = null
130
+ lastDragTouch = null
131
+ autoScrollDir = 0
132
+ dragFrame = 0
133
+ tapCount = 0
134
+ lastTapTime = 0
135
+ lastTapPos = null
136
+ // the read-only textarea of the select-text overlay
137
+ selectTextRef = createRef()
138
+
139
+ terminalColorQueryDisposables = []
140
+
144
141
  componentDidMount () {
145
- this.initTerminal()
142
+ // xterm and every addon are dynamic imports: a failed chunk fetch would
143
+ // otherwise end as a silent unhandled rejection with a permanently blank
144
+ // terminal pane
145
+ this.initTerminal().catch(e => {
146
+ console.error(e)
147
+ this.handleError({ message: e.message })
148
+ })
146
149
  if (this.props.tab.enableSsh === false) {
147
150
  this.props.tab.pane = paneMap.fileManager
148
151
  }
@@ -187,6 +190,12 @@ class Term extends Component {
187
190
  prevProps,
188
191
  this.props
189
192
  )
193
+ if (
194
+ prevProps.tab?.triggers !== this.props.tab?.triggers &&
195
+ this.triggerManager
196
+ ) {
197
+ this.refreshTriggers()
198
+ }
190
199
  const themeChanged = !isEqual(
191
200
  this.props.themeConfig,
192
201
  prevProps.themeConfig
@@ -207,6 +216,12 @@ class Term extends Component {
207
216
  clearTimeout(this.longPressTimer)
208
217
  this.longPressTimer = null
209
218
  this.touchStartPos = null
219
+ this.stopAutoScroll()
220
+ window.cancelAnimationFrame(this.dragFrame)
221
+ this.dragFrame = 0
222
+ this.dragSelect = null
223
+ this.lastDragTouch = null
224
+ this.lastTapPos = null
210
225
  if (window.store.activeTerminalId === this.props.tab.id) {
211
226
  window.store.activeTerminalId = ''
212
227
  }
@@ -214,10 +229,16 @@ class Term extends Component {
214
229
  this.term.parent = null
215
230
  }
216
231
  this.disposeTerminalColorQueryHandlers()
232
+ this.startupQueue?.dispose()
233
+ try {
234
+ this._notifyOnDataDebounced?.cancel?.()
235
+ } catch (_) {}
236
+ this._notifyOnDataDebounced = null
217
237
  window.cancelAnimationFrame(this.timers.themeRaf)
218
238
  this.timers.themeRaf = null
219
239
  Object.keys(this.timers).forEach(k => {
220
240
  clearTimeout(this.timers[k])
241
+ clearInterval(this.timers[k])
221
242
  this.timers[k] = null
222
243
  })
223
244
  this.onClose = true
@@ -225,11 +246,17 @@ class Term extends Component {
225
246
  this.socket.close()
226
247
  this.socket = null
227
248
  }
249
+ if (this.attachAddon) {
250
+ try {
251
+ this.attachAddon.dispose?.()
252
+ } catch (_) {}
253
+ this.attachAddon = null
254
+ }
228
255
  if (this.term) {
229
256
  this.term.dispose()
230
257
  this.term = null
231
258
  }
232
- this.attachAddon = null
259
+ this.disposeTriggerManager?.()
233
260
  this.fitAddon = null
234
261
  this.zmodemClient = null
235
262
  this.trzszClient = null
@@ -237,2016 +264,144 @@ class Term extends Component {
237
264
  this.searchAddon = null
238
265
  this.fitAddon = null
239
266
  this.cmdAddon = null
267
+ this.serializeAddon = null
240
268
  this.imageAddon = null
241
269
  this.webglContextLossDisposable?.dispose?.()
242
270
  this.webglContextLossDisposable = null
271
+ try {
272
+ this.webglAddon?.dispose?.()
273
+ } catch (_) {}
243
274
  this.webglAddon = null
244
275
  this.webglRecovering = false
245
276
  }
246
277
 
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 = {}
278
+ // ---- small helpers other mixins build on ----
360
279
 
361
- getDomId = () => {
280
+ getDomId () {
362
281
  return `term-${this.props.tab.id}`
363
282
  }
364
283
 
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 = () => {
284
+ isActiveTerminal () {
392
285
  return this.props.tab.id === this.props.activeTabId &&
393
286
  this.props.tab.pane === paneMap.terminal
394
287
  }
395
288
 
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()
289
+ isRemote () {
290
+ return this.props.tab?.host
418
291
  }
419
292
 
420
- pasteSelectedShortcut = (e) => {
421
- e.stopPropagation()
422
- this.tryInsertSelected()
293
+ isSsh () {
294
+ const { host, type } = this.props.tab
295
+ return host && (type === 'ssh' || type === undefined)
423
296
  }
424
297
 
425
- pasteTextTooLong = () => {
426
- if (window.et.isWebApp) {
427
- return false
428
- }
429
- const text = readClipboard()
430
- return text.length > 500
298
+ isLocal () {
299
+ const { host, type } = this.props.tab
300
+ return !host &&
301
+ (type === 'local' || type === undefined)
431
302
  }
432
303
 
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
- })
304
+ isTelnet () {
305
+ return this.props.tab?.type === connectionMap.telnet
450
306
  }
451
307
 
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)
308
+ isSerial () {
309
+ return this.props.tab?.type === connectionMap.serial
458
310
  }
459
311
 
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()) {
312
+ isShellTerminal () {
313
+ const { type } = this.props.tab || {}
314
+ if (!type) {
471
315
  return true
472
316
  }
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()
317
+ return (
318
+ type === connectionMap.ssh ||
319
+ type === connectionMap.local ||
320
+ type === connectionMap.telnet ||
321
+ type === connectionMap.serial
322
+ )
482
323
  }
483
324
 
484
- runQuickCommand = (cmd, inputOnly = false) => {
485
- if (this.term && this.attachAddon) {
486
- this.attachAddon._sendData(cmd + (inputOnly ? '' : '\r'))
487
- this.term.focus()
488
- }
325
+ setStatus = status => {
326
+ const id = this.props.tab?.id
327
+ this.props.editTab(id, {
328
+ status
329
+ })
489
330
  }
490
331
 
491
- cd = (p) => {
492
- if (isUnsafeFilename(p)) {
493
- return message.error('File name contains unsafe characters')
332
+ renderResetFontSizeButton () {
333
+ if (!this.state.fontSizeChanged) {
334
+ return null
494
335
  }
495
- const isWinPath = /^[a-zA-Z]:\\/.test(p)
496
- this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
336
+ const txt = `${e('reset')} ${e('fontSize')}`
337
+ return (
338
+ <Button
339
+ className='terminal-fontsize-reset'
340
+ onClick={this.handleResetFontSize}
341
+ type='default'
342
+ size='small'
343
+ title={txt}
344
+ icon={<AimOutlined />}
345
+ />
346
+ )
497
347
  }
498
348
 
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
349
+ render () {
350
+ const { loading } = this.state
351
+ const { height, width, left, top, fullscreen } = this.props
352
+ const { id } = this.props.tab
353
+ const isActive = this.isActiveTerminal()
354
+ const cls = classnames(
355
+ 'term-wrap',
356
+ 'tw-' + id,
357
+ {
358
+ 'terminal-not-active': !isActive
570
359
  }
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
360
+ )
361
+ const prps1 = {
362
+ className: cls,
363
+ style: {
364
+ height,
365
+ width,
366
+ left,
367
+ top,
368
+ zIndex: 10
369
+ },
370
+ onDrop: this.onDrop,
371
+ onContextMenu: this.onContextMenuInner,
372
+ onTouchStart: this.onTouchStart,
373
+ onTouchMove: this.onTouchMove,
374
+ onTouchEnd: this.onTouchEnd
589
375
  }
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
376
+ // const fileProps = {
377
+ // type: 'file',
378
+ // multiple: true,
379
+ // id: `${id}-file-sel`,
380
+ // className: 'hide'
381
+ // }
382
+ const prps3 = {
383
+ id: this.getDomId(),
384
+ ref: this.domRef,
385
+ className: 'absolute term-wrap-2',
386
+ style: {
387
+ left: 0,
388
+ top: 0,
389
+ right: 0,
390
+ bottom: 0
632
391
  }
633
392
  }
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)
700
- }
701
-
702
- onTouchMove = (e) => {
703
- if (!this.touchStartPos) {
704
- return
393
+ const dropdownProps = {
394
+ menu: {
395
+ items: this.renderContextMenu(),
396
+ onClick: this.onContextMenu
397
+ },
398
+ trigger: this.props.config.pasteWhenContextMenu ? [] : ['contextMenu']
705
399
  }
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
- }
714
- }
715
-
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
- }
731
- }
732
-
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
758
- })
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
- }
851
-
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
2161
- }
2162
- const txt = `${e('reset')} ${e('fontSize')}`
2163
- return (
2164
- <Button
2165
- className='terminal-fontsize-reset'
2166
- onClick={this.handleResetFontSize}
2167
- type='default'
2168
- size='small'
2169
- title={txt}
2170
- icon={<AimOutlined />}
2171
- />
2172
- )
2173
- }
2174
-
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
- render () {
2195
- const { loading } = this.state
2196
- const { height, width, left, top, fullscreen } = this.props
2197
- const { id } = this.props.tab
2198
- const isActive = this.isActiveTerminal()
2199
- const cls = classnames(
2200
- 'term-wrap',
2201
- 'tw-' + id,
2202
- {
2203
- 'terminal-not-active': !isActive
2204
- }
2205
- )
2206
- const prps1 = {
2207
- className: cls,
2208
- style: {
2209
- height,
2210
- width,
2211
- left,
2212
- top,
2213
- zIndex: 10
2214
- },
2215
- onDrop: this.onDrop,
2216
- onContextMenu: this.onContextMenuInner,
2217
- onTouchStart: this.onTouchStart,
2218
- onTouchMove: this.onTouchMove,
2219
- onTouchEnd: this.onTouchEnd
2220
- }
2221
- // const fileProps = {
2222
- // type: 'file',
2223
- // multiple: true,
2224
- // id: `${id}-file-sel`,
2225
- // className: 'hide'
2226
- // }
2227
- const prps3 = {
2228
- id: this.getDomId(),
2229
- ref: this.domRef,
2230
- className: 'absolute term-wrap-2',
2231
- style: {
2232
- left: 0,
2233
- top: 0,
2234
- right: 0,
2235
- bottom: 0
2236
- }
2237
- }
2238
- const dropdownProps = {
2239
- menu: {
2240
- items: this.renderContextMenu(),
2241
- onClick: this.onContextMenu
2242
- },
2243
- trigger: this.props.config.pasteWhenContextMenu ? [] : ['contextMenu']
2244
- }
2245
- const barProps = {
2246
- matchIndex: this.state.matchIndex,
2247
- matches: this.state.searchResults,
2248
- totalLines: this.state.totalLines,
2249
- height
400
+ const barProps = {
401
+ matchIndex: this.state.matchIndex,
402
+ matches: this.state.searchResults,
403
+ totalLines: this.state.totalLines,
404
+ height
2250
405
  }
2251
406
  const spin = loading ? <Spin className='loading-wrapper' spinning={loading} /> : null
2252
407
  return (
@@ -2282,6 +437,14 @@ class Term extends Component {
2282
437
  onCancel={this.handleDropFileModalCancel}
2283
438
  />
2284
439
  {spin}
440
+ <TerminalSelectText
441
+ visible={this.state.selectTextVisible}
442
+ text={this.state.selectTextContent}
443
+ textareaRef={this.selectTextRef}
444
+ onSelectAll={this.handleSelectTextAll}
445
+ onCopy={this.handleSelectTextCopy}
446
+ onClose={this.handleSelectTextClose}
447
+ />
2285
448
  </div>
2286
449
  </Dropdown>
2287
450
  )