@electerm/electerm-react 3.15.178 → 3.15.187

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 (31) hide show
  1. package/client/common/bookmark-schemas.js +2 -0
  2. package/client/common/default-setting.js +2 -1
  3. package/client/common/drop-position.js +59 -0
  4. package/client/components/bookmark-form/bookmark-schema.js +2 -0
  5. package/client/components/bookmark-form/common/connection-hopping.jsx +33 -7
  6. package/client/components/bookmark-form/common/ssh-tunnel-form.jsx +25 -7
  7. package/client/components/bookmark-form/common/ssh-tunnels.jsx +1 -1
  8. package/client/components/bookmark-form/config/serial.js +14 -0
  9. package/client/components/quick-commands/on-drop.js +6 -1
  10. package/client/components/quick-commands/quick-commands-box.jsx +18 -2
  11. package/client/components/quick-commands/quick-commands-list-form.jsx +15 -4
  12. package/client/components/quick-commands/quick-commands-list.jsx +18 -2
  13. package/client/components/session/session-control.jsx +30 -12
  14. package/client/components/session/session.jsx +5 -0
  15. package/client/components/setting-panel/setting-common.jsx +1 -0
  16. package/client/components/sftp/address-bookmark-item.jsx +15 -2
  17. package/client/components/sidebar/transport-ui.jsx +21 -14
  18. package/client/components/tabs/index.jsx +18 -3
  19. package/client/components/tabs/tab.jsx +45 -9
  20. package/client/components/tabs/tabs.styl +8 -0
  21. package/client/components/terminal/attach-addon-custom.js +147 -36
  22. package/client/components/terminal/highlight-addon.js +26 -8
  23. package/client/components/terminal/term-search.jsx +1 -1
  24. package/client/components/terminal/terminal.jsx +75 -1
  25. package/client/components/terminal-info/format-disks.js +45 -0
  26. package/client/components/terminal-info/run-cmd.jsx +2 -26
  27. package/client/components/tree-list/tree-list.jsx +57 -39
  28. package/client/css/basic.styl +14 -0
  29. package/client/css/mobile.styl +18 -0
  30. package/client/store/item.js +10 -6
  31. package/package.json +1 -1
@@ -13,6 +13,7 @@ import {
13
13
  import { action } from 'manate'
14
14
  import { addClass, removeClass } from '../../common/class'
15
15
  import { refsStatic } from '../common/ref'
16
+ import { isDropAfterHalf, setDropIndicator, clearDropIndicator } from '../../common/drop-position'
16
17
  import './transfer.styl'
17
18
 
18
19
  const e = window.translate
@@ -70,28 +71,28 @@ export default function Transporter (props) {
70
71
  addClass(dom.current, onDragCls)
71
72
  }
72
73
 
73
- function onDragEnter () {
74
+ function updateDropIndicator (e) {
74
75
  clearCls()
75
- addClass(dom.current, onDragOverCls)
76
+ setDropIndicator(dom.current, isDropAfterHalf(e, dom.current))
77
+ }
78
+
79
+ function onDragEnter (e) {
80
+ e.preventDefault()
81
+ updateDropIndicator(e)
76
82
  }
77
83
 
78
84
  function onDragExit () {
79
85
  // debug('ondragexit')
80
- // let {target} = e
81
- // removeClass(target, 'sftp-dragover')
82
86
  }
83
87
 
84
- function onDragLeave (e) {
85
- // debug('ondragleave')
86
- const { target } = e
87
- removeClass(target, onDragOverCls)
88
+ function onDragLeave () {
89
+ clearDropIndicator(dom.current)
88
90
  }
89
91
 
90
92
  function onDragOver (e) {
91
93
  // debug('ondragover')
92
- // debug(e.target)
93
- // removeClass(dom.current, 'sftp-dragover')
94
94
  e.preventDefault()
95
+ updateDropIndicator(e)
95
96
  }
96
97
 
97
98
  function onDragStart (e) {
@@ -103,6 +104,7 @@ export default function Transporter (props) {
103
104
 
104
105
  function onDrop (e) {
105
106
  e.preventDefault()
107
+ clearDropIndicator(dom.current)
106
108
  const { target } = e
107
109
  if (!target) {
108
110
  return
@@ -126,15 +128,19 @@ export default function Transporter (props) {
126
128
 
127
129
  const arr = window.store.fileTransfers
128
130
  const indexFrom = arr.findIndex(t => t.id === fromId)
129
- let indexDrop = arr.findIndex(t => t.id === dropId)
131
+ const indexDrop = arr.findIndex(t => t.id === dropId)
132
+ // bottom half of the target row => insert after it, so the last
133
+ // transfer can receive a drop (append to the end of the queue).
134
+ const insertAfter = isDropAfterHalf(e, onDropTab)
130
135
  if (indexFrom >= 0 && indexDrop >= 0) {
131
136
  // Reorder tabs and update batch
132
137
  action(function () {
133
138
  const [tr] = arr.splice(indexFrom, 1)
134
- if (indexFrom < indexDrop) {
135
- indexDrop = indexDrop - 1
139
+ let insertIndex = insertAfter ? indexDrop + 1 : indexDrop
140
+ if (indexFrom < insertIndex) {
141
+ insertIndex = insertIndex - 1
136
142
  }
137
- arr.splice(indexDrop, 0, tr)
143
+ arr.splice(insertIndex, 0, tr)
138
144
  })()
139
145
  }
140
146
  }
@@ -142,6 +148,7 @@ export default function Transporter (props) {
142
148
  function onDragEnd (e) {
143
149
  removeClass(dom.current, onDragCls)
144
150
  clearCls()
151
+ clearDropIndicator(dom.current)
145
152
  e && e.dataTransfer && e.dataTransfer.clearData()
146
153
  }
147
154
  const isTransfer = typeTo !== typeFrom
@@ -386,6 +386,23 @@ export default class Tabs extends Component {
386
386
  )
387
387
  }
388
388
 
389
+ renderMobileTabsInner (currentTabEl) {
390
+ const inner = (
391
+ <div className='tabs-inner mobile-tabs-inner'>
392
+ {currentTabEl}
393
+ </div>
394
+ )
395
+ const { config } = this.props
396
+ if (config.useSystemTitleBar) {
397
+ return inner
398
+ }
399
+ return (
400
+ <AppDrag>
401
+ {inner}
402
+ </AppDrag>
403
+ )
404
+ }
405
+
389
406
  renderMobileTabs () {
390
407
  const { tabs = [], currentBatchTabId } = this.props
391
408
  const currentTab = tabs.find(t => t.id === currentBatchTabId)
@@ -410,9 +427,7 @@ export default class Tabs extends Component {
410
427
  : null
411
428
  return (
412
429
  <div className='tabs mobile-tabs' ref={this.tabsRef}>
413
- <div className='tabs-inner mobile-tabs-inner'>
414
- {currentTabEl}
415
- </div>
430
+ {this.renderMobileTabsInner(currentTabEl)}
416
431
  {this.renderMobileTabsExtra(items)}
417
432
  {this.renderWindowControl()}
418
433
  </div>
@@ -25,10 +25,12 @@ import isDark from '../../common/is-color-dark'
25
25
  import { action } from 'manate'
26
26
  import iconsMap from '../sys-menu/icons-map.jsx'
27
27
  import { shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
28
+ import { isDropAfterHalf } from '../../common/drop-position'
28
29
 
29
30
  const e = window.translate
30
31
  const onDragCls = 'ondrag-tab'
31
32
  const onDragOverCls = 'dragover-tab'
33
+ const onDragOverAfterCls = 'dragover-tab-after'
32
34
 
33
35
  class Tab extends Component {
34
36
  constructor (props) {
@@ -110,19 +112,36 @@ class Tab extends Component {
110
112
  document.querySelectorAll('.' + onDragOverCls).forEach((d) => {
111
113
  removeClass(d, onDragOverCls)
112
114
  })
115
+ document.querySelectorAll('.' + onDragOverAfterCls).forEach((d) => {
116
+ removeClass(d, onDragOverAfterCls)
117
+ })
113
118
  }
114
119
 
115
120
  handleClick = (e) => {
116
121
  window.store.clickTab(this.props.tab.id, this.props.batch)
117
122
  }
118
123
 
124
+ onMouseEnter = () => {
125
+ const {
126
+ tab,
127
+ batch,
128
+ currentBatchTabId
129
+ } = this.props
130
+ if (document.querySelector('.' + onDragCls)) {
131
+ return
132
+ }
133
+ if (tab.id === currentBatchTabId) {
134
+ return
135
+ }
136
+ window.store.clickTab(tab.id, batch)
137
+ }
138
+
119
139
  onDrag = () => {
120
140
  addClass(this.tabRef.current, onDragCls)
121
141
  }
122
142
 
123
- onDragEnter = () => {
124
- this.clearCls()
125
- addClass(this.tabRef.current, onDragOverCls)
143
+ onDragEnter = (e) => {
144
+ this.applyDragOverCls(e)
126
145
  }
127
146
 
128
147
  onDragExit = () => {
@@ -135,13 +154,22 @@ class Tab extends Component {
135
154
  // debug('ondragleave')
136
155
  const { target } = e
137
156
  removeClass(target, onDragOverCls)
157
+ removeClass(target, onDragOverAfterCls)
158
+ }
159
+
160
+ // Pick the before/after indicator based on which half of the tab
161
+ // the pointer is in, so the drop line shows where it will actually go.
162
+ applyDragOverCls = e => {
163
+ this.clearCls()
164
+ const insertAfter = isDropAfterHalf(e, this.tabRef.current, true)
165
+ addClass(this.tabRef.current, insertAfter ? onDragOverAfterCls : onDragOverCls)
138
166
  }
139
167
 
140
168
  onDragOver = e => {
141
169
  // debug('ondragover')
142
170
  // debug(e.target)
143
- // removeClass(this.tabRef.current, 'sftp-dragover')
144
171
  e.preventDefault()
172
+ this.applyDragOverCls(e)
145
173
  }
146
174
 
147
175
  onDragStart = e => {
@@ -178,7 +206,9 @@ class Tab extends Component {
178
206
  const { id } = fromTab
179
207
  const storeTabs = window.store.tabs
180
208
  const indexFrom = storeTabs.findIndex(t => t.id === id)
181
- let indexDrop = storeTabs.findIndex(t => t.id === dropId)
209
+ const indexDrop = storeTabs.findIndex(t => t.id === dropId)
210
+ // tabs are laid out horizontally: right half of the target => insert after
211
+ const insertAfter = isDropAfterHalf(e, onDropTab, true)
182
212
 
183
213
  if (indexFrom >= 0 && indexDrop >= 0) {
184
214
  const targetTab = storeTabs[indexDrop]
@@ -197,10 +227,13 @@ class Tab extends Component {
197
227
  action(function () {
198
228
  const [tab] = storeTabs.splice(indexFrom, 1)
199
229
  tab.batch = targetTab.batch // Update the batch to match target tab's batch
200
- if (indexFrom < indexDrop) {
201
- indexDrop = indexDrop - 1
230
+ // insertAfter lets a tab land after the target, so the last tab
231
+ // can receive a drop (insertIndex can reach storeTabs.length).
232
+ let insertIndex = insertAfter ? indexDrop + 1 : indexDrop
233
+ if (indexFrom < insertIndex) {
234
+ insertIndex = insertIndex - 1
202
235
  }
203
- storeTabs.splice(indexDrop, 0, tab)
236
+ storeTabs.splice(insertIndex, 0, tab)
204
237
  })()
205
238
  window.store.focus()
206
239
  }
@@ -216,6 +249,7 @@ class Tab extends Component {
216
249
 
217
250
  onDragEnd = e => {
218
251
  removeClass(this.tabRef.current, onDragCls)
252
+ removeClass(this.tabRef.current, onDragOverAfterCls)
219
253
  this.clearCls()
220
254
  e && e.dataTransfer && e.dataTransfer.clearData()
221
255
  }
@@ -422,7 +456,8 @@ class Tab extends Component {
422
456
  const {
423
457
  isLast,
424
458
  tab,
425
- currentBatchTabId
459
+ currentBatchTabId,
460
+ config
426
461
  } = this.props
427
462
  const {
428
463
  id,
@@ -480,6 +515,7 @@ class Tab extends Component {
480
515
  data-id={id}
481
516
  {...pick(this, [
482
517
  'onDrag',
518
+ ...(config && config.switchTabOnHover ? ['onMouseEnter'] : []),
483
519
  'onDragEnter',
484
520
  'onDragExit',
485
521
  'onDragLeave',
@@ -68,6 +68,14 @@
68
68
  width 1px
69
69
  border 1px dashed var(--text-dark)
70
70
  height 36px
71
+ &.dragover-tab-after::after
72
+ position absolute
73
+ content ''
74
+ right -2px
75
+ top 0
76
+ width 1px
77
+ border 1px dashed var(--text-dark)
78
+ height 36px
71
79
  @keyframes blink
72
80
  0%
73
81
  background-color #e0e0e0
@@ -23,6 +23,32 @@ export default class AttachAddonCustom {
23
23
  this._passwordPromptDetected = false
24
24
  this._pendingEchoCheck = null
25
25
  this._echoCheckTimer = null
26
+ // Write coalescing: terminal output is buffered and flushed on a short
27
+ // timer so that heavy bursts (e.g. `yum install` download progress, which
28
+ // rewrites the same line with \r + clear-line thousands of times per
29
+ // second) collapse into a few term.write() calls per frame instead of
30
+ // blocking the main thread on every WebSocket message.
31
+ this._writeBuffer = []
32
+ this._bufferBytes = 0
33
+ this._flushScheduled = false
34
+ this._flushTimer = null
35
+ // Coalescing window. Output is flushed at most once per interval.
36
+ // 16ms ~= one frame; low enough that interactive echo feels instant,
37
+ // high enough to merge a multi-MB/s flood into ~60 writes/s.
38
+ this._flushIntervalMs = 16
39
+ // Time of the last actual flush. Drives the interactive fast path in
40
+ // _enqueueWrite: output arriving after an idle gap (keystroke echo,
41
+ // command result) is flushed immediately instead of paying the
42
+ // coalescing delay. 0 = the first chunk ever flushes immediately.
43
+ this._lastFlushTime = 0
44
+ // Soft cap on buffered-but-unflushed bytes. Under a sustained flood the
45
+ // producer outruns the renderer; once pending output exceeds this we drop
46
+ // the OLDEST data (preserving the newest, visible tail and the line
47
+ // currently being rewritten). Normal interactive output is many orders of
48
+ // magnitude smaller and is never dropped.
49
+ this._maxBufferBytes = 256 * 1024
50
+ this._droppedBytes = 0
51
+ this._droppedWarned = false
26
52
  }
27
53
 
28
54
  _initBase = async () => {
@@ -182,27 +208,11 @@ export default class AttachAddonCustom {
182
208
  }
183
209
  }
184
210
 
185
- if (this.outputSuppressed) {
186
- let str = data
187
- if (typeof data !== 'string') {
188
- const decoder = this.decoder || new TextDecoder('utf-8')
189
- try {
190
- str = decoder.decode(data instanceof ArrayBuffer ? data : new Uint8Array(data))
191
- } catch (e) {
192
- str = ''
193
- }
194
- }
195
-
196
- if (this.checkForShellIntegration(str)) {
197
- this.onShellIntegrationDetected()
198
- return
199
- }
200
-
201
- this.suppressedData.push(data)
202
- return
203
- }
204
-
205
- // Password prompt detection on output
211
+ // Decode once, synchronously. The previous implementation routed binary
212
+ // chunks through `new Blob()` + `FileReader.readAsArrayBuffer()` and decoded
213
+ // in an async `onRead` callback; that async round-trip (plus a per-chunk
214
+ // Blob allocation) showed up in the profile and fragmented main-thread work.
215
+ // We already hold a TextDecoder, so decode inline like the suppression path.
206
216
  let str = data
207
217
  if (typeof data !== 'string') {
208
218
  try {
@@ -213,6 +223,18 @@ export default class AttachAddonCustom {
213
223
  str = ''
214
224
  }
215
225
  }
226
+
227
+ if (this.outputSuppressed) {
228
+ if (this.checkForShellIntegration(str)) {
229
+ this.onShellIntegrationDetected()
230
+ return
231
+ }
232
+ this.suppressedData.push(data)
233
+ return
234
+ }
235
+
236
+ // Prompt/echo detection runs per chunk (cheap) so password prompts and
237
+ // shell integration are still detected promptly.
216
238
  this._handleEchoDetection(str)
217
239
  if (this._checkPasswordPrompt(str) && !this._passwordPromptDetected) {
218
240
  this._passwordPromptDetected = true
@@ -222,27 +244,109 @@ export default class AttachAddonCustom {
222
244
  }, 100)
223
245
  }
224
246
 
225
- if (typeof data === 'string') {
226
- term?.parent?.notifyOnData()
227
- term.write(data)
228
- // Notify parent that the terminal buffer has been updated (echo received).
229
- // This allows the suggestion dropdown to refresh after the buffer reflects
230
- // the latest server-side state (e.g., after backspace echo).
231
- term?.parent?.onTerminalWrite?.()
247
+ // Coalesce the actual write (see _enqueueWrite). notifyOnData /
248
+ // onTerminalWrite fire once per flush instead of once per chunk.
249
+ this._enqueueWrite(str)
250
+ }
251
+
252
+ // Buffer decoded output and flush it on a short timer so a burst of messages
253
+ // becomes a single (size-capped) term.write() call, keeping the main thread
254
+ // free to handle UI events and input between flushes.
255
+ _enqueueWrite = (str) => {
256
+ if (!this.term || !str) {
257
+ return
258
+ }
259
+ this._writeBuffer.push(str)
260
+ this._bufferBytes += str.length
261
+ if (this._bufferBytes > this._maxBufferBytes) {
262
+ this._dropOldestUntil()
263
+ }
264
+ // A hidden window gets its timers throttled by Chromium (Electron's
265
+ // backgroundThrottling defaults to true), which would stall the flush
266
+ // timer and force the byte cap to drop real output. Nothing is painted
267
+ // while hidden, so coalescing buys nothing — flush synchronously.
268
+ if (document.hidden) {
269
+ clearTimeout(this._flushTimer)
270
+ this._flushWrites()
232
271
  return
233
272
  }
234
- data = new Uint8Array(data)
235
- const fileReader = new FileReader()
236
- fileReader.addEventListener('load', this.onRead)
237
- fileReader.readAsArrayBuffer(new window.Blob([data]))
273
+ // Interactive fast path: if the last flush was longer ago than the
274
+ // coalescing window (keystroke echo, command result after idle), flush
275
+ // immediately so typing adds zero latency. Only output arriving inside
276
+ // a burst window pays the coalescing delay — which is exactly the flood
277
+ // case coalescing exists for.
278
+ const elapsed = Date.now() - this._lastFlushTime
279
+ if (elapsed >= this._flushIntervalMs) {
280
+ clearTimeout(this._flushTimer)
281
+ this._flushWrites()
282
+ return
283
+ }
284
+ if (!this._flushScheduled) {
285
+ this._flushScheduled = true
286
+ this._flushTimer = setTimeout(this._flushWrites, this._flushIntervalMs - elapsed)
287
+ }
238
288
  }
239
289
 
240
- onRead = (ev) => {
241
- const data = ev.target.result
290
+ // Under heavy flood the producer outruns the renderer; once pending output
291
+ // exceeds the cap we drop the OLDEST chunks (preserving the newest, visible
292
+ // tail and the line currently being rewritten). The first kept chunk is
293
+ // trimmed to the next newline so we never render a half line / broken escape.
294
+ _dropOldestUntil = () => {
295
+ const buf = this._writeBuffer
296
+ let kept = 0
297
+ let cutIdx = buf.length
298
+ for (let i = buf.length - 1; i >= 0; i--) {
299
+ if (kept + buf[i].length > this._maxBufferBytes) {
300
+ cutIdx = i + 1
301
+ break
302
+ }
303
+ kept += buf[i].length
304
+ }
305
+ if (cutIdx <= 0) {
306
+ return
307
+ }
308
+ let dropped = 0
309
+ for (let i = 0; i < cutIdx; i++) {
310
+ dropped += buf[i].length
311
+ }
312
+ if (cutIdx < buf.length) {
313
+ const first = buf[cutIdx]
314
+ const nl = first.indexOf('\n')
315
+ if (nl >= 0 && nl < first.length - 1) {
316
+ dropped += nl + 1
317
+ buf[cutIdx] = first.slice(nl + 1)
318
+ } else {
319
+ dropped += buf[cutIdx].length
320
+ cutIdx += 1
321
+ }
322
+ }
323
+ this._writeBuffer = buf.slice(cutIdx)
324
+ this._bufferBytes -= dropped
325
+ this._droppedBytes += dropped
326
+ if (!this._droppedWarned) {
327
+ this._droppedWarned = true
328
+ console.warn('[AttachAddon] Heavy output detected; coalescing writes and dropping intermediate output to keep the UI responsive.')
329
+ }
330
+ }
331
+
332
+ _flushWrites = () => {
333
+ this._flushScheduled = false
334
+ this._flushTimer = null
335
+ const buf = this._writeBuffer
336
+ if (!buf.length || !this.term) {
337
+ this._writeBuffer = []
338
+ this._bufferBytes = 0
339
+ return
340
+ }
341
+ this._writeBuffer = []
342
+ this._bufferBytes = 0
343
+ const data = buf.length === 1 ? buf[0] : buf.join('')
242
344
  const { term } = this
345
+ this._lastFlushTime = Date.now()
346
+ term.write(data)
347
+ // Notify parent that the terminal buffer has been updated (echo received),
348
+ // once per flush instead of once per chunk.
243
349
  term?.parent?.notifyOnData()
244
- const str = this.decoder.decode(data)
245
- term?.write(str)
246
350
  term?.parent?.onTerminalWrite?.()
247
351
  }
248
352
 
@@ -324,6 +428,13 @@ export default class AttachAddonCustom {
324
428
  this._stopKeepalive()
325
429
  clearTimeout(this._echoCheckTimer)
326
430
  this._echoCheckTimer = null
431
+ if (this._flushTimer) {
432
+ clearTimeout(this._flushTimer)
433
+ this._flushTimer = null
434
+ }
435
+ this._flushScheduled = false
436
+ this._writeBuffer = []
437
+ this._bufferBytes = 0
327
438
  this.term = null
328
439
  this._disposables.forEach(d => d.dispose())
329
440
  this._disposables.length = 0
@@ -2,6 +2,18 @@ export class KeywordHighlighterAddon {
2
2
  constructor (keywords) {
3
3
  this.keywords = keywords || []
4
4
  this.compiledPatterns = this.compilePatterns()
5
+ // Writes larger than this bypass keyword highlighting. Highlighting is meant
6
+ // for interactive command output; a large chunk is a command flood or
7
+ // download progress where scanning every byte (and rebuilding the result
8
+ // string) is pure overhead. Coupled with write coalescing in the attach
9
+ // addon, flood output skips this path entirely.
10
+ this.maxHighlightLength = 4096
11
+ // Compile the ANSI/OSC splitter once instead of allocating a new RegExp on
12
+ // every write (this was the #1 allocation in the hot path under load).
13
+ const ESC = String.fromCharCode(27) // \x1b
14
+ const BEL = String.fromCharCode(7) // \x07
15
+ // eslint-disable-next-line no-control-regex
16
+ this._ansiPattern = new RegExp('(' + ESC + '\\][^' + BEL + ESC + ']*(?:' + BEL + '|' + ESC + '\\\\)|' + ESC + '\\[\\??[0-9;]*[A-Za-z])', 'g')
5
17
  }
6
18
 
7
19
  // Pre-compile all regex patterns once for better performance
@@ -54,11 +66,8 @@ export class KeywordHighlighterAddon {
54
66
 
55
67
  // Split text into segments: ANSI/OSC sequences vs plain text
56
68
  // Match OSC sequences (ESC ] ... BEL or ESC ] ... ESC \) and CSI sequences (ESC [ ... letter)
57
- // Use String.fromCharCode to avoid lint warnings about control characters
58
- const ESC = String.fromCharCode(27) // \x1b
59
- const BEL = String.fromCharCode(7) // \x07
60
- // eslint-disable-next-line no-control-regex
61
- const ansiPattern = new RegExp('(' + ESC + '\\][^' + BEL + ESC + ']*(?:' + BEL + '|' + ESC + '\\\\)|' + ESC + '\\[\\??[0-9;]*[A-Za-z])', 'g')
69
+ const ansiPattern = this._ansiPattern
70
+ ansiPattern.lastIndex = 0
62
71
 
63
72
  const segments = []
64
73
  let lastIndex = 0
@@ -142,9 +151,18 @@ export class KeywordHighlighterAddon {
142
151
  if (self.containsDcsSequence(data)) {
143
152
  return self.originalWrite(data)
144
153
  }
145
- self.originalWrite(
146
- terminal.displayRaw ? self.escape(data) : self.highlightKeywords(data)
147
- )
154
+ // displayRaw applies to every write regardless of size: raw mode shows
155
+ // escape sequences as text, so skipping escape() for large chunks would
156
+ // let raw ESC bytes be interpreted by xterm instead of displayed.
157
+ if (terminal.displayRaw) {
158
+ return self.originalWrite(self.escape(data))
159
+ }
160
+ // Skip keyword highlighting for large writes (command floods, download
161
+ // progress). Highlighting targets interactive command output.
162
+ if (data.length > self.maxHighlightLength) {
163
+ return self.originalWrite(data)
164
+ }
165
+ self.originalWrite(self.highlightKeywords(data))
148
166
  }
149
167
  }
150
168
 
@@ -70,7 +70,7 @@ export default class TermSearch extends PureComponent {
70
70
  } = this.props
71
71
  refs.get('term-' + activeTabId)
72
72
  ?.searchPrev(
73
- searchTerm ?? this.props.termSearch,
73
+ typeof searchTerm === 'string' ? searchTerm : this.props.termSearch,
74
74
  copy(termSearchOptions)
75
75
  )
76
76
  }
@@ -21,7 +21,8 @@ import {
21
21
  rendererTypes,
22
22
  isMac,
23
23
  isMacJs,
24
- connectionMap
24
+ connectionMap,
25
+ terminalSerialType
25
26
  } from '../../common/constants.js'
26
27
  import deepCopy from 'json-deep-copy'
27
28
  import { readClipboardAsync, readClipboard, copy } from '../../common/clipboard.js'
@@ -74,6 +75,38 @@ import {
74
75
 
75
76
  const e = window.translate
76
77
 
78
+ // Expands \n \t \r \\ and \xHH hex byte escapes, used to let users type
79
+ // control bytes (e.g. \x01 = Ctrl+A) in the serial "closeSequence" field.
80
+ function expandCloseSequence (text) {
81
+ let result = ''
82
+ for (let i = 0; i < text.length; i++) {
83
+ if (text[i] === '\\' && i + 1 < text.length) {
84
+ const next = text[i + 1]
85
+ if (next === 'n') {
86
+ result += '\n'
87
+ i++
88
+ } else if (next === 't') {
89
+ result += '\t'
90
+ i++
91
+ } else if (next === 'r') {
92
+ result += '\r'
93
+ i++
94
+ } else if (next === '\\') {
95
+ result += '\\'
96
+ i++
97
+ } else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
98
+ result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
99
+ i += 3
100
+ } else {
101
+ result += text[i]
102
+ }
103
+ } else {
104
+ result += text[i]
105
+ }
106
+ }
107
+ return result
108
+ }
109
+
77
110
  class Term extends Component {
78
111
  constructor (props) {
79
112
  super(props)
@@ -635,6 +668,7 @@ class Term extends Component {
635
668
  // long-press detection here.
636
669
  longPressTimer = null
637
670
  touchStartPos = null
671
+ longPressFired = false
638
672
  longPressThreshold = 500 // ms
639
673
  longPressMoveTolerance = 10 // px
640
674
 
@@ -648,6 +682,7 @@ class Term extends Component {
648
682
  clientY: touch.clientY,
649
683
  target: e.currentTarget
650
684
  }
685
+ this.longPressFired = false
651
686
  clearTimeout(this.longPressTimer)
652
687
  this.longPressTimer = setTimeout(() => {
653
688
  this.handleLongPress()
@@ -669,15 +704,27 @@ class Term extends Component {
669
704
  }
670
705
 
671
706
  onTouchEnd = () => {
707
+ const wasTap = this.touchStartPos && !this.longPressFired
672
708
  clearTimeout(this.longPressTimer)
673
709
  this.longPressTimer = null
674
710
  this.touchStartPos = null
711
+ // xterm's own touch (Gesture) handler calls preventDefault()/stopPropagation()
712
+ // on touchstart/touchend, which suppresses the synthesised mousedown xterm
713
+ // relies on to focus its hidden helper textarea. As a result a tap never
714
+ // focuses the terminal on touch devices, so the soft keyboard never opens
715
+ // and you cannot type. Focus explicitly on a clean tap (not a long-press,
716
+ // not a scroll) so mobile input works. The focus() runs synchronously inside
717
+ // this user-gesture handler, so iOS/Android will show the keyboard.
718
+ if (wasTap && this.term) {
719
+ this.term.focus()
720
+ }
675
721
  }
676
722
 
677
723
  handleLongPress = () => {
678
724
  if (!this.touchStartPos || this.state.loading) {
679
725
  return
680
726
  }
727
+ this.longPressFired = true
681
728
  const { clientX, clientY, target } = this.touchStartPos
682
729
 
683
730
  // Select the word at the touch position (same as desktop right-click word
@@ -1984,6 +2031,33 @@ class Term extends Component {
1984
2031
  this.props.delTab(id)
1985
2032
  }
1986
2033
 
2034
+ /**
2035
+ * Manually triggered from the "exit gracefully" control in
2036
+ * session-control.jsx (serial tabs only). Writes the configured key
2037
+ * sequence (default \x01ky = Ctrl+A, k, y to kill a GNU screen window) to
2038
+ * the socket, waits a bit so the remote end (e.g. a Bluetooth console
2039
+ * adapter) can release cleanly, then closes the tab.
2040
+ */
2041
+ exitGracefully = async () => {
2042
+ const { tab } = this.props
2043
+ if (tab.type !== terminalSerialType) {
2044
+ return
2045
+ }
2046
+ if (!this.onClose && this.attachAddon?._sendData) {
2047
+ try {
2048
+ const data = expandCloseSequence(tab.closeSequence || '\\x01ky')
2049
+ if (data) {
2050
+ this.attachAddon._sendData(data)
2051
+ }
2052
+ } catch (err) {
2053
+ console.error('send close sequence failed', err)
2054
+ }
2055
+ const delay = Number(tab.closeSequenceDelay)
2056
+ await new Promise(resolve => setTimeout(resolve, Number.isFinite(delay) && delay >= 0 ? delay : 500))
2057
+ }
2058
+ this.props.delTab(tab.id)
2059
+ }
2060
+
1987
2061
  handleShowInfo = () => {
1988
2062
  const { logName, tab } = this.props
1989
2063
  const infoProps = {