@electerm/electerm-react 5.5.9 → 5.5.15

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.
@@ -7,6 +7,18 @@ import { chooseSaveDirectory } from './choose-save-folder'
7
7
  import { DownloadOutlined } from '@ant-design/icons'
8
8
 
9
9
  export default async function download (filename, text) {
10
+ // iOS WKWebView: sandbox targets are invisible to the Files app and
11
+ // blob-anchor downloads are a no-op — save to Documents (visible in
12
+ // Files) via the native bridge instead (see
13
+ // web-components/native-file-save.js).
14
+ if (window.et && window.et.isWebApp && window.et.saveTextNative) {
15
+ try {
16
+ await window.et.saveTextNative(filename, text)
17
+ return
18
+ } catch (e) {
19
+ console.log('native save failed, falling back to sandbox save:', e)
20
+ }
21
+ }
10
22
  const opts = window.et.isWebApp
11
23
  ? { filename, content: text }
12
24
  : undefined
@@ -13,24 +13,30 @@ export const isWslDistroRoot = (path) => {
13
13
  }
14
14
 
15
15
  export default function resolve (basePath, nameOrDot) {
16
+ // Callers can hand this a value that is not a path yet (a session path that
17
+ // is still loading, a missing tab option, ...). Reading `.includes` off it
18
+ // used to throw and take the whole renderer down with a white screen, so
19
+ // anything that is not a string is treated as an empty path here.
20
+ const base = typeof basePath === 'string' ? basePath : ''
21
+ const name = typeof nameOrDot === 'string' ? nameOrDot : ''
16
22
  const hasWinDrive = (path) => /^[a-zA-Z]:/.test(path)
17
- const isWin = basePath.includes('\\') || nameOrDot.includes('\\') || hasWinDrive(basePath) || hasWinDrive(nameOrDot)
23
+ const isWin = base.includes('\\') || name.includes('\\') || hasWinDrive(base) || hasWinDrive(name)
18
24
  const sep = isWin ? '\\' : '/'
19
- if (/^[a-zA-Z]:/.test(nameOrDot)) {
20
- return nameOrDot.replace(/^\//, '').replace(/\//g, sep)
25
+ if (/^[a-zA-Z]:/.test(name)) {
26
+ return name.replace(/^\//, '').replace(/\//g, sep)
21
27
  }
22
- if (nameOrDot.startsWith('/')) {
23
- return nameOrDot.replace(/\\/g, sep)
28
+ if (name.startsWith('/')) {
29
+ return name.replace(/\\/g, sep)
24
30
  }
25
- if (nameOrDot.startsWith('\\\\')) {
26
- return nameOrDot
31
+ if (name.startsWith('\\\\')) {
32
+ return name
27
33
  }
28
- if (nameOrDot === '..') {
29
- if (isWslDistroRoot(basePath)) {
34
+ if (name === '..') {
35
+ if (isWslDistroRoot(base)) {
30
36
  return '/'
31
37
  }
32
- const baseEndsWithSep = basePath.endsWith(sep)
33
- const parts = basePath.split(sep)
38
+ const baseEndsWithSep = base.endsWith(sep)
39
+ const parts = base.split(sep)
34
40
  if (parts.length > 1) {
35
41
  parts.pop()
36
42
  if (isWin && parts.length === 1) {
@@ -40,10 +46,10 @@ export default function resolve (basePath, nameOrDot) {
40
46
  }
41
47
  return '/'
42
48
  }
43
- if (isWslDistroRoot(basePath) && !basePath.endsWith(sep)) {
44
- return basePath + sep + nameOrDot
49
+ if (isWslDistroRoot(base) && !base.endsWith(sep)) {
50
+ return base + sep + name
45
51
  }
46
- const result = basePath.endsWith(sep) ? basePath + nameOrDot : basePath + sep + nameOrDot
52
+ const result = base.endsWith(sep) ? base + name : base + sep + name
47
53
  return isWin && result.length === 3 && result.endsWith(':\\') ? '/' : result
48
54
  }
49
55
 
@@ -30,7 +30,7 @@ export default function AiHistoryItem (props) {
30
30
  onClick={() => onSelect(item)}
31
31
  className='pointer'
32
32
  >
33
- {displayItem}
33
+ <span className='ai-history-label'>{displayItem}</span>
34
34
  </Tag>
35
35
  )
36
36
 
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'
5
5
  import { Space } from 'antd'
6
6
  import { safeGetItemJSON, safeSetItemJSON } from '../../common/safe-local-storage'
7
7
  import AiHistoryItem from './ai-history-item'
8
+ import './ai-history.styl'
8
9
 
9
10
  const MAX_HISTORY = 20
10
11
 
@@ -0,0 +1,23 @@
1
+ // AI history items (ai-history.jsx / ai-history-item.jsx)
2
+ //
3
+ // Each entry is an antd Tag: nowrap, no max-width, and the text is a user
4
+ // written description up to 50 chars long. Space renders an inline-flex box,
5
+ // which shrink-to-fits its widest *unwrappable* child — so one long entry
6
+ // pushes the whole panel into horizontal scrolling instead of using the width
7
+ // it already has. The label gets its own ellipsis box so the tag can shrink to
8
+ // the available width while the close icon stays reachable.
9
+ .ai-bookmark-history
10
+ .ant-space
11
+ display flex
12
+ .ant-space-item
13
+ min-width 0
14
+ .ant-tag
15
+ max-width 100%
16
+
17
+ .ai-history-label
18
+ display inline-block
19
+ // leave room for the tag close icon
20
+ max-width calc(100% - 16px)
21
+ overflow hidden
22
+ text-overflow ellipsis
23
+ vertical-align middle
@@ -10,6 +10,9 @@
10
10
  width 92px
11
11
  .color-picker-box
12
12
  width 420px
13
+ // the popover is portaled to the body, a 420px box simply falls off a phone
14
+ // screen (measured: the swatch list and the antd picker still fit at 280px)
15
+ max-width calc(100vw - 40px)
13
16
 
14
17
  .color-picker-defaults
15
18
  width 200px
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pointer coordinate mapping for remote desktop surfaces.
3
+ *
4
+ * The remote surface is rendered with `object-fit: contain`, so the element
5
+ * box and the drawn surface rarely match: one axis gets letterbox margins.
6
+ * Deriving the scale from the element box alone therefore sends the pointer
7
+ * to the wrong place (the classic "only the top-left region reacts" bug), and
8
+ * each session used to re-implement that math. Shared here instead.
9
+ */
10
+
11
+ function clamp (v, max) {
12
+ return Math.max(0, Math.min(v, max))
13
+ }
14
+
15
+ /**
16
+ * visible remote surface rect inside a `object-fit: contain` element
17
+ */
18
+ export function containRect (el, sourceWidth, sourceHeight) {
19
+ if (!el || !sourceWidth || !sourceHeight) {
20
+ return null
21
+ }
22
+ const rect = el.getBoundingClientRect()
23
+ if (!rect.width || !rect.height) {
24
+ return null
25
+ }
26
+ const scale = Math.min(
27
+ rect.width / sourceWidth,
28
+ rect.height / sourceHeight
29
+ )
30
+ return {
31
+ scale,
32
+ left: rect.left + (rect.width - sourceWidth * scale) / 2,
33
+ top: rect.top + (rect.height - sourceHeight * scale) / 2
34
+ }
35
+ }
36
+
37
+ /**
38
+ * pointer event -> remote framebuffer pixel, clamped to the surface
39
+ */
40
+ export function eventToRemotePos (e, el, sourceWidth, sourceHeight) {
41
+ const rect = containRect(el, sourceWidth, sourceHeight)
42
+ if (!rect || !rect.scale) {
43
+ return { x: 0, y: 0 }
44
+ }
45
+ return {
46
+ x: clamp(Math.round((e.clientX - rect.left) / rect.scale), sourceWidth - 1),
47
+ y: clamp(Math.round((e.clientY - rect.top) / rect.scale), sourceHeight - 1)
48
+ }
49
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared shell for remote desktop sessions (RDP / VNC / SPICE).
3
+ *
4
+ * Layout is a plain flex column:
5
+ *
6
+ * [ control bar ] auto height, wraps when the window is narrow
7
+ * [ viewport ] flex: 1, holds the remote surface
8
+ *
9
+ * The control bar owns a real height instead of being positioned over the
10
+ * surface, so the old "- 10 / - 80" guesses are gone and the surface can
11
+ * never be covered by the bar.
12
+ *
13
+ * The viewport is measured with a ResizeObserver rather than derived from
14
+ * the layout props, because in fullscreen the session is re-parented to the
15
+ * whole window by term-fullscreen.styl and those props are stale. Measured
16
+ * size is what lets the remote surface scale up to fill the screen.
17
+ */
18
+ import { PureComponent, createRef } from 'react'
19
+ import { Spin } from 'antd'
20
+ import classnames from 'classnames'
21
+ import './remote-session-shell.styl'
22
+
23
+ export default class RemoteSessionShell extends PureComponent {
24
+ constructor (props) {
25
+ super(props)
26
+ this.viewRef = createRef()
27
+ this.state = {
28
+ width: 0,
29
+ height: 0
30
+ }
31
+ }
32
+
33
+ componentDidMount () {
34
+ this.observeViewport()
35
+ }
36
+
37
+ componentDidUpdate () {
38
+ // the viewport node is stable, this only re-observes if React swapped it
39
+ this.observeViewport()
40
+ }
41
+
42
+ componentWillUnmount () {
43
+ this.unobserveViewport()
44
+ }
45
+
46
+ observeViewport = () => {
47
+ const el = this.viewRef.current
48
+ if (!el || this.viewEl === el) {
49
+ return
50
+ }
51
+ this.unobserveViewport()
52
+ this.viewEl = el
53
+ this.observer = new ResizeObserver(this.measureViewport)
54
+ this.observer.observe(el)
55
+ this.measureViewport()
56
+ }
57
+
58
+ unobserveViewport = () => {
59
+ if (this.observer) {
60
+ this.observer.disconnect()
61
+ this.observer = null
62
+ }
63
+ this.viewEl = null
64
+ }
65
+
66
+ measureViewport = () => {
67
+ const el = this.viewRef.current
68
+ if (!el) {
69
+ return
70
+ }
71
+ const { clientWidth: width, clientHeight: height } = el
72
+ const { width: prevWidth, height: prevHeight } = this.state
73
+ if (width === prevWidth && height === prevHeight) {
74
+ return
75
+ }
76
+ this.setState({
77
+ width,
78
+ height
79
+ })
80
+ this.props.onViewportResize?.(width, height)
81
+ }
82
+
83
+ // measured viewport box, 0 until the first layout pass
84
+ getViewportSize = () => {
85
+ const { width, height } = this.state
86
+ return {
87
+ width,
88
+ height
89
+ }
90
+ }
91
+
92
+ render () {
93
+ const {
94
+ loading,
95
+ fit,
96
+ wrapClassName,
97
+ controlLeft,
98
+ controlRight,
99
+ floatControl,
100
+ overlay,
101
+ children
102
+ } = this.props
103
+ const cls = classnames(
104
+ 'remote-session-wrap',
105
+ 'session-v-wrap',
106
+ wrapClassName,
107
+ {
108
+ fit: !!fit
109
+ }
110
+ )
111
+ return (
112
+ <Spin
113
+ spinning={!!loading}
114
+ className='remote-session-spin'
115
+ >
116
+ <div className={cls}>
117
+ <div className='remote-session-control session-v-info'>
118
+ <div className='remote-session-control-left'>
119
+ {controlLeft}
120
+ </div>
121
+ <div className='remote-session-control-right'>
122
+ {controlRight}
123
+ </div>
124
+ </div>
125
+ <div
126
+ className='remote-session-viewport'
127
+ ref={this.viewRef}
128
+ >
129
+ {children}
130
+ </div>
131
+ {overlay}
132
+ {floatControl}
133
+ </div>
134
+ </Spin>
135
+ )
136
+ }
137
+ }
@@ -0,0 +1,80 @@
1
+ // shared remote session (rdp/vnc/spice) shell, see remote-session-shell.jsx
2
+
3
+ .remote-session-wrap
4
+ display flex
5
+ flex-direction column
6
+ width 100%
7
+ height 100%
8
+ min-height 0
9
+ background var(--main)
10
+
11
+ // antd Spin root carries the className, both levels must pass the height on
12
+ // or the session inside has nothing to resolve `height: 100%` against
13
+ .remote-session-spin
14
+ height 100%
15
+ > .ant-spin-container
16
+ height 100%
17
+
18
+ // control bar: one row while there is room, wraps to more rows when the
19
+ // window is narrow, so no action icon ever gets pushed out of reach
20
+ .remote-session-control
21
+ position relative
22
+ flex 0 0 auto
23
+ display flex
24
+ flex-wrap wrap
25
+ align-items center
26
+ justify-content space-between
27
+ row-gap 4px
28
+ padding 4px 8px
29
+ z-index 300
30
+
31
+ .remote-session-control-left
32
+ .remote-session-control-right
33
+ display flex
34
+ flex-wrap wrap
35
+ align-items center
36
+ row-gap 4px
37
+ min-width 0
38
+
39
+ .remote-session-control-right
40
+ margin-left auto
41
+
42
+ .remote-session-viewport
43
+ position relative
44
+ flex 1 1 auto
45
+ display flex
46
+ min-width 0
47
+ min-height 0
48
+ overflow auto
49
+ // scrolled when fit is off, i.e. the remote surface at its own size
50
+ &::-webkit-scrollbar
51
+ width 16px
52
+ height 16px
53
+ background var(--main-darker)
54
+ &::-webkit-scrollbar-track
55
+ background var(--main-darker)
56
+ box-shadow inset 0 0 5px var(--main-darker)
57
+ &::-webkit-scrollbar-thumb
58
+ background var(--primary)
59
+ border-radius 0
60
+ &::-webkit-scrollbar-corner
61
+ background var(--main-darker)
62
+ > *
63
+ flex 0 0 auto
64
+ margin auto
65
+
66
+ // fit: scale the remote surface to the viewport, keeping the source aspect
67
+ // ratio, so at most one axis keeps empty margin and fullscreen has as little
68
+ // empty space as the ratio allows
69
+ .remote-session-wrap.fit
70
+ .remote-session-viewport
71
+ overflow hidden
72
+ > *
73
+ width 100%
74
+ height 100%
75
+ margin 0
76
+
77
+ // surface that should always fill the viewport (noVNC sizes its canvas itself)
78
+ .remote-session-fill
79
+ width 100%
80
+ height 100%
@@ -0,0 +1,89 @@
1
+ /**
2
+ * antd Tabs wrapper that collapses to a single tab selector when narrow.
3
+ *
4
+ * A row of tabs is the first thing to break down on a phone: antd keeps them
5
+ * on one line and hides the overflow behind its own dropdown, so the active
6
+ * tab ends up buried. On mobile this wrapper renders only the active tab with
7
+ * a down arrow on the right instead — click it to pick another tab from a
8
+ * dropdown.
9
+ *
10
+ * - desktop: every prop is forwarded to antd Tabs untouched, so it is a
11
+ * drop-in replacement (`items` / `activeKey` / `onChange` / `type` / `size`
12
+ * / `destroyOnHidden` ... all behave as before)
13
+ * - mobile: renders the active tab only; the tabs come from the same `items`
14
+ * array, so callers don't have to describe their tabs twice. The caller's
15
+ * `className` goes on the wrapper element (the button carries the
16
+ * `responsive-tabs-mobile` class), so a class like `.setting-tabs` keeps
17
+ * positioning the selector exactly where it positioned the tab bar.
18
+ *
19
+ * Which breakpoint counts as mobile is decided by the caller via `isMobile`
20
+ * (the setting panel collapses at its own 800px drill-down breakpoint, the
21
+ * theme form at the global 600px one). When omitted it follows the global
22
+ * `store.isMobile`.
23
+ */
24
+
25
+ import { auto } from 'manate/react'
26
+ import { Dropdown, Tabs } from 'antd'
27
+ import { DownOutlined } from '@ant-design/icons'
28
+ import classNames from 'classnames'
29
+ import './responsive-tabs.styl'
30
+
31
+ export default auto(function ResponsiveTabs (props) {
32
+ const {
33
+ isMobile,
34
+ items = [],
35
+ activeKey,
36
+ onChange,
37
+ className,
38
+ ...rest
39
+ } = props
40
+ const mobile = isMobile === undefined
41
+ ? window.store.isMobile
42
+ : isMobile
43
+ if (!mobile) {
44
+ return (
45
+ <Tabs
46
+ {...rest}
47
+ className={className}
48
+ items={items}
49
+ activeKey={activeKey}
50
+ onChange={onChange}
51
+ />
52
+ )
53
+ }
54
+ const current = items.find(item => item.key === activeKey) || items[0]
55
+ if (!current) {
56
+ return null
57
+ }
58
+ // item level onClick, same as the session tabs dropdown (tabs/index.jsx):
59
+ // it fires with the item's own key and keeps onChange single argument
60
+ const menu = {
61
+ selectable: true,
62
+ selectedKeys: [String(activeKey)],
63
+ items: items.map(item => {
64
+ return {
65
+ key: item.key,
66
+ label: item.label,
67
+ disabled: item.disabled,
68
+ onClick: () => onChange && onChange(item.key)
69
+ }
70
+ })
71
+ }
72
+ return (
73
+ <div className={classNames('responsive-tabs-mobile-wrap', className)}>
74
+ <Dropdown
75
+ menu={menu}
76
+ trigger={['click']}
77
+ placement='bottomLeft'
78
+ >
79
+ <button
80
+ type='button'
81
+ className='responsive-tabs-mobile'
82
+ >
83
+ <span className='responsive-tabs-mobile-label elli'>{current.label}</span>
84
+ <DownOutlined className='responsive-tabs-mobile-icon' />
85
+ </button>
86
+ </Dropdown>
87
+ </div>
88
+ )
89
+ })
@@ -0,0 +1,34 @@
1
+ // common/responsive-tabs — the mobile (collapsed) tab bar, see responsive-tabs.jsx
2
+ //
3
+ // .responsive-tabs-mobile-wrap is the element the caller's className lands on
4
+ // (e.g. `.setting-tabs`, which positions the bar absolutely), the button is
5
+ // always full width inside it.
6
+ .responsive-tabs-mobile
7
+ box-sizing border-box
8
+ display flex
9
+ align-items center
10
+ width 100%
11
+ height 36px
12
+ padding 0 10px
13
+ font-size 13px
14
+ line-height 1
15
+ color var(--text)
16
+ background transparent
17
+ border 1px solid var(--main-light)
18
+ border-radius 4px
19
+ cursor pointer
20
+ overflow hidden
21
+ &:hover
22
+ color var(--primary)
23
+ border-color var(--primary)
24
+ .responsive-tabs-mobile-label
25
+ flex 1
26
+ min-width 0
27
+ display flex
28
+ align-items center
29
+ text-align left
30
+ .responsive-tabs-mobile-icon
31
+ flex-shrink 0
32
+ margin-left 8px
33
+ font-size 11px
34
+ color var(--text-light)