@electerm/electerm-react 5.3.5 → 5.3.26

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.
@@ -201,6 +201,9 @@ export const commonLineEndings = commonTxLineEndings
201
201
  export const maxBatchInput = 30
202
202
  export const windowControlWidth = 94
203
203
  export const mobileBreakpoint = 600
204
+ // breakpoint matching the setting panel's `@media (max-width: 800px)` CSS —
205
+ // must be kept in sync with setting-wrap.styl
206
+ export const settingPanelMobileBreakpoint = 800
204
207
  export const baseUpdateCheckUrls = [
205
208
  packInfo.homepage,
206
209
  'https://gitee.com/github-zxdong262/electerm/raw/gh-pages'
@@ -130,7 +130,7 @@ export default class BookmarkIndex2 extends PureComponent {
130
130
  return (
131
131
  <Button
132
132
  size='small'
133
- className='mg2l create-ai-btn'
133
+ className='mg1l create-ai-btn'
134
134
  icon={<RobotOutlined />}
135
135
  onClick={this.handleToggleAIMode}
136
136
  >
@@ -171,15 +171,17 @@ export default class BookmarkIndex2 extends PureComponent {
171
171
  const isNew = id.startsWith(newBookmarkIdPrefix)
172
172
  const keys = Object.keys(sessionConfig)
173
173
  return (
174
- <div className='form-wrap pd1x'>
174
+ <div className='form-wrap pd1x mg2b'>
175
175
  <div className='form-title pd1t pd1x pd2b bold'>
176
- <BookOutlined className='mg1r' />
177
- <span>
178
- {((!isNew ? e('edit') : e('new')) + ' ' + e(settingMap.bookmarks))}
179
- </span>
180
- {this.renderTitle(formData, isNew)}
176
+ <p>
177
+ <BookOutlined className='mg1r' />
178
+ <span>
179
+ {((!isNew ? e('edit') : e('new')) + ' ' + e(settingMap.bookmarks))}
180
+ </span>
181
+ {this.renderTitle(formData, isNew)}
182
+ {this.renderAIButton(isNew)}
183
+ </p>
181
184
  {this.renderTypes(bookmarkType, isNew, keys)}
182
- {this.renderAIButton(isNew)}
183
185
  </div>
184
186
  {this.renderForm()}
185
187
  </div>
@@ -86,6 +86,30 @@ export default auto(function Index (props) {
86
86
  store.checkPendingDeepLink()
87
87
  }, [])
88
88
 
89
+ // Track the actual input modality rather than a static capability probe:
90
+ // a touch-capable laptop is a mouse machine until a real touch happens,
91
+ // and a tablet stays touch even though it can also pair a mouse. Stored in
92
+ // `store.isTouchDevice` (seeded false — no upfront probe, which only says
93
+ // the screen *can* be touched, not what the user operates with); the
94
+ // `is-touch-device` class drives always-visible hover-only action icons, so
95
+ // it must follow the device the user is actually operating.
96
+ useEffect(() => {
97
+ const { store } = window
98
+ const handlePointer = (e) => {
99
+ if (e.pointerType === 'mouse') {
100
+ store.isTouchDevice = false
101
+ } else if (e.pointerType === 'touch' || e.pointerType === 'pen') {
102
+ store.isTouchDevice = true
103
+ }
104
+ }
105
+ document.addEventListener('pointerdown', handlePointer)
106
+ document.addEventListener('pointermove', handlePointer)
107
+ return () => {
108
+ document.removeEventListener('pointerdown', handlePointer)
109
+ document.removeEventListener('pointermove', handlePointer)
110
+ }
111
+ }, [])
112
+
89
113
  const { store } = props
90
114
  const {
91
115
  configLoaded,
@@ -122,7 +146,8 @@ export default auto(function Index (props) {
122
146
  'fs-with-footer': fullscreen && store.inActiveTerminal,
123
147
  'is-main': !isSecondInstance,
124
148
  'is-mobile': store.isMobile,
125
- 'is-desktop': !store.isMobile
149
+ 'is-desktop': !store.isMobile,
150
+ 'is-touch-device': store.isTouchDevice
126
151
  })
127
152
  const ext1 = {
128
153
  className: cls,
@@ -1,18 +1,42 @@
1
1
  /**
2
- * two column layout, left column fixed with, right column auto width
2
+ * two column layout, left column fixed width, right column auto width.
3
+ * the DOM is identical on desktop and mobile — which part is visible is
4
+ * pure CSS driven by the 800px media query in setting-wrap.styl (not the
5
+ * global `.is-mobile` class, which matches a different breakpoint), so a
6
+ * resize across the breakpoint never remounts the forms:
7
+ * - desktop (>800px): both columns side by side, breadcrumb hidden
8
+ * - mobile menu view (`setting-view-menu`): left menu fills the panel
9
+ * - mobile content view (`setting-view-content`): breadcrumb on top
10
+ * (click it to go back to the menu) + right content scrolls below
3
11
  */
4
12
 
13
+ import { auto } from 'manate/react'
5
14
  import Placeholder from '../common/placeholder'
15
+ import SettingMobileBreadcrumb from './setting-mobile-breadcrumb'
6
16
 
7
- export default function SettingCol (props) {
17
+ const e = window.translate
18
+
19
+ function SettingCol (props) {
20
+ const store = window.store
21
+ const {
22
+ title,
23
+ name
24
+ } = store.settingItem
25
+ const label = title || name || e('new')
26
+ const viewCls = store.settingMobileView === 'content'
27
+ ? 'setting-view-content'
28
+ : 'setting-view-menu'
8
29
  return (
9
- <div className='setting-col'>
30
+ <div className={`setting-col ${viewCls}`}>
31
+ <SettingMobileBreadcrumb
32
+ onBack={() => store.backToSettingMenu()}
33
+ parentLabel={e(store.settingTab)}
34
+ currentLabel={label}
35
+ />
10
36
  <div className='setting-row setting-row-left'>
11
37
  {props.children[0]}
12
38
  </div>
13
- <div
14
- className='setting-row setting-row-right'
15
- >
39
+ <div className='setting-row setting-row-right'>
16
40
  <div className='setting-col-content'>
17
41
  {props.children[1]}
18
42
  </div>
@@ -23,3 +47,5 @@ export default function SettingCol (props) {
23
47
  </div>
24
48
  )
25
49
  }
50
+
51
+ export default auto(SettingCol)
@@ -45,6 +45,18 @@
45
45
  .theme-item:hover
46
46
  .list-item-remove
47
47
  right 24px
48
+ // touch screens have no hover — always reveal the hover-only action icons
49
+ .is-touch-device
50
+ .list-item-edit
51
+ .list-item-apply
52
+ .list-item-remove
53
+ .list-item-bookmark
54
+ .list-item-duplicate
55
+ .list-item-autorun
56
+ display block
57
+ .theme-item
58
+ .list-item-remove
59
+ right 24px
48
60
  .setting-tabs-setting
49
61
  .item-list-unit
50
62
  .list-item-remove
@@ -0,0 +1,29 @@
1
+ /**
2
+ * breadcrumb shown at the top of the settings content view on mobile.
3
+ * - parent crumb = the active setting tab, clickable, returns to the menu list
4
+ * - current crumb = the selected item name, plain text
5
+ * `onBack` + labels are props so it stays reusable for other panels
6
+ */
7
+
8
+ import { ArrowLeftOutlined } from '@ant-design/icons'
9
+
10
+ export default function SettingMobileBreadcrumb (props) {
11
+ const {
12
+ onBack,
13
+ parentLabel,
14
+ currentLabel
15
+ } = props
16
+ return (
17
+ <div className='setting-mobile-back'>
18
+ <button
19
+ className='setting-mobile-back-btn'
20
+ onClick={onBack}
21
+ >
22
+ <ArrowLeftOutlined />
23
+ <span className='setting-mobile-back-parent'>{parentLabel}</span>
24
+ </button>
25
+ <span className='setting-mobile-back-sep'>&gt;</span>
26
+ <span className='setting-mobile-back-current elli'>{currentLabel}</span>
27
+ </div>
28
+ )
29
+ }
@@ -9,7 +9,8 @@ import { lazy, Suspense } from 'react'
9
9
  import SettingModal from './setting-wrap'
10
10
  import {
11
11
  settingMap,
12
- modals
12
+ modals,
13
+ settingPanelMobileBreakpoint
13
14
  } from '../../common/constants'
14
15
  const TabBookmarks = lazy(() => import('./tab-bookmarks'))
15
16
  const TabQuickCommands = lazy(() => import('./tab-quick-commands'))
@@ -25,6 +26,11 @@ const e = window.translate
25
26
  export default auto(function SettingModalWrap (props) {
26
27
  const selectItem = (item) => {
27
28
  window.store.setSettingItem(item)
29
+ // narrow panels drill into the right col content after picking a menu
30
+ // item — same 800px breakpoint as the setting panel media query
31
+ if (window.store.innerWidth <= settingPanelMobileBreakpoint) {
32
+ window.store.settingMobileView = 'content'
33
+ }
28
34
  }
29
35
 
30
36
  function renderTabs () {
@@ -188,7 +194,6 @@ export default auto(function SettingModalWrap (props) {
188
194
  visible={show}
189
195
  useSystemTitleBar={useSystemTitleBar}
190
196
  innerWidth={innerWidth}
191
- isMobile={props.store.isMobile}
192
197
  >
193
198
  {renderTabs()}
194
199
  </SettingModal>
@@ -16,9 +16,6 @@ export default class SettingWrap extends Component {
16
16
  }
17
17
 
18
18
  renderRightClose () {
19
- if (this.props.isMobile) {
20
- return null
21
- }
22
19
  return (
23
20
  <CloseCircleOutlined
24
21
  className='close-setting-wrap-icon close-setting-wrap'
@@ -67,34 +67,103 @@
67
67
  .setting-col
68
68
  ::-webkit-scrollbar
69
69
  width 0
70
- @media (max-width: 1100px)
70
+ @media (max-width: 800px)
71
71
  .setting-col
72
72
  position fixed
73
73
  top 128px
74
74
  bottom 0
75
75
  left var(--left-side-bar-width, 43px)
76
76
  right 0
77
- overflow-y scroll
77
+ display flex
78
+ flex-direction column
79
+ overflow hidden
78
80
  .setting-row
79
81
  position static
80
82
  top auto
83
+ // right close button is redundant with the left one on mobile
84
+ .close-setting-wrap:not(.alt-close-setting-wrap)
85
+ display none
86
+ // menu view by default: the left col list fills the panel
81
87
  .setting-row-left
88
+ flex 1
89
+ min-height 0
90
+ overflow-y auto
91
+ position static
92
+ top auto
93
+ bottom auto
82
94
  left auto
83
95
  width auto
84
- bottom auto
85
- padding 138px 10px 10px 10px
96
+ padding 10px
86
97
  .setting-row-right
87
- left auto
98
+ display none
99
+ position static
100
+ top auto
88
101
  right auto
89
102
  bottom auto
90
- top auto
103
+ left auto
104
+ width auto
91
105
  padding 10px
92
- position static
93
- overflow visible
94
- .setting-col-content
95
- overflow visible
106
+ // content view: fixed breadcrumb on top, right col content scrolls below
107
+ .setting-view-content
108
+ .setting-mobile-back
109
+ display flex
110
+ .setting-row-left
111
+ display none
112
+ .setting-row-right
113
+ display flex
114
+ flex-direction column
115
+ flex 1
116
+ min-height 0
117
+ overflow hidden
118
+ position static
119
+ top auto
120
+ right auto
121
+ bottom auto
122
+ left auto
123
+ width auto
124
+ padding 10px
125
+ .setting-col-content
126
+ flex 1
127
+ overflow-y auto
128
+ min-height 0
96
129
  .setting-tabs-bookmarks
97
130
  .setting-row-left
98
131
  width auto
99
132
  .setting-row-right
100
- left auto
133
+ left auto
134
+
135
+ // breadcrumb used on the narrow (mobile) content view; hidden by default and
136
+ // re-shown by `.setting-view-content` inside the 800px media query above
137
+ .setting-mobile-back
138
+ display none
139
+ flex-shrink 0
140
+ align-items center
141
+ gap 4px
142
+ padding 8px 10px
143
+ background var(--main)
144
+ border-bottom 1px solid var(--main-light)
145
+ z-index 10
146
+ .setting-mobile-back-btn
147
+ display flex
148
+ align-items center
149
+ gap 4px
150
+ height 24px
151
+ padding 0 6px
152
+ font-size 13px
153
+ color var(--primary)
154
+ background transparent
155
+ border 0
156
+ border-radius 4px
157
+ cursor pointer
158
+ &:hover
159
+ background var(--primary)
160
+ color var(--primary-contrast)
161
+ .setting-mobile-back-sep
162
+ color var(--text-dark)
163
+ font-size 13px
164
+ line-height 24px
165
+ padding 0 2px
166
+ .setting-mobile-back-current
167
+ font-size 13px
168
+ min-width 0
169
+ max-width 60%
@@ -1,3 +1,4 @@
1
+ import { useEffect } from 'react'
1
2
  import {
2
3
  InfoCircleOutlined,
3
4
  UpCircleOutlined,
@@ -48,37 +49,43 @@ export default function Sidebar (props) {
48
49
  const { store } = window
49
50
 
50
51
  const handleClickOutside = (event) => {
51
- // Don't close if pinned or has active input
52
- if (store.pinned || hasActiveInput()) {
52
+ const { store } = window
53
+ // Nothing to dismiss when the panel is closed or pinned; also ignore
54
+ // clicks while typing in an input so the panel is not yanked away
55
+ // mid-interaction.
56
+ if (!store.openedSideBar || store.pinned || hasActiveInput()) {
53
57
  return
54
58
  }
59
+ const target = event.target
60
+ // Never treat the panel itself or the bookmark toggle button as "outside".
61
+ // The very click that toggles the panel open lands on that button, so this
62
+ // guard makes the toggle immune to event-ordering races — and, in dev hot
63
+ // reloads, to stale listeners left behind by an older build.
64
+ if (target.closest && (
65
+ target.closest('.sidebar-panel') ||
66
+ target.closest('.bookmark-sidebar-toggle')
67
+ )) {
68
+ return
69
+ }
70
+ store.setOpenedSideBar('')
71
+ }
55
72
 
56
- // Check if click is outside the sidebar panel
57
- const sidebarPanel = document.querySelector('.sidebar-panel')
58
- if (sidebarPanel && !sidebarPanel.contains(event.target)) {
59
- store.setOpenedSideBar('')
73
+ // One always-on document listener for click-outside dismissal, attached
74
+ // once. Previously the listener was added/removed from inside the bookmark
75
+ // click handler, so it leaked when the panel was closed via the ✕ icon and
76
+ // then instantly re-closed the panel on the next bookmark-icon click.
77
+ useEffect(() => {
78
+ document.addEventListener('click', handleClickOutside)
79
+ return () => {
60
80
  document.removeEventListener('click', handleClickOutside)
61
81
  }
62
- }
82
+ }, [])
63
83
 
64
84
  const handleClickBookmark = () => {
65
85
  if (showModal) {
66
86
  store.showModal = 0
67
87
  }
68
- if (pinned) {
69
- return
70
- }
71
- if (openedSideBar === 'bookmarks') {
72
- // Remove listener when closing
73
- document.removeEventListener('click', handleClickOutside)
74
- store.setOpenedSideBar('')
75
- } else {
76
- // Add listener when opening, with slight delay to avoid conflict with this click
77
- setTimeout(() => {
78
- document.addEventListener('click', handleClickOutside)
79
- }, 0)
80
- store.setOpenedSideBar('bookmarks')
81
- }
88
+ store.openLeftSidePanel()
82
89
  }
83
90
 
84
91
  const handleShowUpgrade = () => {
@@ -81,7 +81,7 @@ export default function LeftSidebarIcons (props) {
81
81
  >
82
82
  <BookOutlined
83
83
  onClick={handleClickBookmark}
84
- className='font20 iblock control-icon'
84
+ className='font20 iblock control-icon bookmark-sidebar-toggle'
85
85
  />
86
86
  </SideIcon>
87
87
  ),
@@ -23,6 +23,8 @@
23
23
  z-index 100
24
24
  bottom 0
25
25
  background var(--main-dark)
26
+ display flex
27
+ flex-direction column
26
28
 
27
29
  .item-list
28
30
  padding-right 0
@@ -105,6 +107,20 @@
105
107
  z-index 200
106
108
  width 0
107
109
 
110
+ // icon bar: fills sidebar height, scrolls when overflow on small screens,
111
+ // scrollbar hidden but scrolling still works
112
+ .sidebar-bar
113
+ flex 1
114
+ min-height 0
115
+ overflow-y auto
116
+ overflow-x hidden
117
+ scrollbar-width none // Firefox
118
+ -ms-overflow-style none // IE/Edge
119
+ &::-webkit-scrollbar
120
+ width 0
121
+ height 0
122
+ display none
123
+
108
124
  // mobile: icon bar hidden — hide its buttons
109
125
  .sidebar.collapsed
110
126
  .sidebar-bar
@@ -29,7 +29,9 @@ import {
29
29
  PlayCircleFilled,
30
30
  StopOutlined,
31
31
  DownloadOutlined,
32
- SwapOutlined
32
+ SwapOutlined,
33
+ PushpinOutlined,
34
+ PushpinFilled
33
35
  } from '@ant-design/icons'
34
36
  import IconHolder from './icon-holder'
35
37
 
@@ -62,5 +64,7 @@ export default {
62
64
  PlayCircleFilled,
63
65
  StopOutlined,
64
66
  DownloadOutlined,
65
- SwapOutlined
67
+ SwapOutlined,
68
+ PushpinOutlined,
69
+ PushpinFilled
66
70
  }
@@ -122,11 +122,23 @@ export default class Tabs extends Component {
122
122
  )
123
123
  }
124
124
 
125
+ // desktop tabs bar renders pinned tabs first: stable partition that keeps
126
+ // the original relative order inside the pinned and unpinned groups.
127
+ // store.tabs order itself is untouched by pinning.
128
+ getSortedTabs = () => {
129
+ const { tabs = [] } = this.props
130
+ return [
131
+ ...tabs.filter(t => t.isPinned),
132
+ ...tabs.filter(t => !t.isPinned)
133
+ ]
134
+ }
135
+
125
136
  adjustScroll = () => {
126
137
  if (this.props.isMobile || !this.domRef.current) {
127
138
  return
128
139
  }
129
- const { tabs, currentBatchTabId, batch } = this.props
140
+ const { currentBatchTabId, batch } = this.props
141
+ const tabs = this.getSortedTabs()
130
142
  const index = tabs.findIndex(t => t.id === currentBatchTabId)
131
143
  const tabsDomWith = Array.from(
132
144
  document.querySelectorAll(`.v${batch + 1} .tab`)
@@ -209,7 +221,7 @@ export default class Tabs extends Component {
209
221
  }
210
222
 
211
223
  renderExtra () {
212
- const items = this.props.tabs.map((t, i) => {
224
+ const items = this.getSortedTabs().map((t, i) => {
213
225
  return {
214
226
  key: i + '##' + t.id,
215
227
  label: (
@@ -261,7 +273,8 @@ export default class Tabs extends Component {
261
273
  }
262
274
 
263
275
  renderContentInner () {
264
- const { tabs = [], width, config } = this.props
276
+ const { width, config } = this.props
277
+ const tabs = this.getSortedTabs()
265
278
  const len = tabs.length
266
279
  const tabsWidthAll = tabMargin * len + 10 + this.tabsWidth()
267
280
  const { overflow } = this.state
@@ -9,7 +9,8 @@ import {
9
9
  CloseOutlined,
10
10
  Loading3QuartersOutlined,
11
11
  BorderlessTableOutlined,
12
- LockOutlined
12
+ LockOutlined,
13
+ PushpinFilled
13
14
  } from '@ant-design/icons'
14
15
  import {
15
16
  Tooltip,
@@ -239,8 +240,15 @@ class Tab extends Component {
239
240
  }
240
241
  }
241
242
 
242
- handleReloadTab = async () => {
243
- window.store.reloadTab(this.props.tab.id)
243
+ handleReloadTab = async (e) => {
244
+ // stop propagation so the parent tab-title click doesn't re-activate
245
+ // this tab by its now-stale (removed) id after reloadTab replaces it
246
+ e && e.stopPropagation()
247
+ const { tab, batch } = this.props
248
+ const newTab = window.store.reloadTab(tab.id)
249
+ if (newTab) {
250
+ window.store.clickTab(newTab.id, batch)
251
+ }
244
252
  }
245
253
 
246
254
  handleReloadAll = () => {
@@ -309,8 +317,13 @@ class Tab extends Component {
309
317
  window.store.closeTabsRight(this.props.tab.id)
310
318
  }
311
319
 
320
+ togglePin = () => {
321
+ const { tab } = this.props
322
+ window.store.pinTab(tab.id, !tab.isPinned)
323
+ }
324
+
312
325
  renderContext = () => {
313
- const { tabs, tabIndex } = this.props
326
+ const { tabs, tabIndex, tab } = this.props
314
327
  const len = tabs.length
315
328
  const index = tabIndex
316
329
  const noRight = index >= len - 1
@@ -359,6 +372,13 @@ class Tab extends Component {
359
372
  icon: <iconsMap.EditOutlined />,
360
373
  label: e('rename')
361
374
  },
375
+ !window.store.isMobile && {
376
+ key: 'togglePin',
377
+ icon: tab.isPinned
378
+ ? <iconsMap.PushpinFilled />
379
+ : <iconsMap.PushpinOutlined />,
380
+ label: tab.isPinned ? e('unpin') : e('pin')
381
+ },
362
382
  {
363
383
  key: 'handleReloadTab',
364
384
  icon: <iconsMap.ReloadOutlined />,
@@ -542,6 +562,7 @@ class Tab extends Component {
542
562
  }
543
563
  <span className='tab-title'>
544
564
  <span className='iblock mg1r tab-count' style={styleTag}>{tabCount}</span>
565
+ {tab.isPinned && <PushpinFilled className='tab-pin' />}
545
566
  <span className='mg1r'>{title}</span>
546
567
  </span>
547
568
  </div>
@@ -135,6 +135,10 @@
135
135
  font-size 10px
136
136
  &:hover
137
137
  background var(--error)
138
+ // touch screens have no hover — always reveal the close icon
139
+ .is-touch-device
140
+ .tab-close
141
+ display block
138
142
 
139
143
  .tabs-add-btn
140
144
  display inline-block
@@ -225,6 +229,10 @@
225
229
  height 20px
226
230
  line-height 21px
227
231
 
232
+ .tab-pin
233
+ margin-right 3px
234
+ font-size 11px
235
+
228
236
  .ssh-tunnel-list-wrapper
229
237
  max-height 300px
230
238
  overflow-y auto
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { lazy, Suspense, useEffect, useState } from 'react'
40
+ import { auto } from 'manate/react'
40
41
 
41
42
  const ShortcutBar = lazy(() => import('./shortcut-bar'))
42
43
 
@@ -52,38 +53,37 @@ export const KEYBOARD_MIN = 120
52
53
  // keyboard from a mouse-driven window resize on touch-capable desktops.
53
54
  const TOUCH_GRACE_MS = 3000
54
55
 
55
- // kept as a coarse capability fallback for engines without visualViewport.
56
- // Modern Chromium (Electron) always exposes window.visualViewport, so this is
57
- // effectively only reached on very old runtimes — where remote-control
58
- // misreporting is not a concern.
59
- export function isTouchDevice () {
60
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
61
- return false
62
- }
63
- const coarse = typeof window.matchMedia === 'function' &&
64
- window.matchMedia('(pointer: coarse)').matches
65
- const hasTouch = (navigator.maxTouchPoints || 0) > 0 ||
66
- ('ontouchstart' in window)
67
- return Boolean(coarse || hasTouch)
68
- }
69
-
70
- export default function ShortcutBarEntry (props) {
71
- // mount the lazy bar only after a soft keyboard has been seen at least once,
72
- // so its code (and antd/icon deps) stays out of the main bundle on devices
73
- // that never show one (desktops, remote-control clients mis-reported as
74
- // touch, etc.).
75
- //
56
+ function ShortcutBarEntry (props) {
57
+ const { store } = props
76
58
  // opt out entirely when the user has disabled the shortcut bar in common
77
59
  // settings — bail before any listener is attached or component imported.
78
- const { store } = props
79
60
  const disabled = store.config.disableShortcutBar
61
+ // follow the store's live touch state (flipped by main.jsx from real
62
+ // pointer input — mouse → false, touch/pen → true) instead of probing screen
63
+ // capability, which only says the screen *can* be touched, not whether the
64
+ // user is actually using touch, so the bar reacts when a touch-capable
65
+ // machine starts being used by touch.
66
+ const isTouch = store.isTouchDevice
67
+ // very old runtimes expose no visualViewport — the lazy "wait for a real
68
+ // keyboard" signal below can never fire there, so fall back straight to the
69
+ // touch state.
70
+ const noVisualViewport = typeof window === 'undefined' || !window.visualViewport
80
71
  const [keyboardSeen, setKeyboardSeen] = useState(() => {
81
- if (disabled || typeof window === 'undefined' || !window.visualViewport) {
82
- return disabled ? false : isTouchDevice()
72
+ if (noVisualViewport) {
73
+ return !disabled && isTouch
83
74
  }
84
75
  return false
85
76
  })
86
77
 
78
+ // keep the no-visualViewport fallback current as the store's touch state
79
+ // flips at runtime; on modern engines keyboardSeen is driven by the resize
80
+ // listener below and this never runs.
81
+ useEffect(() => {
82
+ if (noVisualViewport) {
83
+ setKeyboardSeen(!disabled && isTouch)
84
+ }
85
+ }, [disabled, isTouch, noVisualViewport])
86
+
87
87
  useEffect(() => {
88
88
  const vv = window.visualViewport
89
89
  if (!vv) {
@@ -171,3 +171,5 @@ export default function ShortcutBarEntry (props) {
171
171
  </Suspense>
172
172
  )
173
173
  }
174
+
175
+ export default auto(ShortcutBarEntry)
@@ -40,6 +40,9 @@ export default function ThemeForm (props) {
40
40
  action.current = 'saveOnly'
41
41
  form.submit()
42
42
  }
43
+ function applyOnly () {
44
+ props.store.setTheme(props.formData.id)
45
+ }
43
46
  // A function to validate the input text
44
47
  async function validateInput (_, value) {
45
48
  const input = value
@@ -189,6 +192,25 @@ export default function ThemeForm (props) {
189
192
  if (!id) {
190
193
  return null
191
194
  }
195
+ if (readonly) {
196
+ return (
197
+ <FormItem>
198
+ <Button
199
+ type='primary'
200
+ className='mg1r mg1b'
201
+ onClick={applyOnly}
202
+ >
203
+ {e('apply')}
204
+ </Button>
205
+ <Button
206
+ type='dashed'
207
+ onClick={exporter}
208
+ >
209
+ {e('export')}
210
+ </Button>
211
+ </FormItem>
212
+ )
213
+ }
192
214
  return (
193
215
  <FormItem>
194
216
  <Button
@@ -5,8 +5,7 @@
5
5
  import { PureComponent } from 'react'
6
6
  import {
7
7
  ExportOutlined,
8
- ImportOutlined,
9
- EditOutlined
8
+ ImportOutlined
10
9
  } from '@ant-design/icons'
11
10
  import { Button, Space } from 'antd'
12
11
  import Upload from '../common/upload'
@@ -19,21 +18,6 @@ export default class BookmarkTransport extends PureComponent {
19
18
 
20
19
  handleDownload = () => {}
21
20
 
22
- handleToggleEdit = () => {
23
- window.store.bookmarkSelectMode = true
24
- }
25
-
26
- renderEdit () {
27
- return (
28
- <Button
29
- icon={<EditOutlined />}
30
- onClick={this.handleToggleEdit}
31
- title={e('edit')}
32
- key='edit-and-del'
33
- />
34
- )
35
- }
36
-
37
21
  renderExport () {
38
22
  return (
39
23
  <Button
@@ -63,19 +47,11 @@ export default class BookmarkTransport extends PureComponent {
63
47
  }
64
48
 
65
49
  render () {
66
- const edit = this.renderEdit()
67
- if (edit === null) {
68
- return (
69
- <Space.Compact>
70
- {this.renderExport()}
71
- {this.renderImport()}
72
- </Space.Compact>
73
- )
74
- }
75
- return [
76
- edit,
77
- this.renderExport(),
78
- this.renderImport()
79
- ]
50
+ return (
51
+ <Space.Compact>
52
+ {this.renderExport()}
53
+ {this.renderImport()}
54
+ </Space.Compact>
55
+ )
80
56
  }
81
57
  }
@@ -187,3 +187,16 @@
187
187
  &.is-active
188
188
  opacity 1
189
189
  pointer-events auto
190
+ // touch screens have no hover — always reveal the hover-only action icons
191
+ .is-touch-device
192
+ .tree-list-row
193
+ .tree-item-title
194
+ padding-right 60px
195
+ .tree-item-op-wrap
196
+ opacity 1
197
+ pointer-events auto
198
+ .add-menu-wrap
199
+ .sidebar-panel
200
+ .tree-list-row
201
+ .tree-item-title
202
+ padding-right 20px
@@ -29,7 +29,6 @@ import {
29
29
  import * as ls from '../common/safe-local-storage'
30
30
  import { exclude } from 'manate'
31
31
  import initSettingItem from '../common/init-setting-item'
32
- import { getRandomDefaultColor } from '../common/rand-hex-color'
33
32
 
34
33
  const e = window.translate
35
34
 
@@ -39,7 +38,7 @@ function getDefaultBookmarkGroups (bookmarks) {
39
38
  title: e(defaultBookmarkGroupId),
40
39
  id: defaultBookmarkGroupId,
41
40
  bookmarkIds: bookmarks.map(d => d.id),
42
- color: getRandomDefaultColor()
41
+ color: '#0088cc'
43
42
  })
44
43
  ]
45
44
  }
@@ -135,6 +134,8 @@ export default () => {
135
134
  settingTab: settingMap.bookmarks, // setting tab
136
135
  bookmarkId: undefined,
137
136
  showModal: 0,
137
+ // mobile settings view: 'menu' (left col list, default) or 'content' (right col)
138
+ settingMobileView: 'menu',
138
139
 
139
140
  // setting sync related
140
141
  autoSyncReady: false,
@@ -214,6 +215,11 @@ export default () => {
214
215
  isMaximized: window.pre.runSync('isMaximized'),
215
216
  hasNodePty: window.pre.runSync('nodePtyCheck'),
216
217
  isMobile: window.innerWidth <= mobileBreakpoint,
218
+ // reflects the input device actually in use, not screen capability — a
219
+ // capability probe only proves the screen can be touched, not whether the
220
+ // user operates it by touch. Seeded false and left to main.jsx, which sets
221
+ // mouse → false / touch → true on the first real pointer event.
222
+ isTouchDevice: false,
217
223
  fullscreen: false,
218
224
  tabsHeight: 36,
219
225
 
@@ -40,6 +40,7 @@ export default Store => {
40
40
  })
41
41
  store.setSettingItem(item)
42
42
  store.openSettingModal()
43
+ store.settingMobileView = 'content'
43
44
  }
44
45
 
45
46
  Store.prototype.handleOpenQuickCommandsSetting = function () {
@@ -136,12 +137,20 @@ export default Store => {
136
137
  )
137
138
  }
138
139
  store.showModal = modals.setting
140
+ // mobile always lands on the left col menu first
141
+ store.settingMobileView = 'menu'
142
+ }
143
+
144
+ Store.prototype.backToSettingMenu = function () {
145
+ window.store.settingMobileView = 'menu'
139
146
  }
140
147
 
141
148
  Store.prototype.hideSettingModal = function () {
142
149
  const { store } = window
143
150
  store.showModal = modals.hide
144
151
  store.setSettingItem({})
152
+ // reset so a later open starts from the menu on mobile
153
+ store.settingMobileView = 'menu'
145
154
  }
146
155
 
147
156
  Store.prototype.loadFontList = async function () {
@@ -161,5 +170,7 @@ export default Store => {
161
170
  settingTab
162
171
  })
163
172
  store.setSettingItem(item)
173
+ // a new tab means a new menu to pick from on mobile
174
+ store.settingMobileView = 'menu'
164
175
  }
165
176
  }
@@ -19,6 +19,18 @@ export default Store => {
19
19
  store.expandedKeys = []
20
20
  }
21
21
 
22
+ // Single entry point for opening/closing the left side panel from the
23
+ // sidebar icons. The panel is a toggle: call again to close. It also
24
+ // re-centers the pin behaviour so every open/close transition goes
25
+ // through this one method.
26
+ Store.prototype.openLeftSidePanel = function (field = 'bookmarks') {
27
+ const { store } = window
28
+ if (store.pinned) {
29
+ return
30
+ }
31
+ store.setOpenedSideBar(store.openedSideBar ? '' : field)
32
+ }
33
+
22
34
  Store.prototype.handlePin = function (pinned) {
23
35
  const { store } = window
24
36
  const current = !store.pinned
@@ -52,6 +52,46 @@ function isJSON (str = '') {
52
52
  return str.startsWith('[')
53
53
  }
54
54
 
55
+ /**
56
+ * Names of data types that are actually encrypted on upload when a
57
+ * sync password is set. Only these types trigger the fail-closed
58
+ * check on download.
59
+ */
60
+ const encryptedDataNames = new Set([
61
+ settingMap.bookmarks,
62
+ settingMap.profiles
63
+ ])
64
+
65
+ /**
66
+ * Decrypt sync data with fail-closed semantics.
67
+ *
68
+ * Only bookmarks and profiles are encrypted on upload when a sync
69
+ * password is configured. For those two types, plaintext JSON
70
+ * (content starting with '[') must never be silently accepted when a
71
+ * password is set — it could be attacker-injected data from a
72
+ * compromised sync backend. Instead, we reject it so the user is
73
+ * alerted rather than silently importing untrusted data.
74
+ *
75
+ * For all other data types, plaintext JSON is expected even when a
76
+ * password is set, so we accept it without error.
77
+ */
78
+ async function decryptSyncData (str, pass, dataName) {
79
+ if (!str) {
80
+ return str
81
+ }
82
+ if (isJSON(str)) {
83
+ if (pass && encryptedDataNames.has(dataName)) {
84
+ throw new Error(
85
+ 'Sync data is plaintext but a sync password is configured. ' +
86
+ 'This may indicate the data was tampered with or the sync backend is compromised. ' +
87
+ 'Aborting sync download for safety.'
88
+ )
89
+ }
90
+ return str
91
+ }
92
+ return window.pre.runGlobalAsync('decryptAsync', str, pass)
93
+ }
94
+
55
95
  async function fetchData (type, func, args, token, proxy) {
56
96
  const data = {
57
97
  type,
@@ -337,9 +377,7 @@ export default (Store) => {
337
377
  let serverItems = []
338
378
  if (serverStr) {
339
379
  try {
340
- if (!isJSON(serverStr)) {
341
- serverStr = await window.pre.runGlobalAsync('decryptAsync', serverStr, pass)
342
- }
380
+ serverStr = await decryptSyncData(serverStr, pass, n)
343
381
  serverItems = JSON.parse(serverStr)
344
382
  } catch (e) {
345
383
  console.error(`Failed to parse server data for ${n}:`, e)
@@ -520,9 +558,7 @@ export default (Store) => {
520
558
  continue
521
559
  }
522
560
  }
523
- if (!isJSON(str)) {
524
- str = await window.pre.runGlobalAsync('decryptAsync', str, pass)
525
- }
561
+ str = await decryptSyncData(str, pass, n)
526
562
  let arr = JSON.parse(str)
527
563
  if (n === settingMap.terminalThemes) {
528
564
  arr = store.fixThemes(arr)
@@ -585,9 +621,7 @@ export default (Store) => {
585
621
  continue
586
622
  }
587
623
  }
588
- if (!isJSON(str)) {
589
- str = await window.pre.runGlobalAsync('decryptAsync', str, pass)
590
- }
624
+ str = await decryptSyncData(str, pass, n)
591
625
  let arr = JSON.parse(
592
626
  str
593
627
  )
@@ -60,6 +60,8 @@ export default Store => {
60
60
  })
61
61
  store.setSettingItem(getInitItem([], settingMap.bookmarks))
62
62
  store.openSettingModal()
63
+ // on mobile, jump straight to the new bookmark form instead of the menu
64
+ store.settingMobileView = 'content'
63
65
  }
64
66
 
65
67
  Store.prototype.onNewSshAI = function () {
@@ -127,6 +127,8 @@ export default Store => {
127
127
  if (store[batchProp] === tabId) {
128
128
  store[batchProp] = newTab.id
129
129
  }
130
+
131
+ return newTab
130
132
  }
131
133
 
132
134
  Store.prototype.reloadAllTabs = function () {
@@ -168,6 +170,17 @@ export default Store => {
168
170
  store[`activeTabId${sourceTab.batch}`] = duplicatedTab.id
169
171
  }
170
172
 
173
+ // only toggles the flag: the tabs bar renders pinned tabs first,
174
+ // store.tabs order itself is never changed by pinning
175
+ Store.prototype.pinTab = function (tabId, isPinned = true) {
176
+ const { store } = window
177
+ const tab = store.tabs.find(t => t.id === tabId)
178
+ if (!tab || Boolean(tab.isPinned) === isPinned) {
179
+ return
180
+ }
181
+ tab.isPinned = isPinned
182
+ }
183
+
171
184
  Store.prototype.closeOtherTabs = function (id) {
172
185
  const { store } = window
173
186
  const { tabs } = store
@@ -534,7 +547,8 @@ export default Store => {
534
547
  'sshSftpSplitView',
535
548
  'sshTunnelResults',
536
549
  'displayRaw',
537
- 'autoReConnect'
550
+ 'autoReConnect',
551
+ 'isPinned'
538
552
  ]
539
553
  const { history } = store
540
554
  const index = history.filter(d => d.id && d.tab).findIndex(d => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.3.5",
3
+ "version": "5.3.26",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",