@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.
- package/README.md +12 -2
- package/client/common/constants.js +14 -0
- package/client/common/default-setting.js +2 -2
- package/client/common/download.jsx +12 -0
- package/client/common/parse-vv.js +313 -0
- package/client/common/resolve.js +20 -14
- package/client/common/theme-defaults.js +5 -2
- package/client/common/update-check.js +5 -4
- package/client/components/ai/ai-history-item.jsx +1 -1
- package/client/components/ai/ai-history.jsx +1 -0
- package/client/components/ai/ai-history.styl +23 -0
- package/client/components/ai/ai-presets.js +13 -2
- package/client/components/bg/css-overwrite.jsx +16 -1
- package/client/components/bookmark-form/common/color-picker.styl +3 -0
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
- package/client/components/bookmark-form/config/spice.js +1 -0
- package/client/components/common/remote-pointer.js +49 -0
- package/client/components/common/remote-session-shell.jsx +137 -0
- package/client/components/common/remote-session-shell.styl +80 -0
- package/client/components/common/responsive-tabs.jsx +89 -0
- package/client/components/common/responsive-tabs.styl +34 -0
- package/client/components/footer/footer-entry.jsx +39 -14
- package/client/components/icons/ai-icon.styl +4 -1
- package/client/components/layout/layout.jsx +7 -1
- package/client/components/main/main.jsx +9 -2
- package/client/components/main/term-fullscreen.styl +7 -0
- package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
- package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
- package/client/components/quick-commands/quick-commands-box.jsx +23 -5
- package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
- package/client/components/rdp/rdp-session.jsx +94 -124
- package/client/components/rdp/rdp.styl +13 -36
- package/client/components/session/session.styl +0 -16
- package/client/components/setting-panel/list.jsx +2 -2
- package/client/components/setting-panel/setting-modal.jsx +8 -3
- package/client/components/sftp/sftp-entry.jsx +26 -0
- package/client/components/side-panel-r/right-side-panel.styl +11 -1
- package/client/components/side-panel-r/side-panel-r.jsx +45 -9
- package/client/components/sidebar/bookmark-select.jsx +3 -1
- package/client/components/sidebar/index.jsx +13 -4
- package/client/components/sidebar/side-panel.jsx +8 -3
- package/client/components/sidebar/sidebar-panel.jsx +20 -7
- package/client/components/spice/spice-session.jsx +69 -72
- package/client/components/spice/spice.styl +10 -19
- package/client/components/terminal/shortcut-bar.styl +14 -0
- package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
- package/client/components/terminal-info/terminal-info.styl +3 -0
- package/client/components/theme/terminal-theme-list.styl +3 -0
- package/client/components/theme/theme-ai-editor.jsx +2 -2
- package/client/components/theme/theme-form.jsx +20 -23
- package/client/components/theme/theme-form.styl +8 -10
- package/client/components/theme/theme-list-item.jsx +59 -25
- package/client/components/vnc/vnc-session.jsx +72 -104
- package/client/components/vnc/vnc.styl +25 -16
- package/client/css/mobile.styl +28 -0
- package/client/store/common.js +44 -0
- package/client/store/init-state.js +2 -1
- package/client/store/setting.js +4 -0
- package/client/store/sidebar.js +5 -1
- package/client/store/store.js +9 -2
- 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.
|
|
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={
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
) {
|
|
@@ -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
|
-
|
|
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, [
|