@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.
Files changed (43) hide show
  1. package/client/common/constants.js +12 -0
  2. package/client/common/dangerous-session-fields.js +39 -0
  3. package/client/common/default-setting.js +1 -0
  4. package/client/common/distribute-tabs.js +42 -0
  5. package/client/common/import-task.js +46 -3
  6. package/client/common/parse-quick-connect.js +47 -6
  7. package/client/common/quick-connect-string.js +134 -0
  8. package/client/common/safe-join.js +33 -0
  9. package/client/common/sanitize-filename.js +8 -0
  10. package/client/common/ui-theme-color.mjs +136 -0
  11. package/client/common/update-check.js +14 -0
  12. package/client/components/ai/ai-output.jsx +5 -0
  13. package/client/components/ai/ai.styl +19 -0
  14. package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
  15. package/client/components/bookmark-form/form-renderer.jsx +13 -1
  16. package/client/components/bookmark-form/index.jsx +20 -1
  17. package/client/components/bookmark-form/render-form.jsx +2 -2
  18. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  19. package/client/components/main/ui-theme.jsx +1 -20
  20. package/client/components/main/upgrade.jsx +2 -0
  21. package/client/components/setting-panel/install-command.jsx +151 -0
  22. package/client/components/setting-panel/setting-common.jsx +5 -0
  23. package/client/components/setting-panel/setting-terminal.jsx +10 -1
  24. package/client/components/terminal/mixins/term-attach.js +10 -2
  25. package/client/components/terminal/mixins/term-init.js +1 -0
  26. package/client/components/terminal/mixins/term-socket.js +12 -2
  27. package/client/components/terminal/mixins/term-suggestions.js +16 -3
  28. package/client/components/terminal/osc52-addon.js +7 -2
  29. package/client/components/terminal/session-status-overlay.jsx +44 -0
  30. package/client/components/terminal/shell.js +11 -3
  31. package/client/components/terminal/shortcut-bar-press.js +192 -0
  32. package/client/components/terminal/shortcut-bar.jsx +199 -4
  33. package/client/components/terminal/shortcut-bar.styl +40 -21
  34. package/client/components/terminal/ssh-reload-state.js +41 -2
  35. package/client/components/terminal/startup-queue.js +6 -2
  36. package/client/components/terminal/terminal.jsx +17 -2
  37. package/client/components/terminal/terminal.styl +14 -5
  38. package/client/store/mcp-handler.js +8 -15
  39. package/client/store/sync.js +47 -18
  40. package/client/store/tab.js +47 -17
  41. package/client/store/watch.js +8 -0
  42. package/package.json +1 -1
  43. 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 sendToTerminal (data) {
55
+ function activeTerm () {
51
56
  const { store } = window
52
- const term = refs.get('term-' + store.activeTabId)
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='shortcut-bar-btn'
186
- onClick={() => sendToTerminal(b.data)}
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
- export function createWindowsRestoreCwdCommand (cwd) {
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 cmd/powershell (`""` is accepted by both as a literal quote).
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) || `cd "${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 ReconnectOverlay from './reconnect-overlay.jsx'
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
- <ReconnectOverlay
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-reconnect-overlay
225
+ .terminal-session-stopped
226
226
  position absolute
227
- bottom 6px
228
- right 12px
229
- font-size 11px
230
- color rgba(255, 255, 255, 0.55)
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