@electerm/electerm-react 5.5.35 → 5.5.66
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 +1 -0
- package/client/common/constants.js +1 -0
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/parse-quick-connect.js +47 -6
- package/client/common/parse-widget-log.js +57 -0
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -0
- package/client/components/ai/agent-tool-call-card.jsx +7 -3
- package/client/components/ai/agent.js +176 -33
- package/client/components/ai/ai-auto-compress.js +119 -0
- package/client/components/ai/ai-chat-history-item.jsx +125 -40
- package/client/components/ai/ai-chat.jsx +67 -19
- package/client/components/ai/ai-compress.js +95 -0
- package/client/components/ai/ai-config.jsx +29 -16
- package/client/components/ai/ai-config.styl +20 -0
- package/client/components/ai/ai-context.js +1 -1
- package/client/components/ai/ai-output.jsx +1 -6
- package/client/components/ai/ai-presets.js +0 -15
- package/client/components/batch-op/batch-op-runner.jsx +1 -0
- package/client/components/bookmark-form/bookmark-schema.js +1 -0
- package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
- package/client/components/bookmark-form/config/ssh.js +1 -0
- package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
- package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
- package/client/components/file-transfer/transfer.jsx +3 -119
- package/client/components/main/main.jsx +1 -0
- package/client/components/session/session-control.jsx +35 -2
- package/client/components/session/session-control.styl +5 -0
- package/client/components/session/session.jsx +13 -0
- package/client/components/setting-panel/setting-terminal.jsx +1 -0
- package/client/components/setting-panel/tab-widgets.jsx +27 -7
- package/client/components/sftp/file-item.jsx +0 -13
- package/client/components/terminal/mixins/term-init.js +1 -0
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/terminal/shell.js +11 -3
- package/client/components/terminal/shortcut-bar-press.js +192 -0
- package/client/components/terminal/shortcut-bar.jsx +199 -4
- package/client/components/terminal/shortcut-bar.styl +40 -21
- package/client/components/terminal/terminal.jsx +4 -0
- package/client/components/widgets/widget-control.jsx +4 -2
- package/client/components/widgets/widget-instance-detail.jsx +370 -0
- package/client/components/widgets/widget-instance.jsx +30 -13
- package/client/components/widgets/widget-instances.jsx +3 -1
- package/client/components/widgets/widgets-list.jsx +8 -0
- package/client/components/widgets/widgets.styl +100 -0
- package/client/store/common.js +81 -23
- package/client/store/init-state.js +7 -0
- package/client/store/load-data.js +1 -0
- package/client/store/mcp-handler.js +8 -15
- package/client/store/sync.js +1 -0
- package/client/store/tab.js +2 -15
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- package/client/components/file-transfer/zip.js +0 -42
|
@@ -27,6 +27,7 @@ export const initMixin = {
|
|
|
27
27
|
allowProposedApi: true,
|
|
28
28
|
scrollback: config.scrollback,
|
|
29
29
|
rightClickSelectsWord: config.rightClickSelectsWord || false,
|
|
30
|
+
mouseEventsRequireAlt: config.mouseEventsRequireAlt || false,
|
|
30
31
|
fontFamily: tab.fontFamily || config.fontFamily,
|
|
31
32
|
theme: tc,
|
|
32
33
|
allowTransparency: true,
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
* `stopPropagation()` is enough to veto xterm's scroll for the duration of
|
|
42
42
|
* the drag. `preventDefault()` is not an option: React listens to
|
|
43
43
|
* `touchmove` passively, so it is ignored (with a console warning).
|
|
44
|
+
*
|
|
45
|
+
* A long press is not the only way in: the toolbar shows a touch-only menu
|
|
46
|
+
* icon next to the three-dot toggle (see session-control.jsx), which opens the
|
|
47
|
+
* same menu through openContextMenuAtPoint() below.
|
|
44
48
|
*/
|
|
45
49
|
|
|
46
50
|
const DEFAULT_WORD_SEPARATOR = './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?'
|
|
@@ -505,6 +509,20 @@ export const touchMixin = {
|
|
|
505
509
|
target.dispatchEvent(event)
|
|
506
510
|
},
|
|
507
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Opens the menu from the toolbar icon, which lives outside the antd
|
|
514
|
+
* dropdown's trigger element — so the event is dispatched on the terminal's
|
|
515
|
+
* own node and bubbles up to it. The icon's corner is passed in as the point
|
|
516
|
+
* to drop the menu from, rather than the finger that tapped it.
|
|
517
|
+
*/
|
|
518
|
+
openContextMenuAtPoint (clientX, clientY) {
|
|
519
|
+
this.openContextMenuAt(
|
|
520
|
+
clientX,
|
|
521
|
+
clientY,
|
|
522
|
+
this.domRef?.current || this.term?.element
|
|
523
|
+
)
|
|
524
|
+
},
|
|
525
|
+
|
|
508
526
|
cancelLongPress () {
|
|
509
527
|
clearTimeout(this.longPressTimer)
|
|
510
528
|
this.longPressTimer = null
|
|
@@ -79,13 +79,21 @@ function getShInlineIntegration () {
|
|
|
79
79
|
return [
|
|
80
80
|
'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
|
|
81
81
|
'then export ELECTERM_SHELL_INTEGRATION=1',
|
|
82
|
-
'__e_esc() { printf \'%s\' "$1" | sed \'s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g\'; }',
|
|
83
82
|
// sh/dash/ash re-evaluate $(...) inside PS1 every time the prompt is
|
|
84
83
|
// shown, but only if the $() stays literal — so PS1 must be assigned
|
|
85
84
|
// single-quoted. printf interprets \033/\a, unlike PS1 backslash
|
|
86
85
|
// escapes which dash does not expand.
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
//
|
|
87
|
+
// The prompt is deliberately self-contained: it must never call a shell
|
|
88
|
+
// function. On OpenWrt /etc/profile does `export PS1=...`, so PS1 is an
|
|
89
|
+
// environment variable and every child shell inherits it — while shell
|
|
90
|
+
// functions are not inherited. A `PS1='$(__e_ps1)'...` prompt therefore
|
|
91
|
+
// dies with "ash: __e_ps1: not found" on every prompt of any nested shell
|
|
92
|
+
// (ash, su, login, ...), and the exported ELECTERM_SHELL_INTEGRATION
|
|
93
|
+
// guard stops the child from ever defining the missing function.
|
|
94
|
+
// Inlining keeps it working there too, and printf is an ash builtin so
|
|
95
|
+
// this is cheaper than the old function + sed pair.
|
|
96
|
+
'PS1=\'$(printf "\\033]633;P;Cwd=%s\\a\\033]633;A\\a" "$PWD")\'"${PS1:-# }"',
|
|
89
97
|
'fi'
|
|
90
98
|
].join('; ')
|
|
91
99
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* press-and-hold auto-repeat for the touch shortcut bar.
|
|
3
|
+
*
|
|
4
|
+
* Why: the bar's buttons sent their key from `click` alone, so a touch user
|
|
5
|
+
* had to tap over and over for anything that repeats — holding Bksp to erase
|
|
6
|
+
* a word, holding ↓ to walk back through the scrollback. A physical key
|
|
7
|
+
* repeats while it is held, so the bar does the same: press, wait out the
|
|
8
|
+
* initial delay, then send the key at a steady interval until the finger (or
|
|
9
|
+
* mouse button) lifts.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately free of React and of the DOM — shortcut-bar.jsx owns the
|
|
12
|
+
* wiring, this owns the timing, so the timing can be unit-tested on its own
|
|
13
|
+
* (src/test/unit-ci/shortcut-bar-press.spec.js).
|
|
14
|
+
*
|
|
15
|
+
* Two movement tolerances, because the two phases want opposite things:
|
|
16
|
+
* - before the repeat begins, ANY movement means the finger is scrolling
|
|
17
|
+
* the bar rather than pressing a key, so a tight tolerance cancels —
|
|
18
|
+
* a press that slides off must never send a repeat;
|
|
19
|
+
* - once the key is repeating, a held finger always drifts a little, so it
|
|
20
|
+
* has to travel much further before it counts as a scroll. With one
|
|
21
|
+
* tolerance, holding Bksp for two seconds would either stop halfway (too
|
|
22
|
+
* tight) or keep erasing while the bar slides sideways (too loose).
|
|
23
|
+
*
|
|
24
|
+
* Only one press is ever in flight. A second pointerdown takes over (a second
|
|
25
|
+
* finger on another key is a deliberate change of target, and two repeating
|
|
26
|
+
* keys at once helps nobody), and any event from a different pointerId is
|
|
27
|
+
* ignored, so a stray move from a finger already lifted cannot stop or
|
|
28
|
+
* corrupt the live repeat.
|
|
29
|
+
*
|
|
30
|
+
* `up()` / `cancel()` report whether the gesture had become a long press, and
|
|
31
|
+
* both report the same value — a release can be caught by the component's own
|
|
32
|
+
* handler or by the window-level safety net (the pointer may be released
|
|
33
|
+
* anywhere, even outside the window), so the answer must not depend on which
|
|
34
|
+
* one runs first. The caller uses it to swallow the `click` the browser
|
|
35
|
+
* synthesizes after a long press, which would otherwise send one extra key.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const longPressDelay = 400
|
|
39
|
+
|
|
40
|
+
// ~16 sends/sec once repeating: fast enough to clear a long line or scroll
|
|
41
|
+
// back through history, slow enough that the remote end can keep up.
|
|
42
|
+
export const repeatInterval = 60
|
|
43
|
+
|
|
44
|
+
// pre-repeat: a press that moves this much is a scroll, not a tap
|
|
45
|
+
export const moveTolerance = 10
|
|
46
|
+
|
|
47
|
+
// while repeating: how far a held finger may drift before it is a scroll
|
|
48
|
+
export const repeatMoveTolerance = 32
|
|
49
|
+
|
|
50
|
+
export function createKeyRepeat (opts = {}) {
|
|
51
|
+
const {
|
|
52
|
+
send = () => {},
|
|
53
|
+
onRepeatChange = () => {},
|
|
54
|
+
delay = longPressDelay,
|
|
55
|
+
interval = repeatInterval,
|
|
56
|
+
tolerance = moveTolerance,
|
|
57
|
+
repeatTolerance = repeatMoveTolerance
|
|
58
|
+
} = opts
|
|
59
|
+
|
|
60
|
+
let holdTimer = null
|
|
61
|
+
let tickTimer = null
|
|
62
|
+
let payload = null
|
|
63
|
+
let startX = 0
|
|
64
|
+
let startY = 0
|
|
65
|
+
let pointerId = null
|
|
66
|
+
let inFlight = false
|
|
67
|
+
let repeating = false
|
|
68
|
+
// sticky for the whole gesture: set when the long press begins, cleared only
|
|
69
|
+
// by the next press, so it survives any release path
|
|
70
|
+
let longFired = false
|
|
71
|
+
|
|
72
|
+
function stopTimers () {
|
|
73
|
+
if (holdTimer) {
|
|
74
|
+
clearTimeout(holdTimer)
|
|
75
|
+
holdTimer = null
|
|
76
|
+
}
|
|
77
|
+
if (tickTimer) {
|
|
78
|
+
clearInterval(tickTimer)
|
|
79
|
+
tickTimer = null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setRepeating (on) {
|
|
84
|
+
if (repeating === on) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
repeating = on
|
|
88
|
+
onRepeatChange(on ? payload : null)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// leave the gesture, keeping `longFired` for the caller's click decision
|
|
92
|
+
function release () {
|
|
93
|
+
stopTimers()
|
|
94
|
+
if (inFlight) {
|
|
95
|
+
setRepeating(false)
|
|
96
|
+
}
|
|
97
|
+
inFlight = false
|
|
98
|
+
payload = null
|
|
99
|
+
pointerId = null
|
|
100
|
+
return longFired
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function beginRepeat () {
|
|
104
|
+
holdTimer = null
|
|
105
|
+
longFired = true
|
|
106
|
+
setRepeating(true)
|
|
107
|
+
// send the first repeat immediately: holding a key should not wait out
|
|
108
|
+
// the delay twice
|
|
109
|
+
send(payload)
|
|
110
|
+
tickTimer = setInterval(() => send(payload), interval)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// an event only counts while it belongs to the press in flight. Events
|
|
114
|
+
// synthesized in tests (or by a browser that omits pointerId) always match.
|
|
115
|
+
function owns (ev) {
|
|
116
|
+
return inFlight &&
|
|
117
|
+
(pointerId === null || !ev || ev.pointerId == null || ev.pointerId === pointerId)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
get repeating () {
|
|
122
|
+
return repeating
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
get longFired () {
|
|
126
|
+
return longFired
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A press starts. `p` is whatever should be handed back to `send` — the
|
|
131
|
+
* bar passes the whole button so it can also read the id for its visual
|
|
132
|
+
* state. A second press cancels the first.
|
|
133
|
+
*/
|
|
134
|
+
down (p, ev) {
|
|
135
|
+
release()
|
|
136
|
+
payload = p
|
|
137
|
+
pointerId = ev && ev.pointerId != null ? ev.pointerId : null
|
|
138
|
+
startX = ev ? ev.clientX || 0 : 0
|
|
139
|
+
startY = ev ? ev.clientY || 0 : 0
|
|
140
|
+
longFired = false
|
|
141
|
+
inFlight = true
|
|
142
|
+
holdTimer = setTimeout(beginRepeat, delay)
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
move (ev) {
|
|
146
|
+
if (!owns(ev)) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const x = ev ? ev.clientX || 0 : 0
|
|
150
|
+
const y = ev ? ev.clientY || 0 : 0
|
|
151
|
+
const dx = x - startX
|
|
152
|
+
const dy = y - startY
|
|
153
|
+
const moved = Math.sqrt(dx * dx + dy * dy)
|
|
154
|
+
if (repeating) {
|
|
155
|
+
if (moved > repeatTolerance) {
|
|
156
|
+
release()
|
|
157
|
+
}
|
|
158
|
+
} else if (moved > tolerance) {
|
|
159
|
+
// the press turned into a scroll of the bar: drop it before it can
|
|
160
|
+
// fire, and let the scroll happen
|
|
161
|
+
release()
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/** the pointer lifted. Returns true if this gesture was a long press. */
|
|
166
|
+
up (ev) {
|
|
167
|
+
if (!owns(ev)) {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
return release()
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The gesture was aborted (pointercancel, a second finger, the bar being
|
|
175
|
+
* hidden, unmount). Never sends anything, so a scroll that turns into a
|
|
176
|
+
* cancel cannot leak a key.
|
|
177
|
+
*/
|
|
178
|
+
cancel (ev) {
|
|
179
|
+
if (ev && !owns(ev)) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
const wasLong = inFlight ? longFired : false
|
|
183
|
+
release()
|
|
184
|
+
return wasLong
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
dispose () {
|
|
188
|
+
release()
|
|
189
|
+
longFired = false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* - first (fixed) button collapses the bar
|
|
11
11
|
* - second (fixed) button opens the edit modal (shortcut-bar-edit.jsx)
|
|
12
12
|
* - tapping a shortcut sends it to the active terminal via runQuickCommand
|
|
13
|
+
* - pressing AND holding one repeats it, like a physical key: a tap has to
|
|
14
|
+
* send exactly one key, but holding Bksp to erase a word or ↓ to walk
|
|
15
|
+
* back through the scrollback is impossible without auto-repeat. The
|
|
16
|
+
* timing lives in shortcut-bar-press.js
|
|
13
17
|
* - while the system input panel overlays the page without resizing the
|
|
14
18
|
* layout viewport (iOS / HarmonyOS do this, Android resizes instead), the
|
|
15
19
|
* bar lifts itself above the panel — see the visualViewport effect below
|
|
@@ -27,6 +31,7 @@ import { shortcutBarHeight } from '../../common/constants'
|
|
|
27
31
|
import ShortcutBarEdit from './shortcut-bar-edit'
|
|
28
32
|
import { KEYBOARD_MIN } from './shortcut-bar-entry'
|
|
29
33
|
import { candidates, loadActive, saveActive, ESC } from './shortcut-bar-defs'
|
|
34
|
+
import { createKeyRepeat } from './shortcut-bar-press'
|
|
30
35
|
import './shortcut-bar.styl'
|
|
31
36
|
|
|
32
37
|
const e = window.translate
|
|
@@ -47,15 +52,57 @@ const appCursorMap = {
|
|
|
47
52
|
[ESC + '[F']: ESC + 'OF' // End
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
function
|
|
55
|
+
function activeTerm () {
|
|
51
56
|
const { store } = window
|
|
52
|
-
|
|
57
|
+
return refs.get('term-' + store.activeTabId)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sendToTerminal (data) {
|
|
61
|
+
const term = activeTerm()
|
|
53
62
|
if (term && typeof term.runQuickCommand === 'function') {
|
|
54
63
|
const resolved = resolveCursorMode(data, term)
|
|
55
64
|
term.runQuickCommand(resolved, true)
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
// Put focus back on the terminal's helper textarea, which is the element that
|
|
69
|
+
// owns the soft keyboard.
|
|
70
|
+
//
|
|
71
|
+
// A press on a bar button focuses that button instead, and the OS then
|
|
72
|
+
// dismisses the input panel — which also drops the bar, since it rides above
|
|
73
|
+
// the panel, so the hold visibly breaks.
|
|
74
|
+
//
|
|
75
|
+
// Preventing the default on mousedown covers the mouse path, but Chromium only
|
|
76
|
+
// synthesizes those compatibility mouse events after touchend, so on touch the
|
|
77
|
+
// theft happens after any reclaim made during pointerdown. Hence this is also
|
|
78
|
+
// driven from the focusin listener in the effect below, which is timing
|
|
79
|
+
// independent. Called from pointerdown it still helps: it puts focus back
|
|
80
|
+
// before the platform has any chance to act on the button.
|
|
81
|
+
//
|
|
82
|
+
// The call is synchronous inside the user gesture, which is what a
|
|
83
|
+
// keyboard-showing focus() requires, and it is idempotent.
|
|
84
|
+
let reclaiming = false
|
|
85
|
+
|
|
86
|
+
function reclaimTerminalFocus () {
|
|
87
|
+
// focus() re-raises focusin, so guard against recursing: a press that
|
|
88
|
+
// repeatedly reclaims would otherwise spin.
|
|
89
|
+
if (reclaiming) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const term = activeTerm()
|
|
93
|
+
if (!term || !term.term || typeof term.term.focus !== 'function') {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
reclaiming = true
|
|
97
|
+
try {
|
|
98
|
+
term.term.focus()
|
|
99
|
+
} finally {
|
|
100
|
+
// released synchronously, once the focus() call has returned — the
|
|
101
|
+
// focusin it triggers has already been dispatched by then
|
|
102
|
+
reclaiming = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
// honor the active terminal's DECCKM state for the cursor / Home / End keys.
|
|
60
107
|
// custom combos can be multi-key sequences (Ctrl+A then ↑), so match the
|
|
61
108
|
// trailing key too — not only the whole payload.
|
|
@@ -177,13 +224,142 @@ function ShortcutBar (props) {
|
|
|
177
224
|
saveActive(next)
|
|
178
225
|
}
|
|
179
226
|
|
|
227
|
+
// ---- press-and-hold auto-repeat ----
|
|
228
|
+
// A tap is a plain click, so it stays one key. On top of that, a press that
|
|
229
|
+
// is held repeats its key (see shortcut-bar-press.js) — the only way to hold
|
|
230
|
+
// Bksp or an arrow key without a physical keyboard.
|
|
231
|
+
const repeatRef = useRef(null)
|
|
232
|
+
// id of the button currently repeating, so only it shows the pressed state
|
|
233
|
+
const [repeatingId, setRepeatingId] = useState(null)
|
|
234
|
+
// a long press has already sent its key(s); the click the browser fires on
|
|
235
|
+
// release must not add one more. Set by the repeat, cleared once consumed.
|
|
236
|
+
const swallowClickRef = useRef(false)
|
|
237
|
+
|
|
238
|
+
if (!repeatRef.current) {
|
|
239
|
+
repeatRef.current = createKeyRepeat({
|
|
240
|
+
send: b => {
|
|
241
|
+
if (b) {
|
|
242
|
+
sendToTerminal(b.data)
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
onRepeatChange: b => setRepeatingId(b ? b.id : null)
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The gesture can end anywhere — the finger can slide off the bar, lift
|
|
250
|
+
// outside the window, or the system can steal it (a callout, an incoming
|
|
251
|
+
// call). A window-level release is the only net that catches all of those;
|
|
252
|
+
// the per-button handler below is what keeps the common case responsive.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
function onGlobalRelease (ev) {
|
|
255
|
+
if (repeatRef.current.up(ev)) {
|
|
256
|
+
swallowClickRef.current = true
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function onGlobalCancel (ev) {
|
|
260
|
+
// a cancelled gesture is not a long press: nothing was sent that the
|
|
261
|
+
// user needs to "not double", so no click has to be swallowed
|
|
262
|
+
repeatRef.current.cancel(ev)
|
|
263
|
+
}
|
|
264
|
+
// Catch-all for focus theft, and the reliable half of the fix.
|
|
265
|
+
//
|
|
266
|
+
// Reclaiming focus at pointerdown is too early to be trustworthy: Chromium
|
|
267
|
+
// only synthesizes the compatibility mouse events (mousedown/mouseup/click)
|
|
268
|
+
// AFTER touchend, so on the touch path the button takes focus *after* the
|
|
269
|
+
// press has already reclaimed it. Timing the reclaim per-event cannot cover
|
|
270
|
+
// that; reacting to focus itself can, and it covers every path — mouse,
|
|
271
|
+
// touch, the compatibility mousedown, and the context-menu focus grab.
|
|
272
|
+
//
|
|
273
|
+
// focusin rather than focus: it bubbles, so one listener on the window sees
|
|
274
|
+
// the bar's buttons from outside the portal.
|
|
275
|
+
//
|
|
276
|
+
// The guard is what keeps this from looping: only focus landing inside this
|
|
277
|
+
// bar is reclaimed, and not when it is the reclaim's own doing.
|
|
278
|
+
function onFocusIn (ev) {
|
|
279
|
+
const el = ev.target
|
|
280
|
+
if (!el || !el.closest || !el.closest('.shortcut-bar')) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (reclaiming) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
reclaimTerminalFocus()
|
|
287
|
+
}
|
|
288
|
+
window.addEventListener('pointerup', onGlobalRelease)
|
|
289
|
+
window.addEventListener('pointercancel', onGlobalCancel)
|
|
290
|
+
window.addEventListener('focusin', onFocusIn)
|
|
291
|
+
return () => {
|
|
292
|
+
window.removeEventListener('pointerup', onGlobalRelease)
|
|
293
|
+
window.removeEventListener('pointercancel', onGlobalCancel)
|
|
294
|
+
window.removeEventListener('focusin', onFocusIn)
|
|
295
|
+
// unmount (or the bar going away) must not leave a timer running
|
|
296
|
+
repeatRef.current.dispose()
|
|
297
|
+
}
|
|
298
|
+
}, [])
|
|
299
|
+
|
|
300
|
+
// the bar hiding mid-hold is a cancel, not a release: the keys already sent
|
|
301
|
+
// stand, and no stale click may be swallowed by the next one
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (!store.shortcutBarVisible) {
|
|
304
|
+
repeatRef.current.cancel()
|
|
305
|
+
swallowClickRef.current = false
|
|
306
|
+
}
|
|
307
|
+
}, [store.shortcutBarVisible])
|
|
308
|
+
|
|
309
|
+
function handleClick (b) {
|
|
310
|
+
if (swallowClickRef.current) {
|
|
311
|
+
swallowClickRef.current = false
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
sendToTerminal(b.data)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function handlePointerDown (ev, b) {
|
|
318
|
+
// a right / middle click is not a key press
|
|
319
|
+
if (ev.button != null && ev.button > 0) {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
// The flag only has to survive from the release to the click the browser
|
|
323
|
+
// synthesizes right after it. A release that lands OFF the button
|
|
324
|
+
// synthesizes no click at all, so without this the flag would still be
|
|
325
|
+
// armed on the next press and eat a legitimate key. Any new press starts
|
|
326
|
+
// strictly after the previous click, so clearing here is safe.
|
|
327
|
+
swallowClickRef.current = false
|
|
328
|
+
// Only a mouse: capturing a touch pointer makes the browser scroll the
|
|
329
|
+
// button row no more, and dragging the row by a button is exactly how the
|
|
330
|
+
// bar is scrolled. Touch instead relies on pointercancel (fired when the
|
|
331
|
+
// browser takes over for a scroll) plus the move tolerance below.
|
|
332
|
+
if (ev.pointerType === 'mouse') {
|
|
333
|
+
try {
|
|
334
|
+
ev.currentTarget.setPointerCapture(ev.pointerId)
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// capture is an optimisation for cancel-on-slide-off; without it the
|
|
337
|
+
// hold still stops on pointerup, just via the window-level net
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// before anything can steal it — see reclaimTerminalFocus()
|
|
341
|
+
reclaimTerminalFocus()
|
|
342
|
+
repeatRef.current.down(b, ev)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function handlePointerUp (ev) {
|
|
346
|
+
if (repeatRef.current.up(ev)) {
|
|
347
|
+
swallowClickRef.current = true
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
180
351
|
function renderButton (b, i) {
|
|
181
352
|
return (
|
|
182
353
|
<button
|
|
183
354
|
type='button'
|
|
184
355
|
key={b.id + '-' + i}
|
|
185
|
-
className=
|
|
186
|
-
|
|
356
|
+
className={
|
|
357
|
+
'shortcut-bar-btn' + (repeatingId === b.id ? ' repeating' : '')
|
|
358
|
+
}
|
|
359
|
+
onPointerDown={ev => handlePointerDown(ev, b)}
|
|
360
|
+
onPointerMove={ev => repeatRef.current.move(ev)}
|
|
361
|
+
onPointerUp={handlePointerUp}
|
|
362
|
+
onClick={() => handleClick(b)}
|
|
187
363
|
>
|
|
188
364
|
{b.label}
|
|
189
365
|
</button>
|
|
@@ -199,6 +375,25 @@ function ShortcutBar (props) {
|
|
|
199
375
|
className='shortcut-bar'
|
|
200
376
|
role='toolbar'
|
|
201
377
|
style={kbOffset ? { bottom: kbOffset + 'px' } : undefined}
|
|
378
|
+
// Keep the press from moving DOM focus in the first place.
|
|
379
|
+
//
|
|
380
|
+
// A button is focusable, and on a real press the browser focuses it
|
|
381
|
+
// (measured: focus lands on .shortcut-bar-btn, not on the terminal's
|
|
382
|
+
// helper textarea). That helper textarea is what owns the soft keyboard,
|
|
383
|
+
// so on Android Chrome focus moving to a non-editable element makes the
|
|
384
|
+
// OS dismiss the input panel — and the bar, which rides above the
|
|
385
|
+
// panel, drops with it, so the hold visibly breaks.
|
|
386
|
+
//
|
|
387
|
+
// This is the preventive half (it still delivers the click, also
|
|
388
|
+
// measured) and it covers the mouse path plus the compatibility mousedown
|
|
389
|
+
// on the container, so the two icon buttons get it too. It cannot cover
|
|
390
|
+
// the touch path on its own — those compatibility events are synthesized
|
|
391
|
+
// after touchend — which is why the focusin listener above exists as the
|
|
392
|
+
// reliable half.
|
|
393
|
+
//
|
|
394
|
+
// pointerdown is deliberately NOT prevented: that would stop the browser
|
|
395
|
+
// scrolling the button row.
|
|
396
|
+
onMouseDown={e => e.preventDefault()}
|
|
202
397
|
>
|
|
203
398
|
<div className='shortcut-bar-fixed'>
|
|
204
399
|
<button
|
|
@@ -17,6 +17,41 @@
|
|
|
17
17
|
user-select none
|
|
18
18
|
-webkit-user-select none
|
|
19
19
|
|
|
20
|
+
// A held button now means "keep sending this key", so the platform must not
|
|
21
|
+
// treat the hold as its own gesture:
|
|
22
|
+
// - the iOS callout (Copy/Look Up) and Android's text-selection handles
|
|
23
|
+
// would swallow it — same reason .term-wrap suppresses them;
|
|
24
|
+
// - touch-action keeps the hold from turning into a scroll of the page; the
|
|
25
|
+
// bar's own horizontal scroll lives on .shortcut-bar-scroll, which is a
|
|
26
|
+
// separate element, so this only affects the button itself.
|
|
27
|
+
.shortcut-bar-btn
|
|
28
|
+
-webkit-touch-callout none
|
|
29
|
+
touch-action manipulation
|
|
30
|
+
flex-shrink 0
|
|
31
|
+
height 32px
|
|
32
|
+
min-width 32px
|
|
33
|
+
padding 0 10px
|
|
34
|
+
display inline-flex
|
|
35
|
+
align-items center
|
|
36
|
+
justify-content center
|
|
37
|
+
border-radius 6px
|
|
38
|
+
border 1px solid var(--main-lighter)
|
|
39
|
+
background var(--main-light)
|
|
40
|
+
color var(--text)
|
|
41
|
+
font-size 13px
|
|
42
|
+
cursor pointer
|
|
43
|
+
white-space nowrap
|
|
44
|
+
&:active
|
|
45
|
+
background var(--primary)
|
|
46
|
+
color var(--primary-contrast)
|
|
47
|
+
&:hover
|
|
48
|
+
color var(--text-light)
|
|
49
|
+
// held down and repeating: keep the :active look for as long as the key is
|
|
50
|
+
// being re-sent, so the user can see the auto-repeat is running
|
|
51
|
+
&.repeating
|
|
52
|
+
background var(--primary)
|
|
53
|
+
color var(--primary-contrast)
|
|
54
|
+
|
|
20
55
|
.shortcut-bar-fixed
|
|
21
56
|
display flex
|
|
22
57
|
align-items stretch
|
|
@@ -33,6 +68,8 @@
|
|
|
33
68
|
color var(--text)
|
|
34
69
|
cursor pointer
|
|
35
70
|
font-size 16px
|
|
71
|
+
-webkit-touch-callout none
|
|
72
|
+
touch-action manipulation
|
|
36
73
|
&:active
|
|
37
74
|
background var(--main-lighter)
|
|
38
75
|
&:hover
|
|
@@ -48,30 +85,12 @@
|
|
|
48
85
|
overflow-y hidden
|
|
49
86
|
scrollbar-width none
|
|
50
87
|
-webkit-overflow-scrolling touch
|
|
88
|
+
// a press that turns into a drag is a scroll of the button row, which is
|
|
89
|
+
// exactly what this container does — the hold itself is handled in JS
|
|
90
|
+
touch-action pan-x
|
|
51
91
|
&::-webkit-scrollbar
|
|
52
92
|
display none
|
|
53
93
|
|
|
54
|
-
.shortcut-bar-btn
|
|
55
|
-
flex-shrink 0
|
|
56
|
-
height 32px
|
|
57
|
-
min-width 32px
|
|
58
|
-
padding 0 10px
|
|
59
|
-
display inline-flex
|
|
60
|
-
align-items center
|
|
61
|
-
justify-content center
|
|
62
|
-
border-radius 6px
|
|
63
|
-
border 1px solid var(--main-lighter)
|
|
64
|
-
background var(--main-light)
|
|
65
|
-
color var(--text)
|
|
66
|
-
font-size 13px
|
|
67
|
-
cursor pointer
|
|
68
|
-
white-space nowrap
|
|
69
|
-
&:active
|
|
70
|
-
background var(--primary)
|
|
71
|
-
color var(--primary-contrast)
|
|
72
|
-
&:hover
|
|
73
|
-
color var(--text-light)
|
|
74
|
-
|
|
75
94
|
// while the bar is visible, lift the footer + reserve space so nothing is
|
|
76
95
|
// covered (terminal area is shrunk by layout.jsx via store.shortcutBarVisible;
|
|
77
96
|
// --shortcut-bar-kb-offset raises both bar + footer above a page-overlaying
|
|
@@ -46,9 +46,11 @@ export default function WidgetControl ({ formData, widgetInstancesLength }) {
|
|
|
46
46
|
title: `${widget.info.name} (${result.instanceId})`,
|
|
47
47
|
widgetId: result.widgetId,
|
|
48
48
|
serverInfo: result.serverInfo,
|
|
49
|
-
config
|
|
49
|
+
config,
|
|
50
|
+
startedAt: Date.now()
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
// assigned, not pushed: see deleteWidgetInstance in store/widgets.js
|
|
53
|
+
window.store.widgetInstances = [...window.store.widgetInstances, instance]
|
|
52
54
|
if (config.autoRun) {
|
|
53
55
|
window.store.toggleAutoRunWidget(instance)
|
|
54
56
|
}
|