@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.
- package/client/common/bookmark-schemas.js +2 -0
- package/client/common/default-setting.js +2 -1
- package/client/common/drop-position.js +59 -0
- package/client/components/bookmark-form/bookmark-schema.js +2 -0
- package/client/components/bookmark-form/common/connection-hopping.jsx +33 -7
- package/client/components/bookmark-form/common/ssh-tunnel-form.jsx +25 -7
- package/client/components/bookmark-form/common/ssh-tunnels.jsx +1 -1
- package/client/components/bookmark-form/config/serial.js +14 -0
- package/client/components/quick-commands/on-drop.js +6 -1
- package/client/components/quick-commands/quick-commands-box.jsx +18 -2
- package/client/components/quick-commands/quick-commands-list-form.jsx +15 -4
- package/client/components/quick-commands/quick-commands-list.jsx +18 -2
- package/client/components/session/session-control.jsx +30 -12
- package/client/components/session/session.jsx +5 -0
- package/client/components/setting-panel/setting-common.jsx +1 -0
- package/client/components/sftp/address-bookmark-item.jsx +15 -2
- package/client/components/sidebar/transport-ui.jsx +21 -14
- package/client/components/tabs/index.jsx +18 -3
- package/client/components/tabs/tab.jsx +45 -9
- package/client/components/tabs/tabs.styl +8 -0
- package/client/components/terminal/attach-addon-custom.js +147 -36
- package/client/components/terminal/highlight-addon.js +26 -8
- package/client/components/terminal/term-search.jsx +1 -1
- package/client/components/terminal/terminal.jsx +75 -1
- package/client/components/terminal-info/format-disks.js +45 -0
- package/client/components/terminal-info/run-cmd.jsx +2 -26
- package/client/components/tree-list/tree-list.jsx +57 -39
- package/client/css/basic.styl +14 -0
- package/client/css/mobile.styl +18 -0
- package/client/store/item.js +10 -6
- 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
|
|
74
|
+
function updateDropIndicator (e) {
|
|
74
75
|
clearCls()
|
|
75
|
-
|
|
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 (
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
139
|
+
let insertIndex = insertAfter ? indexDrop + 1 : indexDrop
|
|
140
|
+
if (indexFrom < insertIndex) {
|
|
141
|
+
insertIndex = insertIndex - 1
|
|
136
142
|
}
|
|
137
|
-
arr.splice(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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(
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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 = {
|