@electerm/electerm-react 5.5.9 → 5.5.25

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 (62) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/download.jsx +12 -0
  5. package/client/common/parse-vv.js +313 -0
  6. package/client/common/resolve.js +20 -14
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/components/ai/ai-history-item.jsx +1 -1
  10. package/client/components/ai/ai-history.jsx +1 -0
  11. package/client/components/ai/ai-history.styl +23 -0
  12. package/client/components/ai/ai-presets.js +13 -2
  13. package/client/components/bg/css-overwrite.jsx +16 -1
  14. package/client/components/bookmark-form/common/color-picker.styl +3 -0
  15. package/client/components/bookmark-form/common/fields.jsx +3 -0
  16. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  17. package/client/components/bookmark-form/config/spice.js +1 -0
  18. package/client/components/common/remote-pointer.js +49 -0
  19. package/client/components/common/remote-session-shell.jsx +137 -0
  20. package/client/components/common/remote-session-shell.styl +80 -0
  21. package/client/components/common/responsive-tabs.jsx +89 -0
  22. package/client/components/common/responsive-tabs.styl +34 -0
  23. package/client/components/footer/footer-entry.jsx +39 -14
  24. package/client/components/icons/ai-icon.styl +4 -1
  25. package/client/components/layout/layout.jsx +7 -1
  26. package/client/components/main/main.jsx +9 -2
  27. package/client/components/main/term-fullscreen.styl +7 -0
  28. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  29. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  30. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  31. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  32. package/client/components/rdp/rdp-session.jsx +94 -124
  33. package/client/components/rdp/rdp.styl +13 -36
  34. package/client/components/session/session.styl +0 -16
  35. package/client/components/setting-panel/list.jsx +2 -2
  36. package/client/components/setting-panel/setting-modal.jsx +8 -3
  37. package/client/components/sftp/sftp-entry.jsx +26 -0
  38. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  39. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  40. package/client/components/sidebar/bookmark-select.jsx +3 -1
  41. package/client/components/sidebar/index.jsx +13 -4
  42. package/client/components/sidebar/side-panel.jsx +8 -3
  43. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  44. package/client/components/spice/spice-session.jsx +69 -72
  45. package/client/components/spice/spice.styl +10 -19
  46. package/client/components/terminal/shortcut-bar.styl +14 -0
  47. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  48. package/client/components/terminal-info/terminal-info.styl +3 -0
  49. package/client/components/theme/terminal-theme-list.styl +3 -0
  50. package/client/components/theme/theme-ai-editor.jsx +2 -2
  51. package/client/components/theme/theme-form.jsx +20 -23
  52. package/client/components/theme/theme-form.styl +8 -10
  53. package/client/components/theme/theme-list-item.jsx +59 -25
  54. package/client/components/vnc/vnc-session.jsx +72 -104
  55. package/client/components/vnc/vnc.styl +25 -16
  56. package/client/css/mobile.styl +28 -0
  57. package/client/store/common.js +44 -0
  58. package/client/store/init-state.js +2 -1
  59. package/client/store/setting.js +4 -0
  60. package/client/store/sidebar.js +5 -1
  61. package/client/store/store.js +9 -2
  62. package/package.json +1 -1
@@ -0,0 +1,137 @@
1
+ /**
2
+ * bookmark form - load a .vv (virt-viewer) connection file
3
+ *
4
+ * Registered as field type 'vvFile' in ./fields.jsx and used by
5
+ * ../config/spice.js. Fills host/port/password/title/proxy from the file and
6
+ * reports anything it could not honour instead of dropping it silently.
7
+ */
8
+ import { useState } from 'react'
9
+ import {
10
+ Form,
11
+ Button,
12
+ Alert
13
+ } from 'antd'
14
+ import { FileAddOutlined } from '@ant-design/icons'
15
+ import fs from '../../../common/fs'
16
+ import { tailFormItemLayout } from '../../../common/form-layout'
17
+ import { parseVv, describeVv } from '../../../common/parse-vv'
18
+
19
+ const FormItem = Form.Item
20
+
21
+ /**
22
+ * window.translate echoes an unknown key back capitalised, so an unmapped
23
+ * 'loadVvFile' would render as "LoadVvFile". The key IS in
24
+ * ~/dev/electerm-locales, but the app consumes the published
25
+ * @electerm/electerm-locales, so it stays unknown until that ships and the dep
26
+ * is bumped. Fall back to the literal, which is what en_us resolves to anyway
27
+ * (capitalizeFirstLetter('load .vv file')), so nothing changes visually when
28
+ * the translation does land.
29
+ */
30
+ function vvLabel () {
31
+ const t = window.translate('loadVvFile')
32
+ return t === 'LoadVvFile' ? 'Load .vv file' : t
33
+ }
34
+
35
+ /**
36
+ * Open the native file dialog, filtered to .vv files.
37
+ * window.api.openDialog is dialog.showOpenDialogSync in the main process
38
+ * (see src/app/lib/ipc.js), so the full Electron options object is accepted.
39
+ * @returns {Promise<string|null>} selected path, or null when cancelled
40
+ */
41
+ async function pickVvFile () {
42
+ const files = await window.api.openDialog({
43
+ title: 'Choose a .vv connection file',
44
+ message: 'Choose a .vv connection file',
45
+ properties: [
46
+ 'openFile',
47
+ 'showHiddenFiles',
48
+ 'noResolveAliases',
49
+ 'treatPackageAsDirectory',
50
+ 'dontAddToRecent'
51
+ ],
52
+ filters: [
53
+ { name: 'Virt Viewer connection file', extensions: ['vv'] },
54
+ { name: 'All files', extensions: ['*'] }
55
+ ]
56
+ }).catch(() => false)
57
+ if (!files || !files.length) {
58
+ return null
59
+ }
60
+ return files[0]
61
+ }
62
+
63
+ export default function VvFileField ({ form }) {
64
+ const [hint, setHint] = useState(null)
65
+ const [loading, setLoading] = useState(false)
66
+
67
+ const load = async () => {
68
+ setLoading(true)
69
+ setHint(null)
70
+ try {
71
+ const filePath = await pickVvFile()
72
+ if (!filePath) {
73
+ return
74
+ }
75
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
76
+ const text = await fs.readFile(filePath)
77
+ const parsed = parseVv(text)
78
+
79
+ if (!parsed.ok) {
80
+ setHint({ type: 'error', text: describeVv(parsed) })
81
+ return
82
+ }
83
+
84
+ // Only push the keys the file actually defined, so loading a .vv on top
85
+ // of a partly filled form does not wipe the other fields.
86
+ form.setFieldsValue(parsed.fields)
87
+
88
+ const notes = parsed.warnings.slice()
89
+ if (parsed.ignored.length) {
90
+ notes.push(
91
+ 'skipped: ' + parsed.ignored.map(d => d.key).join(', ')
92
+ )
93
+ }
94
+ if (parsed.unknown.length) {
95
+ notes.push(
96
+ 'unrecognised: ' + parsed.unknown.map(d => d.key).join(', ')
97
+ )
98
+ }
99
+
100
+ const summary = describeVv(parsed)
101
+ setHint({
102
+ type: notes.length ? 'warning' : 'success',
103
+ text: summary + (notes.length ? ' -- ' + notes.join('; ') : '')
104
+ })
105
+ } catch (err) {
106
+ setHint({ type: 'error', text: 'cannot read file: ' + err.message })
107
+ } finally {
108
+ setLoading(false)
109
+ }
110
+ }
111
+
112
+ return (
113
+ <FormItem
114
+ {...tailFormItemLayout}
115
+ >
116
+ <Button
117
+ icon={<FileAddOutlined />}
118
+ loading={loading}
119
+ onClick={load}
120
+ >
121
+ {vvLabel()}
122
+ </Button>
123
+ {
124
+ hint && (
125
+ <Alert
126
+ className='mg1t'
127
+ type={hint.type}
128
+ title={hint.text}
129
+ showIcon
130
+ closable
131
+ onClose={() => setHint(null)}
132
+ />
133
+ )
134
+ }
135
+ </FormItem>
136
+ )
137
+ }
@@ -26,6 +26,7 @@ const spiceConfig = {
26
26
  fields: [
27
27
  commonFields.category,
28
28
  commonFields.colorTitle,
29
+ { type: 'vvFile', name: '__vv__', label: '' },
29
30
  { type: 'input', name: 'host', label: () => e('host'), rules: [{ required: true, message: e('host') + ' required' }] },
30
31
  commonFields.port,
31
32
  { type: 'switch', name: 'viewOnly', label: () => e('viewOnly'), valuePropName: 'checked' },
@@ -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)
@@ -24,7 +24,7 @@ const {
24
24
 
25
25
  export default auto(function FooterEntry (props) {
26
26
  function handleInfoPanel () {
27
- window.store.openInfoPanel()
27
+ window.store.toggleInfoPanel()
28
28
  }
29
29
 
30
30
  function batchInput (cmd, selectedTabIds) {
@@ -106,11 +106,19 @@ export default auto(function FooterEntry (props) {
106
106
  )
107
107
  }
108
108
 
109
+ function handleAIPanel () {
110
+ window.store.toggleAIPanel()
111
+ }
112
+
109
113
  function renderAIIcon () {
114
+ const { rightPanelVisible, rightPanelTab } = props.store
115
+ // same contract as the info icon: lit while its panel is open
116
+ const active = rightPanelVisible && rightPanelTab === 'ai'
110
117
  return (
111
118
  <div className='terminal-footer-unit terminal-footer-ai'>
112
119
  <AIIcon
113
- onClick={window.store.handleOpenAIPanel}
120
+ onClick={handleAIPanel}
121
+ className={active ? 'active' : ''}
114
122
  />
115
123
  </div>
116
124
  )
@@ -176,11 +184,15 @@ export default auto(function FooterEntry (props) {
176
184
  if (loading) {
177
185
  return null
178
186
  }
187
+ const { rightPanelVisible, rightPanelTab } = props.store
188
+ // keep the icon lit while its panel is open so it reads as "click to close"
189
+ const active = rightPanelVisible && rightPanelTab === 'info'
179
190
  return (
180
191
  <div className='terminal-footer-unit terminal-footer-info'>
181
192
  <BarChartOutlined
182
193
  onClick={handleInfoPanel}
183
- className='pointer font14 terminal-info-icon'
194
+ className={'pointer font14 terminal-info-icon' + (active ? ' active' : '')}
195
+ title={e('info')}
184
196
  />
185
197
  </div>
186
198
  )
@@ -202,7 +214,11 @@ export default auto(function FooterEntry (props) {
202
214
  leftSidePanelWidth,
203
215
  leftSideBarWidth,
204
216
  openedSideBar,
205
- inActiveTerminal
217
+ inActiveTerminal,
218
+ rightPanelVisible,
219
+ rightPanelPinned,
220
+ rightPanelWidth,
221
+ isMobile
206
222
  } = props.store
207
223
  const w = leftSideBarWidth + leftSidePanelWidth
208
224
  // icon bar hidden: show a control on the left of the footer to bring the
@@ -217,16 +233,25 @@ export default auto(function FooterEntry (props) {
217
233
  </div>
218
234
  )
219
235
  : null
220
- const sideProps = openedSideBar
221
- ? {
222
- className: 'main-footer',
223
- style: {
224
- left: `${w}px`
225
- }
226
- }
227
- : {
228
- className: 'main-footer'
229
- }
236
+ // The footer mirrors the space layout.jsx reserves on both sides.
237
+ // Left: reserve while the panel is open OR pinned. A pinned left panel is
238
+ // always open, so this is a superset of the pin state — and it is what keeps
239
+ // the footer from painting over the panel, which spans bottom 0 in both
240
+ // states. Right: reserve only for a pinned panel, because the unpinned right
241
+ // panel is an overlay that stops above the footer and so never needs the
242
+ // space. Mobile reserves nothing on either side, matching layout.jsx, and the
243
+ // right panel has no pin control there at all.
244
+ const footerStyle = {}
245
+ if (openedSideBar && !isMobile) {
246
+ footerStyle.left = `${w}px`
247
+ }
248
+ if (rightPanelVisible && rightPanelPinned && !isMobile) {
249
+ footerStyle.right = `${rightPanelWidth}px`
250
+ }
251
+ const sideProps = {
252
+ className: 'main-footer',
253
+ style: footerStyle
254
+ }
230
255
  if (
231
256
  !inActiveTerminal
232
257
  ) {
@@ -1,3 +1,6 @@
1
1
  .ai-icon
2
2
  display inline-block
3
- cursor pointer
3
+ cursor pointer
4
+ // panel open: same "on" colour the info icon and the panel pin button use
5
+ &.active
6
+ color var(--success)
@@ -151,7 +151,13 @@ export default auto(function Layout (props) {
151
151
  'leftSidePanelWidth',
152
152
  'leftSideBarWidth',
153
153
  'openedSideBar',
154
- 'currentQuickCommands'
154
+ 'currentQuickCommands',
155
+ // the quick-command popup mirrors the footer's horizontal extent, so it
156
+ // needs the same right-panel state the footer uses
157
+ 'rightPanelVisible',
158
+ 'rightPanelPinned',
159
+ 'rightPanelWidth',
160
+ 'isMobile'
155
161
  ])
156
162
  const sessionsProps = {
157
163
  styles: styles.wrapStyles,
@@ -148,7 +148,12 @@ export default auto(function Index (props) {
148
148
  'is-mac': isMac,
149
149
  'not-mac': !isMac,
150
150
  'is-win': isWin,
151
- pinned,
151
+ // The left sidebar dock is desktop-only (see sidebar/index.jsx). This
152
+ // class drives the docked-layout CSS — the footer's left offset and the
153
+ // sidebar panel's top padding — so it has to follow the same gate as the
154
+ // layout maths in layout.jsx, or a pin persisted from a desktop session
155
+ // would restyle the mobile layout.
156
+ pinned: pinned && !store.isMobile,
152
157
  'not-win': !isWin,
153
158
  'qm-pinned': pinnedQuickCommandBar,
154
159
  fullscreen,
@@ -226,6 +231,7 @@ export default auto(function Index (props) {
226
231
  transferHistory: copiedHistory,
227
232
  upgradeInfo,
228
233
  pinned,
234
+ isMobile: store.isMobile,
229
235
  leftSideBarIcons: config.leftSideBarIcons,
230
236
  widgetInstancesLength: widgetInstances.length
231
237
  }
@@ -253,7 +259,8 @@ export default auto(function Index (props) {
253
259
  rightPanelPinned: store.rightPanelPinned,
254
260
  rightPanelWidth: store.rightPanelWidth,
255
261
  title: rightPanelTitle,
256
- rightPanelTab
262
+ rightPanelTab,
263
+ isMobile: store.isMobile
257
264
  }
258
265
  const sshConfigProps = {
259
266
  ...pick(store, [