@electerm/electerm-react 5.1.8 → 5.1.20

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.
@@ -80,6 +80,7 @@ export default {
80
80
  disableDeveloperTool: false,
81
81
  dragDropBehavior: 'ask',
82
82
  switchTabOnHover: false,
83
+ disableShortcutBar: false,
83
84
  leftSideBarIcons: [
84
85
  'newBookmark',
85
86
  'quickConnect',
@@ -81,7 +81,7 @@ const defaultThemeDarkTerminal = () => {
81
81
  background: '#20111b',
82
82
  cursor: '#b5bd68',
83
83
  cursorAccent: '#1d1f21',
84
- selectionBackground: 'rgba(255, 255, 255, 0.3)',
84
+ selectionBackground: 'rgba(200, 200, 200, 0.6)',
85
85
  black: '#575757',
86
86
  red: '#FF2C6D',
87
87
  green: '#19f9d8',
@@ -51,6 +51,14 @@ export const defaultAIPresets = [
51
51
  modelAI: 'openai/gpt-4o-mini',
52
52
  authHeaderNameAI: 'Authorization: Bearer'
53
53
  },
54
+ {
55
+ id: 'orcarouter',
56
+ nameAI: 'OrcaRouter',
57
+ baseURLAI: 'https://api.orcarouter.ai/v1',
58
+ apiPathAI: '/chat/completions',
59
+ modelAI: 'deepseek/deepseek-v4-flash',
60
+ authHeaderNameAI: 'Authorization: Bearer'
61
+ },
54
62
  {
55
63
  id: 'google',
56
64
  nameAI: 'Google Gemini',
@@ -608,6 +608,7 @@ export default class SettingCommon extends Component {
608
608
  'allowMultiInstance',
609
609
  'disableDeveloperTool',
610
610
  'switchTabOnHover',
611
+ 'disableShortcutBar',
611
612
  'debug'
612
613
  ].map(this.renderToggle)
613
614
  }
@@ -216,8 +216,15 @@ export default class AttachAddonCustom {
216
216
  let str = data
217
217
  if (typeof data !== 'string') {
218
218
  try {
219
+ // Decode in streaming mode: slow SSH servers (e.g. embedded router
220
+ // CLIs) often deliver a multi-byte UTF-8 char (CJK = 3 bytes) split
221
+ // across TCP segments, and the server-side idle fast path may forward
222
+ // the first segment immediately. Without { stream: true } the decoder
223
+ // would turn each partial fragment into U+FFFD instead of carrying the
224
+ // trailing bytes over to the next chunk and reassembling the char.
219
225
  str = this.decoder.decode(
220
- data instanceof ArrayBuffer ? data : new Uint8Array(data)
226
+ data instanceof ArrayBuffer ? data : new Uint8Array(data),
227
+ { stream: true }
221
228
  )
222
229
  } catch (e) {
223
230
  str = ''
@@ -435,6 +442,9 @@ export default class AttachAddonCustom {
435
442
  this._flushScheduled = false
436
443
  this._writeBuffer = []
437
444
  this._bufferBytes = 0
445
+ // Reset the streaming decoder so any partial multi-byte sequence held
446
+ // over from this connection can not leak into a reused instance.
447
+ this.decoder = new TextDecoder('utf-8')
438
448
  this.term = null
439
449
  this._disposables.forEach(d => d.dispose())
440
450
  this._disposables.length = 0
@@ -8,7 +8,8 @@ import * as ls from '../../common/safe-local-storage'
8
8
  import { shortcutBarLsKey } from '../../common/constants'
9
9
 
10
10
  // build control bytes at runtime — keeps source free of literal control chars
11
- const ESC = String.fromCharCode(0x1b)
11
+ // exported so shortcut-bar.jsx can honor DECCKM for arrow / Home / End keys.
12
+ export const ESC = String.fromCharCode(0x1b)
12
13
  const DEL = String.fromCharCode(0x7f)
13
14
 
14
15
  // --- candidate library -----------------------------------------------------
@@ -60,6 +61,69 @@ const punctButtons = punctChars.split('').map(ch => ({
60
61
  data: ch
61
62
  }))
62
63
 
64
+ // --- custom combo builder data ---------------------------------------------
65
+ // Bare keys + modifiers for the edit modal's custom combo builder
66
+ // (shortcut-bar-edit.jsx). Unlike `candidates`, keyOptions holds raw keys
67
+ // (letters, digits, nav, f-keys, punctuation) with no pre-applied modifier —
68
+ // the modal composes Ctrl/Alt/Shift/Meta onto them, so the user can build
69
+ // combos like Ctrl+Shift+C or Alt+Up without typing escape sequences.
70
+
71
+ // letters carry a precomputed control byte so Ctrl+letter maps to the right
72
+ // control byte (charCode - 96) without recomputing at click time.
73
+ const letterKeys = 'abcdefghijklmnopqrstuvwxyz'.split('').map(ch => ({
74
+ id: 'key-' + ch,
75
+ label: ch.toUpperCase(),
76
+ data: ch,
77
+ ctrl: String.fromCharCode(ch.charCodeAt(0) - 96)
78
+ }))
79
+
80
+ const digitKeys = '0123456789'.split('').map(ch => ({
81
+ id: 'key-' + ch,
82
+ label: ch,
83
+ data: ch
84
+ }))
85
+
86
+ // bare keys (no Ctrl pre-applied) for the combo builder
87
+ const keyOptions = [
88
+ ...navButtons,
89
+ ...letterKeys,
90
+ ...digitKeys,
91
+ ...fKeys,
92
+ ...punctButtons
93
+ ]
94
+
95
+ // modifier picklist for the two optional modifier slots in the builder
96
+ const modifierOptions = [
97
+ { id: 'ctrl', label: 'Ctrl' },
98
+ { id: 'alt', label: 'Alt' },
99
+ { id: 'shift', label: 'Shift' },
100
+ { id: 'meta', label: 'Meta' }
101
+ ]
102
+
103
+ // compose the bytes to send for a (mods, key) combo.
104
+ // Ctrl+letter -> the control byte; Alt -> ESC prefix; Shift+letter -> uppercase
105
+ function buildComboData (mods, key) {
106
+ const ctrl = mods.includes('ctrl')
107
+ const alt = mods.includes('alt')
108
+ const shift = mods.includes('shift')
109
+ let data = key.data
110
+ if (ctrl && key.ctrl) {
111
+ data = key.ctrl
112
+ } else if (shift && /^[a-z]$/.test(data)) {
113
+ data = data.toUpperCase()
114
+ }
115
+ if (alt) {
116
+ data = ESC + data
117
+ }
118
+ return data
119
+ }
120
+
121
+ // human-readable label, e.g. mods ['ctrl','shift'] + key A -> "Ctrl+Shift+A"
122
+ function buildComboLabel (mods, key) {
123
+ const byId = new Map(modifierOptions.map(m => [m.id, m.label]))
124
+ return [...mods.map(m => byId.get(m) || m), key.label].join('+')
125
+ }
126
+
63
127
  // full library, de-duplicated by id
64
128
  const candidates = []
65
129
  const seenIds = new Set()
@@ -128,4 +192,10 @@ export function saveActive (arr) {
128
192
  ls.setItemJSON(shortcutBarLsKey, arr)
129
193
  }
130
194
 
131
- export { candidates }
195
+ export {
196
+ candidates,
197
+ keyOptions,
198
+ modifierOptions,
199
+ buildComboData,
200
+ buildComboLabel
201
+ }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { useState } from 'react'
15
15
  import { createPortal } from 'react-dom'
16
- import { Input } from 'antd'
16
+ import { Input, Select } from 'antd'
17
17
  import {
18
18
  CloseOutlined,
19
19
  PlusOutlined,
@@ -22,7 +22,13 @@ import {
22
22
  } from '@ant-design/icons'
23
23
  import classnames from 'classnames'
24
24
  import Modal from '../common/modal'
25
- import { defaultActive } from './shortcut-bar-defs'
25
+ import {
26
+ defaultActive,
27
+ keyOptions,
28
+ modifierOptions,
29
+ buildComboData,
30
+ buildComboLabel
31
+ } from './shortcut-bar-defs'
26
32
 
27
33
  const e = window.translate
28
34
 
@@ -35,42 +41,51 @@ export default function ShortcutBarEdit (props) {
35
41
  onSave
36
42
  } = props
37
43
 
38
- const [draft, setDraft] = useState(() => active.map(b => ({ ...b })))
39
44
  const [keyword, setKeyword] = useState('')
40
- const [customLabel, setCustomLabel] = useState('')
41
- const [customData, setCustomData] = useState('')
45
+ const [customMod1, setCustomMod1] = useState(undefined)
46
+ const [customMod2, setCustomMod2] = useState(undefined)
47
+ const [customKey, setCustomKey] = useState(undefined)
48
+
49
+ // antd Select portals its dropdown to document.body by default (~z-index
50
+ // 1050), which renders BEHIND this modal (z-index 1100). Anchor each popup
51
+ // to its trigger's parent so it stays inside the modal's stacking context.
52
+ const popupContainer = trigger => trigger.parentElement
42
53
 
43
- const activeIds = new Set(draft.map(b => b.id))
54
+ // edits persist immediately — there is no Save/Cancel, so every change calls
55
+ // onSave to push it live.
56
+ const activeIds = new Set(active.map(b => b.id))
44
57
 
45
58
  function handleAddCandidate (btn) {
46
59
  if (activeIds.has(btn.id)) {
47
60
  return
48
61
  }
49
- setDraft(prev => [...prev, { ...btn }])
62
+ onSave([...active, { ...btn }])
50
63
  }
51
64
 
52
65
  function handleRemoveActive (id) {
53
- setDraft(prev => prev.filter(b => b.id !== id))
66
+ onSave(active.filter(b => b.id !== id))
54
67
  }
55
68
 
56
69
  function handleAddCustom () {
57
- const label = customLabel.trim()
58
- const data = customData
59
- if (!label) {
70
+ if (!customKey) {
71
+ return
72
+ }
73
+ const mods = [customMod1, customMod2].filter(Boolean)
74
+ const key = keyOptions.find(k => k.id === customKey)
75
+ if (!key) {
60
76
  return
61
77
  }
78
+ const label = buildComboLabel(mods, key)
79
+ const data = buildComboData(mods, key)
62
80
  const id = 'custom-' + label + '-' + Date.now()
63
- setDraft(prev => [...prev, { id, label, data, custom: true }])
64
- setCustomLabel('')
65
- setCustomData('')
81
+ onSave([...active, { id, label, data, custom: true }])
82
+ setCustomMod1(undefined)
83
+ setCustomMod2(undefined)
84
+ setCustomKey(undefined)
66
85
  }
67
86
 
68
87
  function handleReset () {
69
- setDraft(defaultActive())
70
- }
71
-
72
- function handleSave () {
73
- onSave(draft)
88
+ onSave(defaultActive())
74
89
  }
75
90
 
76
91
  const kw = keyword.trim().toLowerCase()
@@ -122,20 +137,6 @@ export default function ShortcutBarEdit (props) {
122
137
  >
123
138
  <ReloadOutlined /> {e('reset')}
124
139
  </button>
125
- <button
126
- type='button'
127
- className='custom-modal-cancel-btn'
128
- onClick={onCancel}
129
- >
130
- {e('cancel')}
131
- </button>
132
- <button
133
- type='button'
134
- className='custom-modal-ok-btn'
135
- onClick={handleSave}
136
- >
137
- {e('save')}
138
- </button>
139
140
  </div>
140
141
  )
141
142
  const sst = e('keyboardShortcuts')
@@ -152,10 +153,10 @@ export default function ShortcutBarEdit (props) {
152
153
  <div className='shortcut-edit-section'>
153
154
  <div className='shortcut-edit-section-title'>{sst}</div>
154
155
  <div className='shortcut-active-list'>
155
- {draft.length === 0 && (
156
+ {active.length === 0 && (
156
157
  <div className='shortcut-active-empty'>0 {sst}</div>
157
158
  )}
158
- {draft.map(renderActive)}
159
+ {active.map(renderActive)}
159
160
  </div>
160
161
  </div>
161
162
 
@@ -178,21 +179,49 @@ export default function ShortcutBarEdit (props) {
178
179
  <div className='shortcut-edit-section'>
179
180
  <div className='shortcut-edit-section-title'>+ {sst}</div>
180
181
  <div className='shortcut-custom-form'>
181
- <Input
182
- value={customLabel}
183
- onChange={ev => setCustomLabel(ev.target.value)}
184
- placeholder={e('label')}
185
- className='shortcut-custom-input'
182
+ <Select
183
+ value={customMod1}
184
+ onChange={setCustomMod1}
185
+ placeholder={e('modifier') + ' (' + e('optional') + ')'}
186
+ className='shortcut-custom-select'
187
+ allowClear
188
+ getPopupContainer={popupContainer}
189
+ options={modifierOptions.map(m => ({
190
+ value: m.id,
191
+ label: m.label,
192
+ disabled: m.id === customMod2
193
+ }))}
186
194
  />
187
- <Input
188
- value={customData}
189
- onChange={ev => setCustomData(ev.target.value)}
190
- placeholder={e('edit')}
191
- className='shortcut-custom-input'
195
+ <Select
196
+ value={customMod2}
197
+ onChange={setCustomMod2}
198
+ placeholder={e('modifier') + ' (' + e('optional') + ')'}
199
+ className='shortcut-custom-select'
200
+ allowClear
201
+ getPopupContainer={popupContainer}
202
+ options={modifierOptions.map(m => ({
203
+ value: m.id,
204
+ label: m.label,
205
+ disabled: m.id === customMod1
206
+ }))}
207
+ />
208
+ <Select
209
+ value={customKey}
210
+ onChange={setCustomKey}
211
+ placeholder={e('key')}
212
+ className='shortcut-custom-select shortcut-custom-key'
213
+ allowClear
214
+ showSearch
215
+ getPopupContainer={popupContainer}
216
+ options={keyOptions.map(k => ({
217
+ value: k.id,
218
+ label: k.label
219
+ }))}
192
220
  />
193
221
  <button
194
222
  type='button'
195
223
  className='custom-modal-ok-btn shortcut-custom-add'
224
+ disabled={!customKey}
196
225
  onClick={handleAddCustom}
197
226
  >
198
227
  <PlusOutlined /> {e('add')}
@@ -1,16 +1,42 @@
1
1
  /**
2
2
  * lazy entry for shortcut-bar.
3
3
  *
4
- * The bar only makes sense on touch devices (it exists to compensate for the
5
- * lack of a physical keyboard when the system input panel is shown). On
6
- * non-touch devices we render nothing AND never import the real component, so
7
- * its code (and the antd/icon deps it pulls in) stays out of the main bundle.
4
+ * The bar compensates for the missing physical keyboard while the system
5
+ * input panel (on-screen keyboard) is shown over a terminal. Capability
6
+ * probes like `(pointer: coarse)` / `maxTouchPoints` are unreliable here —
7
+ * remote-control clients and some touch-capable laptops report touch even
8
+ * though tapping the terminal never brings up a soft keyboard.
9
+ *
10
+ * So instead of guessing, we react to the real signal: the soft keyboard
11
+ * shrinking `window.visualViewport`. Until that is observed we render nothing
12
+ * AND never import the real component, so its code (and the antd/icon deps it
13
+ * pulls in) stays out of the main bundle on every device that doesn't
14
+ * actually show a keyboard.
15
+ *
16
+ * Detection is cross-platform:
17
+ * - visualViewport.height always reflects the *visible* area and shrinks
18
+ * when the keyboard opens on BOTH iOS and Android.
19
+ * - the layout viewport (window.innerHeight) is what differs: iOS keeps it
20
+ * fixed, Android resizes it too — so the naive innerHeight−visualViewport
21
+ * gap works on iOS but is ~0 on Android. We therefore compare
22
+ * visualViewport.height against a baseline captured at the moment the
23
+ * terminal input is focused (i.e. before the keyboard opens), which holds
24
+ * the full pre-keyboard height on both platforms.
8
25
  */
9
26
 
10
- import { lazy, Suspense } from 'react'
27
+ import { lazy, Suspense, useEffect, useState } from 'react'
11
28
 
12
29
  const ShortcutBar = lazy(() => import('./shortcut-bar'))
13
30
 
31
+ // minimum height (px) the visual viewport must lose for us to treat it as
32
+ // "the soft keyboard is now covering part of the screen". A soft keyboard is
33
+ // typically ≥150px; a browser address-bar show/hide is well under this.
34
+ const KEYBOARD_HEIGHT = 120
35
+
36
+ // kept as a coarse capability fallback for engines without visualViewport.
37
+ // Modern Chromium (Electron) always exposes window.visualViewport, so this is
38
+ // effectively only reached on very old runtimes — where remote-control
39
+ // misreporting is not a concern.
14
40
  export function isTouchDevice () {
15
41
  if (typeof window === 'undefined' || typeof navigator === 'undefined') {
16
42
  return false
@@ -23,7 +49,67 @@ export function isTouchDevice () {
23
49
  }
24
50
 
25
51
  export default function ShortcutBarEntry (props) {
26
- if (!isTouchDevice()) {
52
+ // mount the lazy bar only after a soft keyboard has been seen at least once,
53
+ // so its code (and antd/icon deps) stays out of the main bundle on devices
54
+ // that never show one (desktops, remote-control clients mis-reported as
55
+ // touch, etc.).
56
+ //
57
+ // opt out entirely when the user has disabled the shortcut bar in common
58
+ // settings — bail before any listener is attached or component imported.
59
+ const { store } = props
60
+ const disabled = store.config.disableShortcutBar
61
+ const [keyboardSeen, setKeyboardSeen] = useState(() => {
62
+ if (disabled || typeof window === 'undefined' || !window.visualViewport) {
63
+ return disabled ? false : isTouchDevice()
64
+ }
65
+ return false
66
+ })
67
+
68
+ useEffect(() => {
69
+ const vv = window.visualViewport
70
+ if (!vv) {
71
+ return
72
+ }
73
+ // baseline = the visual viewport captured the last time focus moved into a
74
+ // terminal. The keyboard opens *after* that focus, so this baseline holds
75
+ // the pre-keyboard (full) size, and the subsequent resize lets us measure
76
+ // exactly how much the keyboard took — on both iOS and Android.
77
+ let baseline = { height: vv.height, width: vv.width }
78
+
79
+ function onFocusIn (ev) {
80
+ const el = ev.target
81
+ if (el && el.closest && el.closest('.term-wrap')) {
82
+ baseline = { height: vv.height, width: vv.width }
83
+ }
84
+ }
85
+
86
+ function onResize () {
87
+ // ignore viewport changes that also changed width (rotation / split) —
88
+ // a keyboard changes height only.
89
+ const sameWidth = Math.abs(vv.width - baseline.width) < 20
90
+ if (sameWidth && baseline.height - vv.height > KEYBOARD_HEIGHT) {
91
+ setKeyboardSeen(true)
92
+ // The user is focused in a terminal with the keyboard up — they need
93
+ // the bar right now. Set visibility here rather than waiting for the
94
+ // focus/pointer listener inside the (just-mounted) ShortcutBar, which
95
+ // would otherwise miss the focusin that already fired before it
96
+ // mounted — leaving the bar's reserved space empty until a 2nd tap.
97
+ const { store } = window
98
+ if (store && store.inActiveTerminal) {
99
+ store.shortcutBarVisible = true
100
+ }
101
+ }
102
+ }
103
+
104
+ document.addEventListener('focusin', onFocusIn)
105
+ vv.addEventListener('resize', onResize)
106
+ return () => {
107
+ document.removeEventListener('focusin', onFocusIn)
108
+ vv.removeEventListener('resize', onResize)
109
+ }
110
+ }, [])
111
+
112
+ if (disabled || !keyboardSeen) {
27
113
  return null
28
114
  }
29
115
  return (
@@ -22,17 +22,43 @@ import { DownOutlined, EditOutlined } from '@ant-design/icons'
22
22
  import { refs } from '../common/ref'
23
23
  import { shortcutBarHeight } from '../../common/constants'
24
24
  import ShortcutBarEdit from './shortcut-bar-edit'
25
- import { candidates, loadActive, saveActive } from './shortcut-bar-defs'
25
+ import { candidates, loadActive, saveActive, ESC } from './shortcut-bar-defs'
26
26
  import './shortcut-bar.styl'
27
27
 
28
28
  const e = window.translate
29
29
 
30
+ // Arrow / Home / End keys have two wire forms: the normal CSI form (ESC [ x)
31
+ // and the application SS3 form (ESC O x). Which one a keypress should produce
32
+ // is decided by the terminal's DECCKM (Application Cursor Keys) state, which
33
+ // the remote program sets. Some shells accept both forms (bash/readline), but
34
+ // stricter line editors such as OpenWrt's busybox ash only recognize the form
35
+ // matching the current mode — so always sending the CSI form breaks arrow
36
+ // keys there. A real keyboard switches on the mode; this does the same.
37
+ const appCursorMap = {
38
+ [ESC + '[A']: ESC + 'OA', // ↑
39
+ [ESC + '[B']: ESC + 'OB', // ↓
40
+ [ESC + '[C']: ESC + 'OC', // →
41
+ [ESC + '[D']: ESC + 'OD', // ←
42
+ [ESC + '[H']: ESC + 'OH', // Home
43
+ [ESC + '[F']: ESC + 'OF' // End
44
+ }
45
+
30
46
  function sendToTerminal (data) {
31
47
  const { store } = window
32
48
  const term = refs.get('term-' + store.activeTabId)
33
49
  if (term && typeof term.runQuickCommand === 'function') {
34
- term.runQuickCommand(data, true)
50
+ const resolved = resolveCursorMode(data, term)
51
+ term.runQuickCommand(resolved, true)
52
+ }
53
+ }
54
+
55
+ // honor the active terminal's DECCKM state for the cursor / Home / End keys
56
+ function resolveCursorMode (data, term) {
57
+ const app = appCursorMap[data]
58
+ if (app && term?.term?.modes?.applicationCursorKeysMode) {
59
+ return app
35
60
  }
61
+ return data
36
62
  }
37
63
 
38
64
  function ShortcutBar (props) {
@@ -85,10 +111,10 @@ function ShortcutBar (props) {
85
111
  store.shortcutBarVisible = false
86
112
  }
87
113
 
114
+ // edits inside the modal persist live; the modal closes via its own X button.
88
115
  function handleSave (next) {
89
116
  setButtons(next)
90
117
  saveActive(next)
91
- setEditing(false)
92
118
  }
93
119
 
94
120
  function renderButton (b, i) {
@@ -171,12 +171,20 @@
171
171
  gap 8px
172
172
  align-items center
173
173
  flex-wrap wrap
174
+ position relative
174
175
  .shortcut-search-input
175
176
  flex 1
176
177
  min-width 160px
177
178
  .shortcut-custom-input
178
179
  flex 1
179
180
  min-width 120px
181
+ .shortcut-custom-select
182
+ flex 1
183
+ min-width 120px
184
+ width 120px
185
+ .shortcut-custom-key
186
+ min-width 110px
187
+ width 110px
180
188
  .shortcut-custom-add
181
189
  height 32px
182
190
  white-space nowrap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.1.8",
3
+ "version": "5.1.20",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",