@electerm/electerm-react 5.3.26 → 5.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +20 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +15 -0
  6. package/client/common/init-setting-item.js +5 -0
  7. package/client/common/platform.js +24 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +10 -2
  9. package/client/components/ai/agent.js +4 -1
  10. package/client/components/ai/ai-attachments.js +87 -0
  11. package/client/components/ai/ai-chat-empty.jsx +43 -0
  12. package/client/components/ai/ai-chat-entry.jsx +13 -4
  13. package/client/components/ai/ai-chat-history-item.jsx +28 -4
  14. package/client/components/ai/ai-chat-history.jsx +2 -1
  15. package/client/components/ai/ai-chat.jsx +104 -13
  16. package/client/components/ai/ai-config-props.js +4 -130
  17. package/client/components/ai/ai-config.jsx +135 -15
  18. package/client/components/ai/ai-presets.js +173 -0
  19. package/client/components/ai/ai.styl +83 -0
  20. package/client/components/bg/css-overwrite.jsx +11 -0
  21. package/client/components/bookmark-form/bookmark-schema.js +4 -0
  22. package/client/components/bookmark-form/common/fields.jsx +3 -0
  23. package/client/components/bookmark-form/config/common-fields.js +12 -0
  24. package/client/components/bookmark-form/config/local.js +3 -2
  25. package/client/components/bookmark-form/config/serial.js +3 -2
  26. package/client/components/bookmark-form/config/ssh.js +2 -1
  27. package/client/components/bookmark-form/config/telnet.js +3 -2
  28. package/client/components/common/item-filter.jsx +76 -0
  29. package/client/components/common/item-filter.styl +54 -0
  30. package/client/components/common/lazy-boundary.jsx +34 -0
  31. package/client/components/common/opacity.jsx +3 -1
  32. package/client/components/footer/cmd-history.jsx +132 -45
  33. package/client/components/footer/cmd-history.styl +42 -4
  34. package/client/components/footer/footer-entry.jsx +35 -5
  35. package/client/components/footer/footer.styl +8 -1
  36. package/client/components/footer/multi-tab-run-modal.jsx +83 -0
  37. package/client/components/footer/tab-select.jsx +34 -17
  38. package/client/components/layout/layout.jsx +23 -31
  39. package/client/components/layout/pixed.js +3 -0
  40. package/client/components/main/main.jsx +55 -28
  41. package/client/components/main/term-fullscreen.styl +9 -3
  42. package/client/components/quick-commands/qm.styl +3 -2
  43. package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
  44. package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
  45. package/client/components/rdp/file-transfer.js +4 -4
  46. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  47. package/client/components/remote-monitor/monitor-details.styl +87 -0
  48. package/client/components/remote-monitor/monitor-model.js +764 -0
  49. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  50. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  51. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  52. package/client/components/remote-monitor/session-monitor.js +361 -0
  53. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  54. package/client/components/remote-monitor/visibility.js +18 -0
  55. package/client/components/session/session-control.jsx +29 -2
  56. package/client/components/session/session.jsx +40 -18
  57. package/client/components/session/sessions.jsx +5 -2
  58. package/client/components/setting-panel/setting-common.jsx +25 -56
  59. package/client/components/setting-panel/setting-modal.jsx +57 -42
  60. package/client/components/setting-panel/setting-terminal.jsx +8 -2
  61. package/client/components/setting-panel/setting.styl +1 -1
  62. package/client/components/setting-panel/tab-triggers.jsx +71 -0
  63. package/client/components/sftp/file-compare-modal.jsx +6 -3
  64. package/client/components/sftp/file-item.jsx +5 -3
  65. package/client/components/sftp/owner-list.js +11 -2
  66. package/client/components/sftp/sftp-entry.jsx +4 -0
  67. package/client/components/side-panel-r/side-panel-r.jsx +2 -2
  68. package/client/components/sidebar/side-panel.jsx +1 -1
  69. package/client/components/sys-menu/menu-btn.jsx +30 -1
  70. package/client/components/tabs/index.jsx +2 -2
  71. package/client/components/tabs/tab-title.jsx +3 -2
  72. package/client/components/tabs/tab.jsx +5 -1
  73. package/client/components/terminal/attach-addon-custom.js +295 -49
  74. package/client/components/terminal/automation/index.js +64 -0
  75. package/client/components/terminal/automation/keys.js +49 -0
  76. package/client/components/terminal/automation/stream-matcher.js +82 -0
  77. package/client/components/terminal/automation/strip-ansi.js +19 -0
  78. package/client/components/terminal/automation/trigger-engine.js +233 -0
  79. package/client/components/terminal/automation/trigger-presets.js +110 -0
  80. package/client/components/terminal/command-tracker-addon.js +31 -1
  81. package/client/components/terminal/mixins/index.js +27 -0
  82. package/client/components/terminal/mixins/term-attach.js +123 -0
  83. package/client/components/terminal/mixins/term-config.jsx +84 -0
  84. package/client/components/terminal/mixins/term-content.js +126 -0
  85. package/client/components/terminal/mixins/term-context-menu.jsx +323 -0
  86. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  87. package/client/components/terminal/mixins/term-init.js +122 -0
  88. package/client/components/terminal/mixins/term-log.jsx +96 -0
  89. package/client/components/terminal/mixins/term-resize.js +87 -0
  90. package/client/components/terminal/mixins/term-socket.js +433 -0
  91. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  92. package/client/components/terminal/mixins/term-theme.js +190 -0
  93. package/client/components/terminal/mixins/term-touch.js +523 -0
  94. package/client/components/terminal/shell-detect.js +37 -0
  95. package/client/components/terminal/shell.js +0 -28
  96. package/client/components/terminal/shortcut-bar-defs.js +30 -4
  97. package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
  98. package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
  99. package/client/components/terminal/shortcut-bar.jsx +17 -10
  100. package/client/components/terminal/shortcut-bar.styl +4 -0
  101. package/client/components/terminal/ssh-reload-state.js +96 -0
  102. package/client/components/terminal/startup-queue.js +433 -0
  103. package/client/components/terminal/terminal-interactive.jsx +32 -0
  104. package/client/components/terminal/terminal-select-text.jsx +69 -0
  105. package/client/components/terminal/terminal.jsx +227 -2064
  106. package/client/components/terminal/terminal.styl +61 -0
  107. package/client/components/terminal/transfer-client-base.js +5 -29
  108. package/client/components/terminal/trzsz-client.js +1 -3
  109. package/client/components/terminal/xmodem-client.js +1 -3
  110. package/client/components/terminal/xterm-loader.js +7 -0
  111. package/client/components/terminal/zmodem-client.js +1 -3
  112. package/client/components/terminal-info/base.jsx +18 -70
  113. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  114. package/client/components/terminal-info/terminal-info.jsx +50 -59
  115. package/client/components/terminal-info/terminal-info.styl +6 -49
  116. package/client/components/text-editor/open-text-editor.js +30 -0
  117. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  118. package/client/components/text-editor/text-editor.jsx +14 -0
  119. package/client/components/triggers/trigger-editor.jsx +246 -0
  120. package/client/components/triggers/trigger-form.jsx +200 -0
  121. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  122. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  123. package/client/components/triggers/trigger-transport.jsx +60 -0
  124. package/client/components/triggers/triggers-field.jsx +19 -0
  125. package/client/components/widgets/widgets-list.jsx +11 -1
  126. package/client/css/mobile.styl +29 -8
  127. package/client/store/common.js +12 -1
  128. package/client/store/init-state.js +7 -0
  129. package/client/store/mcp-handler.js +158 -30
  130. package/client/store/setting.js +9 -0
  131. package/client/store/store.js +2 -0
  132. package/client/store/sync.js +2 -0
  133. package/client/store/tab.js +42 -3
  134. package/client/store/trigger.js +118 -0
  135. package/client/store/watch.js +13 -5
  136. package/client/views/index.pug +3 -0
  137. package/package.json +1 -1
  138. package/client/components/terminal-info/activity.jsx +0 -57
  139. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  140. package/client/components/terminal-info/disk.jsx +0 -45
  141. package/client/components/terminal-info/format-disks.js +0 -45
  142. package/client/components/terminal-info/network.jsx +0 -121
  143. package/client/components/terminal-info/resource.jsx +0 -99
  144. package/client/components/terminal-info/run-cmd.jsx +0 -263
  145. package/client/components/terminal-info/sys-info.jsx +0 -56
  146. package/client/components/terminal-info/up.jsx +0 -17
@@ -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
+ }