@electerm/electerm-react 5.5.56 → 5.5.76
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/constants.js +12 -0
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/default-setting.js +1 -0
- package/client/common/distribute-tabs.js +42 -0
- package/client/common/import-task.js +46 -3
- package/client/common/parse-quick-connect.js +47 -6
- package/client/common/quick-connect-string.js +134 -0
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -0
- package/client/common/ui-theme-color.mjs +136 -0
- package/client/common/update-check.js +14 -0
- package/client/components/ai/ai-output.jsx +5 -0
- package/client/components/ai/ai.styl +19 -0
- package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
- package/client/components/bookmark-form/form-renderer.jsx +13 -1
- package/client/components/bookmark-form/index.jsx +20 -1
- package/client/components/bookmark-form/render-form.jsx +2 -2
- package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
- package/client/components/main/ui-theme.jsx +1 -20
- package/client/components/main/upgrade.jsx +2 -0
- package/client/components/setting-panel/install-command.jsx +151 -0
- package/client/components/setting-panel/setting-common.jsx +5 -0
- package/client/components/setting-panel/setting-terminal.jsx +10 -1
- package/client/components/terminal/mixins/term-attach.js +10 -2
- package/client/components/terminal/mixins/term-init.js +1 -0
- package/client/components/terminal/mixins/term-socket.js +12 -2
- package/client/components/terminal/mixins/term-suggestions.js +16 -3
- package/client/components/terminal/osc52-addon.js +7 -2
- package/client/components/terminal/session-status-overlay.jsx +44 -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/ssh-reload-state.js +41 -2
- package/client/components/terminal/startup-queue.js +6 -2
- package/client/components/terminal/terminal.jsx +17 -2
- package/client/components/terminal/terminal.styl +14 -5
- package/client/store/mcp-handler.js +8 -15
- package/client/store/sync.js +47 -18
- package/client/store/tab.js +47 -17
- package/client/store/watch.js +8 -0
- package/package.json +1 -1
- package/client/components/terminal/reconnect-overlay.jsx +0 -14
|
@@ -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,17 +46,56 @@ export function createRestoreCwdCommand (cwd) {
|
|
|
46
46
|
return safeCwd ? `cd -- ${quotePosixShellArg(safeCwd)}` : ''
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// The shell setting is hand-entered config, so tolerate a quoted or padded
|
|
50
|
+
// executable path before deciding which shell we are looking at.
|
|
51
|
+
export function isPowerShellExecutable (shell = '') {
|
|
52
|
+
const shellPath = String(shell || '').trim().replace(/^"|"$/g, '')
|
|
53
|
+
return /(?:^|[/\\])(?:powershell|pwsh)(?:\.exe)?$/i.test(shellPath)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createWindowsRestoreCwdCommand (cwd, shell = '') {
|
|
50
57
|
const safeCwd = sanitizeSshCwd(cwd)
|
|
51
58
|
if (!safeCwd) {
|
|
52
59
|
return ''
|
|
53
60
|
}
|
|
61
|
+
if (isPowerShellExecutable(shell)) {
|
|
62
|
+
// -LiteralPath keeps `[`, `]` and `*` in a directory name from being read
|
|
63
|
+
// as a wildcard; '' is PowerShell's escape for a literal single quote.
|
|
64
|
+
return `Set-Location -LiteralPath '${safeCwd.replace(/'/g, "''")}'`
|
|
65
|
+
}
|
|
54
66
|
// Strip control chars already handled by sanitize; escape embedded double
|
|
55
|
-
// quotes for
|
|
67
|
+
// quotes for CMD.
|
|
56
68
|
const escaped = safeCwd.replace(/"/g, '""')
|
|
57
69
|
return `cd /d "${escaped}"`
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The `cd <dir>` command for a terminal session, used when the user sends a
|
|
74
|
+
* directory from the file panel to the terminal.
|
|
75
|
+
*
|
|
76
|
+
* A local session knows which shell it spawned, so it can emit syntax that
|
|
77
|
+
* shell actually understands (PowerShell rejects CMD's `cd /d`). A remote tab
|
|
78
|
+
* cannot: the far side's shell is unknown from here, so it keeps the CMD form.
|
|
79
|
+
* Every branch quotes the path with the target shell's own rules - a directory
|
|
80
|
+
* name is attacker-influenced (it can come out of an unpacked archive), so a
|
|
81
|
+
* bare `cd "..."` would let `$(...)` and backticks run.
|
|
82
|
+
*
|
|
83
|
+
* Returns '' when the path is unusable. Callers must not fall back to
|
|
84
|
+
* interpolating the raw path into a command.
|
|
85
|
+
*/
|
|
86
|
+
export function createCdCommand (dir, { remote = false, shell = '' } = {}) {
|
|
87
|
+
const safeDir = sanitizeSshCwd(dir)
|
|
88
|
+
if (!safeDir) {
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
if (!/^[a-zA-Z]:\\/.test(safeDir)) {
|
|
92
|
+
return `cd ${quotePosixShellArg(safeDir)}`
|
|
93
|
+
}
|
|
94
|
+
return remote
|
|
95
|
+
? `cd /d "${safeDir.replace(/"/g, '""')}"`
|
|
96
|
+
: createWindowsRestoreCwdCommand(safeDir, shell)
|
|
97
|
+
}
|
|
98
|
+
|
|
60
99
|
export function shouldCaptureTerminalReloadState (tab, config = {}) {
|
|
61
100
|
return !!config.restoreTerminalSessionOnReload && isRestorableTerminalTab(tab)
|
|
62
101
|
}
|
|
@@ -141,7 +141,7 @@ export class StartupQueue {
|
|
|
141
141
|
|
|
142
142
|
// ---- queue ----
|
|
143
143
|
|
|
144
|
-
runInitScript = () => {
|
|
144
|
+
runInitScript = (localShell) => {
|
|
145
145
|
const { term } = this
|
|
146
146
|
window.store.triggerResize()
|
|
147
147
|
const {
|
|
@@ -156,9 +156,13 @@ export class StartupQueue {
|
|
|
156
156
|
const startFolder = reloadCwd || startDirectory || window.initFolder
|
|
157
157
|
let cwdCommand = ''
|
|
158
158
|
if (startFolder) {
|
|
159
|
+
// No `|| cd "<raw>"` fallback: these helpers return '' for a path they
|
|
160
|
+
// refuse (control characters, over-long), and interpolating the raw value
|
|
161
|
+
// instead would hand `startDirectory` - which a quick-connect link can
|
|
162
|
+
// set - straight to the shell as a command.
|
|
159
163
|
cwdCommand = this.isPosixShell()
|
|
160
164
|
? createRestoreCwdCommand(startFolder)
|
|
161
|
-
: createWindowsRestoreCwdCommand(startFolder
|
|
165
|
+
: createWindowsRestoreCwdCommand(startFolder, localShell || term.props.config.execWindows)
|
|
162
166
|
}
|
|
163
167
|
if (cwdCommand) {
|
|
164
168
|
scripts.unshift({ script: cwdCommand, delay: 0 })
|
|
@@ -19,7 +19,7 @@ import NormalBuffer from './normal-buffer.jsx'
|
|
|
19
19
|
import createDefaultLogPath from '../../common/default-log-path.js'
|
|
20
20
|
import SearchResultBar from './terminal-search-bar'
|
|
21
21
|
import RemoteFloatControl from '../common/remote-float-control'
|
|
22
|
-
import
|
|
22
|
+
import SessionStatusOverlay from './session-status-overlay.jsx'
|
|
23
23
|
import TerminalErrorHandle from './terminal-error-handle.jsx'
|
|
24
24
|
import DropFileModal from './drop-file-modal.jsx'
|
|
25
25
|
import TerminalSelectText from './terminal-select-text.jsx'
|
|
@@ -77,6 +77,12 @@ class Term extends Component {
|
|
|
77
77
|
matchIndex: -1,
|
|
78
78
|
totalLines: 0,
|
|
79
79
|
reconnectCountdown: null,
|
|
80
|
+
// True once this pane has no live session (socket closed, or the session
|
|
81
|
+
// failed to start). Not derivable from tab.status: store.updateTab mutates
|
|
82
|
+
// the tab in place, and nothing in this component's ancestor chain reads
|
|
83
|
+
// tab.status during render, so a status change does not re-render the
|
|
84
|
+
// pane. Setting it here is also what triggers that render.
|
|
85
|
+
sessionStopped: false,
|
|
80
86
|
terminalError: null,
|
|
81
87
|
dropFileModalVisible: false,
|
|
82
88
|
droppedFiles: [],
|
|
@@ -103,6 +109,10 @@ class Term extends Component {
|
|
|
103
109
|
name: 'rightClickSelectsWord',
|
|
104
110
|
type: 'glob'
|
|
105
111
|
},
|
|
112
|
+
{
|
|
113
|
+
name: 'mouseEventsRequireAlt',
|
|
114
|
+
type: 'glob'
|
|
115
|
+
},
|
|
106
116
|
{
|
|
107
117
|
name: 'fontSize',
|
|
108
118
|
type: 'glob_local'
|
|
@@ -425,8 +435,13 @@ class Term extends Component {
|
|
|
425
435
|
showEditBookmarkButton={this.state.terminalError?.from === 'bookmarks' && !!this.state.terminalError?.srcId}
|
|
426
436
|
onEditBookmark={this.handleEditBookmarkFromError}
|
|
427
437
|
/>
|
|
428
|
-
<
|
|
438
|
+
<SessionStatusOverlay
|
|
439
|
+
// A failed start already has the error alert as its message; the
|
|
440
|
+
// notice would just stack on top of it.
|
|
441
|
+
stopped={this.state.sessionStopped && !this.state.terminalError}
|
|
429
442
|
countdown={this.state.reconnectCountdown}
|
|
443
|
+
reloadShortcut={this.getShortcut('app_reloadCurrentTab')}
|
|
444
|
+
closeShortcut={this.getShortcut('app_closeCurrentTab')}
|
|
430
445
|
/>
|
|
431
446
|
{this.renderResetFontSizeButton()}
|
|
432
447
|
<DropFileModal
|
|
@@ -222,12 +222,21 @@
|
|
|
222
222
|
max-height 200px
|
|
223
223
|
overflow-y scroll
|
|
224
224
|
|
|
225
|
-
.terminal-
|
|
225
|
+
.terminal-session-stopped
|
|
226
226
|
position absolute
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
227
|
+
left 50%
|
|
228
|
+
top 50%
|
|
229
|
+
transform translate(-50%, -50%)
|
|
230
|
+
padding 8px 14px
|
|
231
|
+
background var(--main)
|
|
232
|
+
color var(--text)
|
|
233
|
+
border 1px solid var(--main-lighter)
|
|
234
|
+
border-radius 4px
|
|
235
|
+
box-shadow 0 2px 8px rgba(0, 0, 0, 0.35)
|
|
236
|
+
font-size 12px
|
|
237
|
+
line-height 1.7
|
|
238
|
+
white-space nowrap
|
|
239
|
+
// keyboard-only affordance: never intercept a click meant for the terminal
|
|
231
240
|
pointer-events none
|
|
232
241
|
z-index 110
|
|
233
242
|
|