@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.
- package/README.md +246 -12
- package/client/common/choose-save-folder.js +21 -2
- package/client/common/constants.js +20 -6
- package/client/common/create-title.jsx +6 -0
- package/client/common/default-setting.js +15 -0
- package/client/common/init-setting-item.js +5 -0
- package/client/common/platform.js +24 -0
- package/client/components/ai/agent-tool-call-card.jsx +10 -2
- package/client/components/ai/agent.js +4 -1
- package/client/components/ai/ai-attachments.js +87 -0
- package/client/components/ai/ai-chat-empty.jsx +43 -0
- package/client/components/ai/ai-chat-entry.jsx +13 -4
- package/client/components/ai/ai-chat-history-item.jsx +28 -4
- package/client/components/ai/ai-chat-history.jsx +2 -1
- package/client/components/ai/ai-chat.jsx +104 -13
- package/client/components/ai/ai-config-props.js +4 -130
- package/client/components/ai/ai-config.jsx +135 -15
- package/client/components/ai/ai-presets.js +173 -0
- package/client/components/ai/ai.styl +83 -0
- package/client/components/bg/css-overwrite.jsx +11 -0
- package/client/components/bookmark-form/bookmark-schema.js +4 -0
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/config/common-fields.js +12 -0
- package/client/components/bookmark-form/config/local.js +3 -2
- package/client/components/bookmark-form/config/serial.js +3 -2
- package/client/components/bookmark-form/config/ssh.js +2 -1
- package/client/components/bookmark-form/config/telnet.js +3 -2
- package/client/components/common/item-filter.jsx +76 -0
- package/client/components/common/item-filter.styl +54 -0
- package/client/components/common/lazy-boundary.jsx +34 -0
- package/client/components/common/opacity.jsx +3 -1
- package/client/components/footer/cmd-history.jsx +132 -45
- package/client/components/footer/cmd-history.styl +42 -4
- package/client/components/footer/footer-entry.jsx +35 -5
- package/client/components/footer/footer.styl +8 -1
- package/client/components/footer/multi-tab-run-modal.jsx +83 -0
- package/client/components/footer/tab-select.jsx +34 -17
- package/client/components/layout/layout.jsx +23 -31
- package/client/components/layout/pixed.js +3 -0
- package/client/components/main/main.jsx +55 -28
- package/client/components/main/term-fullscreen.styl +9 -3
- package/client/components/quick-commands/qm.styl +3 -2
- package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
- package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
- package/client/components/rdp/file-transfer.js +4 -4
- package/client/components/remote-monitor/monitor-details.jsx +458 -0
- package/client/components/remote-monitor/monitor-details.styl +87 -0
- package/client/components/remote-monitor/monitor-model.js +764 -0
- package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
- package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
- package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
- package/client/components/remote-monitor/session-monitor.js +361 -0
- package/client/components/remote-monitor/use-monitor-details.js +10 -0
- package/client/components/remote-monitor/visibility.js +18 -0
- package/client/components/session/session-control.jsx +29 -2
- package/client/components/session/session.jsx +40 -18
- package/client/components/session/sessions.jsx +5 -2
- package/client/components/setting-panel/setting-common.jsx +25 -56
- package/client/components/setting-panel/setting-modal.jsx +57 -42
- package/client/components/setting-panel/setting-terminal.jsx +8 -2
- package/client/components/setting-panel/setting.styl +1 -1
- package/client/components/setting-panel/tab-triggers.jsx +71 -0
- package/client/components/sftp/file-compare-modal.jsx +6 -3
- package/client/components/sftp/file-item.jsx +5 -3
- package/client/components/sftp/owner-list.js +11 -2
- package/client/components/sftp/sftp-entry.jsx +4 -0
- package/client/components/side-panel-r/side-panel-r.jsx +2 -2
- package/client/components/sidebar/side-panel.jsx +1 -1
- package/client/components/sys-menu/menu-btn.jsx +30 -1
- package/client/components/tabs/index.jsx +2 -2
- package/client/components/tabs/tab-title.jsx +3 -2
- package/client/components/tabs/tab.jsx +5 -1
- package/client/components/terminal/attach-addon-custom.js +295 -49
- package/client/components/terminal/automation/index.js +64 -0
- package/client/components/terminal/automation/keys.js +49 -0
- package/client/components/terminal/automation/stream-matcher.js +82 -0
- package/client/components/terminal/automation/strip-ansi.js +19 -0
- package/client/components/terminal/automation/trigger-engine.js +233 -0
- package/client/components/terminal/automation/trigger-presets.js +110 -0
- package/client/components/terminal/command-tracker-addon.js +31 -1
- package/client/components/terminal/mixins/index.js +27 -0
- package/client/components/terminal/mixins/term-attach.js +123 -0
- package/client/components/terminal/mixins/term-config.jsx +84 -0
- package/client/components/terminal/mixins/term-content.js +126 -0
- package/client/components/terminal/mixins/term-context-menu.jsx +323 -0
- package/client/components/terminal/mixins/term-file-drop.js +149 -0
- package/client/components/terminal/mixins/term-init.js +122 -0
- package/client/components/terminal/mixins/term-log.jsx +96 -0
- package/client/components/terminal/mixins/term-resize.js +87 -0
- package/client/components/terminal/mixins/term-socket.js +433 -0
- package/client/components/terminal/mixins/term-suggestions.js +224 -0
- package/client/components/terminal/mixins/term-theme.js +190 -0
- package/client/components/terminal/mixins/term-touch.js +523 -0
- package/client/components/terminal/shell-detect.js +37 -0
- package/client/components/terminal/shell.js +0 -28
- package/client/components/terminal/shortcut-bar-defs.js +30 -4
- package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
- package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
- package/client/components/terminal/shortcut-bar.jsx +17 -10
- package/client/components/terminal/shortcut-bar.styl +4 -0
- package/client/components/terminal/ssh-reload-state.js +96 -0
- package/client/components/terminal/startup-queue.js +433 -0
- package/client/components/terminal/terminal-interactive.jsx +32 -0
- package/client/components/terminal/terminal-select-text.jsx +69 -0
- package/client/components/terminal/terminal.jsx +227 -2064
- package/client/components/terminal/terminal.styl +61 -0
- package/client/components/terminal/transfer-client-base.js +5 -29
- package/client/components/terminal/trzsz-client.js +1 -3
- package/client/components/terminal/xmodem-client.js +1 -3
- package/client/components/terminal/xterm-loader.js +7 -0
- package/client/components/terminal/zmodem-client.js +1 -3
- package/client/components/terminal-info/base.jsx +18 -70
- package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
- package/client/components/terminal-info/terminal-info.jsx +50 -59
- package/client/components/terminal-info/terminal-info.styl +6 -49
- package/client/components/text-editor/open-text-editor.js +30 -0
- package/client/components/text-editor/text-editor-entry.jsx +17 -6
- package/client/components/text-editor/text-editor.jsx +14 -0
- package/client/components/triggers/trigger-editor.jsx +246 -0
- package/client/components/triggers/trigger-form.jsx +200 -0
- package/client/components/triggers/trigger-session-modal.jsx +199 -0
- package/client/components/triggers/trigger-setting-list.jsx +89 -0
- package/client/components/triggers/trigger-transport.jsx +60 -0
- package/client/components/triggers/triggers-field.jsx +19 -0
- package/client/components/widgets/widgets-list.jsx +11 -1
- package/client/css/mobile.styl +29 -8
- package/client/store/common.js +12 -1
- package/client/store/init-state.js +7 -0
- package/client/store/mcp-handler.js +158 -30
- package/client/store/setting.js +9 -0
- package/client/store/store.js +2 -0
- package/client/store/sync.js +2 -0
- package/client/store/tab.js +42 -3
- package/client/store/trigger.js +118 -0
- package/client/store/watch.js +13 -5
- package/client/views/index.pug +3 -0
- package/package.json +1 -1
- package/client/components/terminal-info/activity.jsx +0 -57
- package/client/components/terminal-info/data-cols-parser.jsx +0 -54
- package/client/components/terminal-info/disk.jsx +0 -45
- package/client/components/terminal-info/format-disks.js +0 -45
- package/client/components/terminal-info/network.jsx +0 -121
- package/client/components/terminal-info/resource.jsx +0 -99
- package/client/components/terminal-info/run-cmd.jsx +0 -263
- package/client/components/terminal-info/sys-info.jsx +0 -56
- package/client/components/terminal-info/up.jsx +0 -17
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import deepCopy from 'json-deep-copy'
|
|
2
|
+
import { loadWebglAddon } from '../xterm-loader.js'
|
|
3
|
+
import { rendererTypes } from '../../../common/constants.js'
|
|
4
|
+
import {
|
|
5
|
+
createRendererThemeConfig,
|
|
6
|
+
handleTerminalColorQuery,
|
|
7
|
+
blendSelectionOverBackground
|
|
8
|
+
} from '../terminal-color-query.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renderer (DOM / WebGL) and theming, including the OSC 10/11 colour query
|
|
12
|
+
* handlers that let shells ask the terminal for its background colour.
|
|
13
|
+
*/
|
|
14
|
+
export const themeMixin = {
|
|
15
|
+
async loadRenderer (term, config) {
|
|
16
|
+
// xterm 6.x: only the built-in DOM renderer and the WebGL addon exist
|
|
17
|
+
// (the canvas renderer addon was removed in 6.x). 'dom' = no addon loaded
|
|
18
|
+
// (built-in DOM renderer). Legacy 'canvas' settings fall back to DOM.
|
|
19
|
+
if (config.rendererType === rendererTypes.webGL) {
|
|
20
|
+
try {
|
|
21
|
+
const WebglAddon = await loadWebglAddon()
|
|
22
|
+
const webglAddon = new WebglAddon()
|
|
23
|
+
this.webglAddon = webglAddon
|
|
24
|
+
// On macOS native fullscreen the GPU/WebGL context can be lost when
|
|
25
|
+
// the window migrates across Spaces. Without a listener xterm keeps
|
|
26
|
+
// drawing into a dead context and every terminal goes black while the
|
|
27
|
+
// rest of the UI stays alive. Rebuild the addon to recover.
|
|
28
|
+
this.webglContextLossDisposable = webglAddon.onContextLoss(this.handleWebglContextLoss)
|
|
29
|
+
term.loadAddon(webglAddon)
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error('render with webgl failed, fallback to dom renderer')
|
|
32
|
+
console.error(e)
|
|
33
|
+
// built-in DOM renderer is used (no addon loaded)
|
|
34
|
+
this.webglAddon = null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
reloadWebglRenderer (reason = 'reload') {
|
|
40
|
+
console.warn(`webgl renderer ${reason}, rebuilding`)
|
|
41
|
+
try {
|
|
42
|
+
this.webglContextLossDisposable?.dispose?.()
|
|
43
|
+
this.webglContextLossDisposable = null
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(e)
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
this.webglAddon?.dispose?.()
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(e)
|
|
51
|
+
}
|
|
52
|
+
this.webglAddon = null
|
|
53
|
+
const { term } = this
|
|
54
|
+
const { config } = this.props
|
|
55
|
+
return this.loadRenderer(term, config)
|
|
56
|
+
.then(() => {
|
|
57
|
+
term.refresh(0, term.rows - 1)
|
|
58
|
+
})
|
|
59
|
+
.catch(e => {
|
|
60
|
+
console.error(`webgl renderer ${reason} failed`, e)
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
handleWebglContextLoss (webglAddon = this.webglAddon) {
|
|
65
|
+
if (this.webglRecovering || !webglAddon) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
this.webglRecovering = true
|
|
69
|
+
this.reloadWebglRenderer('context loss')
|
|
70
|
+
.finally(() => {
|
|
71
|
+
this.webglRecovering = false
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
disposeTerminalColorQueryHandlers () {
|
|
76
|
+
this.terminalColorQueryDisposables.forEach(disposable => disposable?.dispose?.())
|
|
77
|
+
this.terminalColorQueryDisposables.length = 0
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
getVisibleTerminalBackground () {
|
|
81
|
+
const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
|
|
82
|
+
// The store value (uiThemeConfig.main) is always immediately up-to-date
|
|
83
|
+
// when the theme changes, because it reads directly from store.config.theme.
|
|
84
|
+
// The CSS --main variable lags behind because UiTheme's useEffect runs
|
|
85
|
+
// asynchronously after componentDidUpdate. So we prioritise the store
|
|
86
|
+
// value, and only fall back to CSS (for custom-CSS edge cases) or the
|
|
87
|
+
// terminal theme background (last resort).
|
|
88
|
+
const root = document.documentElement
|
|
89
|
+
const cssMain = root && window.getComputedStyle
|
|
90
|
+
? window.getComputedStyle(root).getPropertyValue('--main').trim()
|
|
91
|
+
: ''
|
|
92
|
+
return uiThemeConfig.main || cssMain || this.props.themeConfig.background
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getVisibleTerminalForeground () {
|
|
96
|
+
const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
|
|
97
|
+
return uiThemeConfig.text
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
registerTerminalColorQueryHandlers (term, themeConfig = {}) {
|
|
101
|
+
this.disposeTerminalColorQueryHandlers()
|
|
102
|
+
if (!term?.parser?.registerOscHandler) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const background = this.getVisibleTerminalBackground()
|
|
106
|
+
const foregroundFallback = this.getVisibleTerminalForeground()
|
|
107
|
+
this.terminalColorQueryDisposables.push(
|
|
108
|
+
term.parser.registerOscHandler(10, data => {
|
|
109
|
+
return handleTerminalColorQuery(term, 10, themeConfig.foreground, foregroundFallback, data)
|
|
110
|
+
}),
|
|
111
|
+
term.parser.registerOscHandler(11, data => {
|
|
112
|
+
return handleTerminalColorQuery(term, 11, background, themeConfig.background, data)
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
getRendererThemeConfig (themeConfig = this.props.themeConfig) {
|
|
118
|
+
return createRendererThemeConfig(
|
|
119
|
+
deepCopy(themeConfig),
|
|
120
|
+
this.props.config.rendererType,
|
|
121
|
+
this.getVisibleTerminalBackground()
|
|
122
|
+
)
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply the current renderer theme to the terminal and trigger a repaint.
|
|
127
|
+
* When `deferred` is true (WebGL mode), a second repaint is scheduled on
|
|
128
|
+
* the next animation frame so the theme picks up CSS --main changes that
|
|
129
|
+
* UiTheme's useEffect applies asynchronously after componentDidUpdate.
|
|
130
|
+
* The optional `term` parameter is used during initTerminal, where
|
|
131
|
+
* this.term hasn't been assigned yet.
|
|
132
|
+
*/
|
|
133
|
+
applyTerminalTheme (deferred = false, term = this.term) {
|
|
134
|
+
if (!term || this.onClose) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
|
|
138
|
+
this.fixSelectionColors(term)
|
|
139
|
+
term.refresh(0, term.rows - 1)
|
|
140
|
+
if (deferred && this.props.config.rendererType === rendererTypes.webGL) {
|
|
141
|
+
window.cancelAnimationFrame(this.timers.themeRaf)
|
|
142
|
+
this.timers.themeRaf = window.requestAnimationFrame(() => {
|
|
143
|
+
if (!this.term || this.onClose) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
this.term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
|
|
147
|
+
this.fixSelectionColors(this.term)
|
|
148
|
+
this.term.refresh(0, this.term.rows - 1)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* xterm computes its DOM selection colour as blend(theme.background,
|
|
155
|
+
* selectionBackground). The terminal background is forced transparent
|
|
156
|
+
* (rgba(0,0,0,0)) so electerm's own CSS background / image shows through,
|
|
157
|
+
* which makes the blend run over transparent-black. Recompute the selection
|
|
158
|
+
* colours over the real visible background, from the *configured* theme
|
|
159
|
+
* colours — xterm's internal selectionBackgroundTransparent forces opaque
|
|
160
|
+
* colours down to 0.3 alpha (xterm#2737), which would make every selection
|
|
161
|
+
* translucent even when the user configured an opaque one.
|
|
162
|
+
*/
|
|
163
|
+
fixSelectionColors (term) {
|
|
164
|
+
const themeConfig = this.props.themeConfig || {}
|
|
165
|
+
const themeService = term?._core?._themeService
|
|
166
|
+
if (!themeService?.modifyColors) {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
const visibleBackground = this.getVisibleTerminalBackground()
|
|
170
|
+
// xterm's DEFAULT_SELECTION, used when the theme omits a selection color
|
|
171
|
+
const selectionFallback = 'rgba(255, 255, 255, 0.3)'
|
|
172
|
+
const inactiveFallback = themeConfig.selectionBackground || selectionFallback
|
|
173
|
+
themeService.modifyColors((c) => {
|
|
174
|
+
const active = blendSelectionOverBackground(
|
|
175
|
+
visibleBackground,
|
|
176
|
+
themeConfig.selectionBackground || selectionFallback
|
|
177
|
+
)
|
|
178
|
+
if (active) {
|
|
179
|
+
c.selectionBackgroundOpaque = active
|
|
180
|
+
}
|
|
181
|
+
const inactive = blendSelectionOverBackground(
|
|
182
|
+
visibleBackground,
|
|
183
|
+
themeConfig.selectionInactiveBackground || inactiveFallback
|
|
184
|
+
)
|
|
185
|
+
if (inactive) {
|
|
186
|
+
c.selectionInactiveBackgroundOpaque = inactive
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile touch support — active only while `store.isTouchDevice` is true.
|
|
3
|
+
*
|
|
4
|
+
* `store.isTouchDevice` is flipped by main.jsx from the real pointer input
|
|
5
|
+
* (mouse → false, touch/pen → true), and a pointerdown always precedes the
|
|
6
|
+
* touch event it stands for, so by the time a touchstart reaches us the flag
|
|
7
|
+
* already reflects the device the user is actually operating. A mouse-driven
|
|
8
|
+
* desktop therefore never enters any of this code, and the whole gesture
|
|
9
|
+
* layer is gated at `onTouchStart` alone: every other handler needs the
|
|
10
|
+
* gesture state that only a touch start can create, so a stray event cannot
|
|
11
|
+
* strand a half-finished gesture when the flag flips mid-gesture.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists at all: xterm.js has **no** touch text selection. Its touch
|
|
14
|
+
* layer maps gestures to scrolling only (`-xterm-gesturestart/-change`, see
|
|
15
|
+
* @xterm/xterm), and `-xterm-gesturetap` has no consumer in core. Every touch
|
|
16
|
+
* terminal therefore has to build selection itself; the previous version here
|
|
17
|
+
* could only ever select a single word, which is what made copying painful.
|
|
18
|
+
*
|
|
19
|
+
* Gesture map (touch mode only):
|
|
20
|
+
* tap → clear any selection, focus (opens the soft keyboard)
|
|
21
|
+
* double tap → select the word under the finger
|
|
22
|
+
* triple tap → select the whole line
|
|
23
|
+
* long press → select the word, then KEEP DRAGGING to extend it,
|
|
24
|
+
* auto-scrolling while the finger is past an edge
|
|
25
|
+
* release, dragged → copy (copyWhenSelect on) or open the menu
|
|
26
|
+
* release, no drag → open the menu, exactly as before
|
|
27
|
+
* two fingers → left alone: xterm's own pinch/scroll handling
|
|
28
|
+
*
|
|
29
|
+
* Dragging out a selection is only ever an approximation of what a phone user
|
|
30
|
+
* expects, so the menu also offers "select text" (see onSelectTextMode): it
|
|
31
|
+
* opens the whole buffer in a full-screen read-only textarea where the OS
|
|
32
|
+
* supplies real selection handles. That overlay is an ordinary DOM surface, so
|
|
33
|
+
* `onTouchStart` bails out of it — see isInsideSelectText().
|
|
34
|
+
*
|
|
35
|
+
* Two things that are easy to get wrong:
|
|
36
|
+
* - `term.select()` takes a BUFFER-ABSOLUTE row, not a viewport row. Passing
|
|
37
|
+
* the raw viewport row selects the wrong text as soon as the user has
|
|
38
|
+
* scrolled back — that was a real bug in the single-word version.
|
|
39
|
+
* - xterm registers its touch listeners on `document`, while React's root
|
|
40
|
+
* container sits below it, so our `onTouchMove` runs first and a plain
|
|
41
|
+
* `stopPropagation()` is enough to veto xterm's scroll for the duration of
|
|
42
|
+
* the drag. `preventDefault()` is not an option: React listens to
|
|
43
|
+
* `touchmove` passively, so it is ignored (with a console warning).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const DEFAULT_WORD_SEPARATOR = './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?'
|
|
47
|
+
|
|
48
|
+
// the select-text overlay; its own native selection must stay out of the
|
|
49
|
+
// terminal gesture layer
|
|
50
|
+
const SELECT_TEXT_CLASS = '.terminal-select-text'
|
|
51
|
+
|
|
52
|
+
// tap detection
|
|
53
|
+
const DOUBLE_TAP_MS = 350
|
|
54
|
+
const DOUBLE_TAP_PX = 24
|
|
55
|
+
|
|
56
|
+
// edge auto-scroll: a short delay before it kicks in, then one scroll step
|
|
57
|
+
// per interval
|
|
58
|
+
const AUTOSCROLL_DELAY = 260
|
|
59
|
+
const AUTOSCROLL_INTERVAL = 60
|
|
60
|
+
|
|
61
|
+
export const touchMixin = {
|
|
62
|
+
isTouchMode () {
|
|
63
|
+
const store = window.store
|
|
64
|
+
return !!(store && store.isTouchDevice)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// ---- gesture entry points, wired up in terminal.jsx ----
|
|
68
|
+
|
|
69
|
+
onTouchStart (e) {
|
|
70
|
+
// the one and only touch-mode gate — see the file header
|
|
71
|
+
if (!this.isTouchMode()) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
// the select-text overlay is a normal DOM surface: its native selection
|
|
75
|
+
// gestures must not be recycled into terminal gestures. Only a touchstart
|
|
76
|
+
// is checked, which is enough — the other handlers need gesture state that
|
|
77
|
+
// only a touchstart can create.
|
|
78
|
+
if (this.isInsideSelectText(e)) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
// two or more fingers mean zoom/scroll for xterm; abort whatever we were
|
|
82
|
+
// doing and stay out of the way
|
|
83
|
+
if (e.touches.length !== 1) {
|
|
84
|
+
this.cancelTouchGesture()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
this.cancelTouchGesture()
|
|
88
|
+
const touch = e.touches[0]
|
|
89
|
+
this.touchStartPos = {
|
|
90
|
+
clientX: touch.clientX,
|
|
91
|
+
clientY: touch.clientY,
|
|
92
|
+
target: e.currentTarget
|
|
93
|
+
}
|
|
94
|
+
this.longPressFired = false
|
|
95
|
+
clearTimeout(this.longPressTimer)
|
|
96
|
+
this.longPressTimer = setTimeout(() => {
|
|
97
|
+
this.handleLongPress()
|
|
98
|
+
}, this.longPressThreshold)
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
onTouchMove (e) {
|
|
102
|
+
// nothing in flight → not our gesture (this also covers touch mode being
|
|
103
|
+
// off, since only a gated onTouchStart can put state in flight)
|
|
104
|
+
if (!this.dragSelect && !this.touchStartPos) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const touch = e.touches[0]
|
|
108
|
+
if (!touch) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (this.dragSelect) {
|
|
112
|
+
// veto xterm's document-level gesture handler so the drag selects
|
|
113
|
+
// instead of scrolling
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
this.lastDragTouch = {
|
|
116
|
+
clientX: touch.clientX,
|
|
117
|
+
clientY: touch.clientY
|
|
118
|
+
}
|
|
119
|
+
this.dragSelect.moved = true
|
|
120
|
+
this.scheduleDragUpdate()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const dx = touch.clientX - this.touchStartPos.clientX
|
|
124
|
+
const dy = touch.clientY - this.touchStartPos.clientY
|
|
125
|
+
if (Math.sqrt(dx * dx + dy * dy) > this.longPressMoveTolerance) {
|
|
126
|
+
// it is a scroll/pan, not a long press — hand it back to xterm
|
|
127
|
+
this.cancelLongPress()
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
onTouchEnd (e) {
|
|
132
|
+
const gesture = this.dragSelect
|
|
133
|
+
const startPos = this.touchStartPos
|
|
134
|
+
const longPressed = this.longPressFired
|
|
135
|
+
|
|
136
|
+
clearTimeout(this.longPressTimer)
|
|
137
|
+
this.longPressTimer = null
|
|
138
|
+
|
|
139
|
+
// touchmove is coalesced to one update per frame, so flush the pending
|
|
140
|
+
// frame here to make the final selection match where the finger lifted
|
|
141
|
+
if (gesture && gesture.moved && this.lastDragTouch) {
|
|
142
|
+
const coords = this.toBufferCoords(
|
|
143
|
+
this.lastDragTouch.clientX,
|
|
144
|
+
this.lastDragTouch.clientY
|
|
145
|
+
)
|
|
146
|
+
if (coords) {
|
|
147
|
+
this.applySelection(gesture.anchor, coords)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
window.cancelAnimationFrame(this.dragFrame)
|
|
151
|
+
this.dragFrame = 0
|
|
152
|
+
this.stopAutoScroll()
|
|
153
|
+
this.dragSelect = null
|
|
154
|
+
this.lastDragTouch = null
|
|
155
|
+
this.touchStartPos = null
|
|
156
|
+
this.longPressFired = false
|
|
157
|
+
|
|
158
|
+
if (gesture) {
|
|
159
|
+
if (gesture.moved && this.props.config.copyWhenSelect) {
|
|
160
|
+
// mobile has no Ctrl+C — copy on release and skip the menu; the
|
|
161
|
+
// selection stays visible so the user can still see what was copied
|
|
162
|
+
this.copySelectionToClipboard()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// no drag, or auto-copy off: the menu is the way to act on the
|
|
166
|
+
// selection, and its Copy item now covers a real multi-line range
|
|
167
|
+
// instead of a single word. Anchor it under the finger that lifted,
|
|
168
|
+
// which for a long drag is where the user is actually looking.
|
|
169
|
+
const endTouch = gesture.moved && e.changedTouches && e.changedTouches[0]
|
|
170
|
+
this.openContextMenuAt(
|
|
171
|
+
endTouch ? endTouch.clientX : gesture.clientX,
|
|
172
|
+
endTouch ? endTouch.clientY : gesture.clientY,
|
|
173
|
+
gesture.target
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (longPressed) {
|
|
178
|
+
// long pressed on a separator/blank cell, so nothing got selected —
|
|
179
|
+
// still offer the menu, as before
|
|
180
|
+
if (startPos) {
|
|
181
|
+
this.openContextMenuAt(startPos.clientX, startPos.clientY, startPos.target)
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!startPos) {
|
|
186
|
+
// the gesture turned into a scroll, which is xterm's job
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
this.handleTap(e.changedTouches[0])
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A tap that never became a drag: single → focus + drop the selection,
|
|
194
|
+
* double → select the word, triple → select the line.
|
|
195
|
+
*/
|
|
196
|
+
handleTap (touch) {
|
|
197
|
+
if (!touch || !this.term) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const { clientX, clientY } = touch
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
const near = this.lastTapPos &&
|
|
203
|
+
Math.abs(clientX - this.lastTapPos.clientX) < DOUBLE_TAP_PX &&
|
|
204
|
+
Math.abs(clientY - this.lastTapPos.clientY) < DOUBLE_TAP_PX
|
|
205
|
+
this.tapCount = (now - this.lastTapTime < DOUBLE_TAP_MS && near)
|
|
206
|
+
? this.tapCount + 1
|
|
207
|
+
: 1
|
|
208
|
+
this.lastTapTime = now
|
|
209
|
+
this.lastTapPos = { clientX, clientY }
|
|
210
|
+
|
|
211
|
+
if (this.tapCount === 2 && this.selectWordAt(clientX, clientY)) {
|
|
212
|
+
this.term.focus()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (this.tapCount >= 3) {
|
|
216
|
+
this.tapCount = 0
|
|
217
|
+
const coords = this.toBufferCoords(clientX, clientY)
|
|
218
|
+
if (coords) {
|
|
219
|
+
// selectLines() takes buffer-absolute line indices, same as select()
|
|
220
|
+
this.term.selectLines(coords.absRow, coords.absRow)
|
|
221
|
+
this.term.focus()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (this.term.hasSelection()) {
|
|
226
|
+
this.term.clearSelection()
|
|
227
|
+
}
|
|
228
|
+
// xterm's own touch handler calls preventDefault()/stopPropagation() on
|
|
229
|
+
// touchstart/touchend, which suppresses the synthesised mousedown xterm
|
|
230
|
+
// relies on to focus its hidden helper textarea — so a tap never focuses
|
|
231
|
+
// the terminal by itself and the soft keyboard would never open. Focusing
|
|
232
|
+
// here runs synchronously inside the user gesture, so iOS/Android show it.
|
|
233
|
+
this.term.focus()
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
handleLongPress () {
|
|
237
|
+
if (!this.touchStartPos || this.state.loading) {
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
this.longPressFired = true
|
|
241
|
+
// a long press is not part of any tap sequence
|
|
242
|
+
this.tapCount = 0
|
|
243
|
+
this.lastTapPos = null
|
|
244
|
+
const { clientX, clientY, target } = this.touchStartPos
|
|
245
|
+
|
|
246
|
+
const anchor = this.selectWordAt(clientX, clientY)
|
|
247
|
+
|
|
248
|
+
// pasteWhenContextMenu means "long press pastes" — honour that contract
|
|
249
|
+
// and never take over the gesture with a drag
|
|
250
|
+
if (this.props.config.pasteWhenContextMenu) {
|
|
251
|
+
this.onPaste()
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if (!anchor) {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
this.dragSelect = {
|
|
258
|
+
anchor,
|
|
259
|
+
moved: false,
|
|
260
|
+
clientX,
|
|
261
|
+
clientY,
|
|
262
|
+
target
|
|
263
|
+
}
|
|
264
|
+
this.lastDragTouch = { clientX, clientY }
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
isInsideSelectText (e) {
|
|
268
|
+
const target = e.target
|
|
269
|
+
return !!(target && target.closest && target.closest(SELECT_TEXT_CLASS))
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// ---- select-text overlay ----
|
|
273
|
+
// The full-screen read-only textarea behind the menu's "select text" item.
|
|
274
|
+
// Dragging a selection out on the canvas is only ever an approximation of a
|
|
275
|
+
// native one, so this hands the whole buffer to the platform instead; the
|
|
276
|
+
// component itself is ./terminal-select-text.jsx.
|
|
277
|
+
|
|
278
|
+
onSelectTextMode () {
|
|
279
|
+
const text = this.getSelectableBufferText()
|
|
280
|
+
if (!text) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
this.setState({
|
|
284
|
+
selectTextVisible: true,
|
|
285
|
+
selectTextContent: text
|
|
286
|
+
})
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
handleSelectTextClose () {
|
|
290
|
+
this.setState({
|
|
291
|
+
selectTextVisible: false,
|
|
292
|
+
selectTextContent: ''
|
|
293
|
+
})
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
handleSelectTextAll () {
|
|
297
|
+
const el = this.selectTextRef.current
|
|
298
|
+
if (!el) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
// readOnly, so this does not raise the soft keyboard; the element still
|
|
302
|
+
// has to be focused or browsers will not paint the selection
|
|
303
|
+
el.focus({ preventScroll: true })
|
|
304
|
+
el.setSelectionRange(0, el.value.length)
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
handleSelectTextCopy () {
|
|
308
|
+
const el = this.selectTextRef.current
|
|
309
|
+
if (!el) {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
const { value, selectionStart, selectionEnd } = el
|
|
313
|
+
// nothing selected by hand → "copy" means the whole buffer, which is what
|
|
314
|
+
// a phone user means after opening this panel
|
|
315
|
+
this.copyToClipboard(
|
|
316
|
+
selectionEnd > selectionStart
|
|
317
|
+
? value.slice(selectionStart, selectionEnd)
|
|
318
|
+
: value
|
|
319
|
+
)
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
// ---- selection plumbing ----
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Selects the word under the point and returns the anchor a following drag
|
|
326
|
+
* extends from. The anchor is the word start, so a drag always keeps the
|
|
327
|
+
* whole word. Returns null when there is no usable coordinate mapping.
|
|
328
|
+
*/
|
|
329
|
+
selectWordAt (clientX, clientY) {
|
|
330
|
+
if (!this.term) {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
const coords = this.toBufferCoords(clientX, clientY)
|
|
334
|
+
if (!coords) {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
const { col, absRow } = coords
|
|
338
|
+
const anchor = { col, absRow }
|
|
339
|
+
const line = this.term.buffer.active.getLine(absRow)
|
|
340
|
+
if (!line) {
|
|
341
|
+
return anchor
|
|
342
|
+
}
|
|
343
|
+
const text = line.translateToString(true)
|
|
344
|
+
const wordSeparator = this.props.config.terminalWordSeparator ||
|
|
345
|
+
DEFAULT_WORD_SEPARATOR
|
|
346
|
+
|
|
347
|
+
// touched a blank/separator cell: nothing to expand, but it is still a
|
|
348
|
+
// valid place to start dragging from
|
|
349
|
+
const char = col < text.length ? text[col] : ''
|
|
350
|
+
if (!char || wordSeparator.includes(char)) {
|
|
351
|
+
return anchor
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let start = col
|
|
355
|
+
while (start > 0 && !wordSeparator.includes(text[start - 1])) {
|
|
356
|
+
start--
|
|
357
|
+
}
|
|
358
|
+
let end = col
|
|
359
|
+
while (end < text.length && !wordSeparator.includes(text[end])) {
|
|
360
|
+
end++
|
|
361
|
+
}
|
|
362
|
+
if (end > start) {
|
|
363
|
+
this.term.select(start, absRow, end - start)
|
|
364
|
+
}
|
|
365
|
+
return { col: start, absRow }
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Client point → buffer cell. `beyond` is -1/1 while the finger is above or
|
|
370
|
+
* below the screen area, which is what drives edge auto-scrolling.
|
|
371
|
+
*/
|
|
372
|
+
toBufferCoords (clientX, clientY) {
|
|
373
|
+
const term = this.term
|
|
374
|
+
if (!term || !term.element || !term.cols || !term.rows) {
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
// measure against the screen element (exactly cols × rows cells) rather
|
|
378
|
+
// than the whole `.xterm` box, whose padding and scrollbar would skew the
|
|
379
|
+
// mapping by a growing amount the further the finger moves
|
|
380
|
+
const screen = term.element.querySelector('.xterm-screen') || term.element
|
|
381
|
+
const rect = screen.getBoundingClientRect()
|
|
382
|
+
if (!rect.width || !rect.height) {
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
const cellWidth = rect.width / term.cols
|
|
386
|
+
const cellHeight = rect.height / term.rows
|
|
387
|
+
const col = Math.min(
|
|
388
|
+
term.cols - 1,
|
|
389
|
+
Math.max(0, Math.floor((clientX - rect.left) / cellWidth))
|
|
390
|
+
)
|
|
391
|
+
const viewportRow = Math.min(
|
|
392
|
+
term.rows - 1,
|
|
393
|
+
Math.max(0, Math.floor((clientY - rect.top) / cellHeight))
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
col,
|
|
397
|
+
beyond: clientY < rect.top ? -1 : (clientY > rect.bottom ? 1 : 0),
|
|
398
|
+
// the selection model stores buffer-absolute rows, not viewport rows
|
|
399
|
+
absRow: term.buffer.active.viewportY + viewportRow
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Selects from `a` to `b`, in either direction.
|
|
405
|
+
*
|
|
406
|
+
* `term.select()` only accepts "start + length" — it clears any end point,
|
|
407
|
+
* so a backwards drag has to be normalised to min/max. The length is a
|
|
408
|
+
* linear character count across buffer rows, matching xterm's own
|
|
409
|
+
* `toSelectionLength = cols * (endY - startY) + (endX - startX + 1)` and the
|
|
410
|
+
* inverse in `finalSelectionEnd`, which is why a multi-line drag needs no
|
|
411
|
+
* private API.
|
|
412
|
+
*/
|
|
413
|
+
applySelection (a, b) {
|
|
414
|
+
if (!this.term || !a || !b) {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const forward = a.absRow < b.absRow ||
|
|
418
|
+
(a.absRow === b.absRow && a.col <= b.col)
|
|
419
|
+
const start = forward ? a : b
|
|
420
|
+
const end = forward ? b : a
|
|
421
|
+
const length = this.term.cols * (end.absRow - start.absRow) +
|
|
422
|
+
(end.col - start.col + 1)
|
|
423
|
+
this.term.select(start.col, start.absRow, Math.max(1, length))
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Coalesces touchmove to one update per frame: every `term.select()` fires
|
|
428
|
+
* `onSelectionChange`, and the terminal re-renders on that, so a raw
|
|
429
|
+
* per-event update makes the drag stutter on slower phones.
|
|
430
|
+
*/
|
|
431
|
+
scheduleDragUpdate () {
|
|
432
|
+
if (this.dragFrame) {
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
this.dragFrame = window.requestAnimationFrame(() => {
|
|
436
|
+
this.dragFrame = 0
|
|
437
|
+
const gesture = this.dragSelect
|
|
438
|
+
const touch = this.lastDragTouch
|
|
439
|
+
if (!gesture || !touch) {
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const coords = this.toBufferCoords(touch.clientX, touch.clientY)
|
|
443
|
+
if (!coords) {
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
this.applySelection(gesture.anchor, coords)
|
|
447
|
+
this.autoScrollFrom(coords.beyond)
|
|
448
|
+
})
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Keeps scrolling while the finger is held past the top/bottom edge, and
|
|
453
|
+
* re-extends the selection as the buffer moves under it. The anchor is in
|
|
454
|
+
* absolute coordinates, so it stays put while the viewport scrolls.
|
|
455
|
+
*/
|
|
456
|
+
autoScrollFrom (beyond) {
|
|
457
|
+
if (!beyond) {
|
|
458
|
+
this.stopAutoScroll()
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (this.autoScrollDir === beyond) {
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
this.stopAutoScroll()
|
|
465
|
+
this.autoScrollDir = beyond
|
|
466
|
+
const tick = () => {
|
|
467
|
+
if (!this.autoScrollDir || !this.dragSelect) {
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
this.term.scrollLines(this.autoScrollDir)
|
|
471
|
+
const touch = this.lastDragTouch
|
|
472
|
+
if (touch) {
|
|
473
|
+
const coords = this.toBufferCoords(touch.clientX, touch.clientY)
|
|
474
|
+
if (coords) {
|
|
475
|
+
this.applySelection(this.dragSelect.anchor, coords)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
this.timers.touchAutoScroll = setTimeout(tick, AUTOSCROLL_INTERVAL)
|
|
479
|
+
}
|
|
480
|
+
this.timers.touchAutoScroll = setTimeout(tick, AUTOSCROLL_DELAY)
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
stopAutoScroll () {
|
|
484
|
+
this.autoScrollDir = 0
|
|
485
|
+
clearTimeout(this.timers.touchAutoScroll)
|
|
486
|
+
this.timers.touchAutoScroll = null
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Opens the antd context-menu dropdown at the finger position by
|
|
491
|
+
* dispatching a synthetic `contextmenu` event, so the `contextMenu` trigger
|
|
492
|
+
* (and its positioning) is handled by antd exactly like a desktop
|
|
493
|
+
* right-click.
|
|
494
|
+
*/
|
|
495
|
+
openContextMenuAt (clientX, clientY, target) {
|
|
496
|
+
if (!target || this.props.config.pasteWhenContextMenu) {
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const event = new MouseEvent('contextmenu', {
|
|
500
|
+
bubbles: true,
|
|
501
|
+
cancelable: true,
|
|
502
|
+
clientX,
|
|
503
|
+
clientY
|
|
504
|
+
})
|
|
505
|
+
target.dispatchEvent(event)
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
cancelLongPress () {
|
|
509
|
+
clearTimeout(this.longPressTimer)
|
|
510
|
+
this.longPressTimer = null
|
|
511
|
+
this.touchStartPos = null
|
|
512
|
+
this.longPressFired = false
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
cancelTouchGesture () {
|
|
516
|
+
this.cancelLongPress()
|
|
517
|
+
this.stopAutoScroll()
|
|
518
|
+
window.cancelAnimationFrame(this.dragFrame)
|
|
519
|
+
this.dragFrame = 0
|
|
520
|
+
this.dragSelect = null
|
|
521
|
+
this.lastDragTouch = null
|
|
522
|
+
}
|
|
523
|
+
}
|