@electerm/electerm-react 3.15.110 → 3.15.121
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/client/common/key-control-pressed.js +2 -2
- package/client/common/normalize-remote-path.js +7 -4
- package/client/components/bookmark-form/bookmark-form.styl +17 -0
- package/client/components/bookmark-form/common/connection-hopping-form.jsx +16 -2
- package/client/components/bookmark-form/common/ssh-tunnel-form.jsx +15 -1
- package/client/components/file-transfer/transfer.jsx +4 -3
- package/client/components/main/main.jsx +2 -0
- package/client/components/rdp/file-transfer.js +5 -3
- package/client/components/session/session.jsx +3 -1
- package/client/components/setting-panel/setting-common.jsx +1 -0
- package/client/components/sftp/file-compare-modal.jsx +145 -0
- package/client/components/sftp/file-compare-modal.styl +13 -0
- package/client/components/sftp/file-info-modal.jsx +14 -3
- package/client/components/sftp/file-item.jsx +66 -6
- package/client/components/sftp/sftp-entry.jsx +5 -0
- package/client/components/sys-menu/icons-map.jsx +4 -2
- package/client/store/workspace.js +8 -1
- package/package.json +1 -1
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* check event if control/meta key pressed
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { isMacJs } from './constants'
|
|
6
6
|
export default e => {
|
|
7
7
|
if (e.metaKey && e.ctrlKey) {
|
|
8
8
|
return false
|
|
9
9
|
}
|
|
10
|
-
return
|
|
10
|
+
return isMacJs
|
|
11
11
|
? e.metaKey
|
|
12
12
|
: e.ctrlKey
|
|
13
13
|
}
|
|
@@ -10,11 +10,14 @@ export default function normalizeRemotePath (path) {
|
|
|
10
10
|
if (!path) return path
|
|
11
11
|
// Fix mixed separators: /c:\windows → /c:/windows
|
|
12
12
|
if (/^\/[a-zA-Z]:\\/.test(path)) {
|
|
13
|
-
|
|
13
|
+
path = path.replace(/\\/g, '/')
|
|
14
|
+
} else if (/^[a-zA-Z]:/.test(path)) {
|
|
15
|
+
// Add leading / to bare drive letters: c: → /c:, c:\windows → /c:/windows
|
|
16
|
+
path = '/' + path.replace(/\\/g, '/')
|
|
14
17
|
}
|
|
15
|
-
//
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
+
// Strip trailing slashes except for root path "/"
|
|
19
|
+
if (path.length > 1 && path.endsWith('/')) {
|
|
20
|
+
path = path.replace(/\/+$/, '')
|
|
18
21
|
}
|
|
19
22
|
return path
|
|
20
23
|
}
|
|
@@ -36,3 +36,20 @@
|
|
|
36
36
|
overflow auto
|
|
37
37
|
font-size 12px
|
|
38
38
|
font-family monospace
|
|
39
|
+
|
|
40
|
+
// Flash the add/save button when the form has unsaved edits,
|
|
41
|
+
// so users remember to click it instead of losing their input.
|
|
42
|
+
.btn-flash
|
|
43
|
+
animation add-btn-flash 1.1s ease-in-out infinite
|
|
44
|
+
|
|
45
|
+
@keyframes add-btn-flash
|
|
46
|
+
0%, 100%
|
|
47
|
+
box-shadow 0 0 0 0 var(--primary)
|
|
48
|
+
transform scale(1)
|
|
49
|
+
50%
|
|
50
|
+
box-shadow 0 0 8px 2px var(--primary)
|
|
51
|
+
transform scale(1.04)
|
|
52
|
+
|
|
53
|
+
@media (prefers-reduced-motion: reduce)
|
|
54
|
+
.btn-flash
|
|
55
|
+
animation none
|
|
@@ -36,6 +36,7 @@ export default function ConnectionHoppingForm (props) {
|
|
|
36
36
|
isEdit
|
|
37
37
|
} = props
|
|
38
38
|
const [authType, setAuthType] = useState(initialValues.authType || authTypeMap.password)
|
|
39
|
+
const [dirty, setDirty] = useState(false)
|
|
39
40
|
|
|
40
41
|
function onChangeAuthType (e) {
|
|
41
42
|
setAuthType(e.target.value)
|
|
@@ -45,6 +46,17 @@ export default function ConnectionHoppingForm (props) {
|
|
|
45
46
|
formChild.submit()
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// mark the form as edited so the add/save button can flash as a reminder
|
|
50
|
+
function handleValuesChange () {
|
|
51
|
+
setDirty(true)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// called on a successful (validated) submit; clears the flash
|
|
55
|
+
function onFinishLocal (data) {
|
|
56
|
+
setDirty(false)
|
|
57
|
+
onFinish(data)
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
const authTypes = authTypesProp || Object.keys(authTypeMap).map(k => k)
|
|
49
61
|
|
|
50
62
|
const treeProps = {
|
|
@@ -52,13 +64,14 @@ export default function ConnectionHoppingForm (props) {
|
|
|
52
64
|
return d.host && d.port && d.username
|
|
53
65
|
}),
|
|
54
66
|
bookmarkGroups: store.bookmarkGroups,
|
|
55
|
-
onSelect:
|
|
67
|
+
onSelect: onFinishLocal
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
return (
|
|
59
71
|
<Form
|
|
60
72
|
form={formChild}
|
|
61
|
-
onFinish={
|
|
73
|
+
onFinish={onFinishLocal}
|
|
74
|
+
onValuesChange={handleValuesChange}
|
|
62
75
|
initialValues={initialValues}
|
|
63
76
|
component='div'
|
|
64
77
|
>
|
|
@@ -144,6 +157,7 @@ export default function ConnectionHoppingForm (props) {
|
|
|
144
157
|
htmlType='button'
|
|
145
158
|
icon={isEdit ? <SaveOutlined /> : <PlusOutlined />}
|
|
146
159
|
onClick={onSubmit}
|
|
160
|
+
className={dirty ? 'btn-flash' : ''}
|
|
147
161
|
>
|
|
148
162
|
{isEdit ? e('save') : e('connectionHopping')}
|
|
149
163
|
</Button>
|
|
@@ -34,6 +34,7 @@ export default function SshTunnelForm (props) {
|
|
|
34
34
|
const [isDynamic, setIsDynamic] = useState(
|
|
35
35
|
(initialValues?.sshTunnel || 'forwardRemoteToLocal') === 'dynamicForward'
|
|
36
36
|
)
|
|
37
|
+
const [dirty, setDirty] = useState(false)
|
|
37
38
|
|
|
38
39
|
function onChange (ev) {
|
|
39
40
|
setIsDynamic(ev.target.value === 'dynamicForward')
|
|
@@ -43,6 +44,17 @@ export default function SshTunnelForm (props) {
|
|
|
43
44
|
formChild.submit()
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// mark the form as edited so the add/save button can flash as a reminder
|
|
48
|
+
function handleValuesChange () {
|
|
49
|
+
setDirty(true)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// called on a successful (validated) submit; clears the flash
|
|
53
|
+
function onFinishLocal (data) {
|
|
54
|
+
setDirty(false)
|
|
55
|
+
onFinish(data)
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
function renderSshTunnelFlow (direction) {
|
|
47
59
|
const localToRemote = direction === 'localToRemote'
|
|
48
60
|
const middle = localToRemote ? e('local') : e('remote')
|
|
@@ -101,7 +113,8 @@ export default function SshTunnelForm (props) {
|
|
|
101
113
|
return (
|
|
102
114
|
<Form
|
|
103
115
|
form={formChild}
|
|
104
|
-
onFinish={
|
|
116
|
+
onFinish={onFinishLocal}
|
|
117
|
+
onValuesChange={handleValuesChange}
|
|
105
118
|
initialValues={initialValues}
|
|
106
119
|
component='div'
|
|
107
120
|
>
|
|
@@ -174,6 +187,7 @@ export default function SshTunnelForm (props) {
|
|
|
174
187
|
htmlType='button'
|
|
175
188
|
icon={isEdit ? <SaveOutlined /> : <PlusOutlined />}
|
|
176
189
|
onClick={onSubmit}
|
|
190
|
+
className={dirty ? 'btn-flash' : ''}
|
|
177
191
|
>
|
|
178
192
|
{isEdit ? e('save') : e('sshTunnel')}
|
|
179
193
|
</Button>
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getFolderFromFilePath
|
|
11
11
|
} from '../sftp/file-read'
|
|
12
12
|
import resolve from '../../common/resolve'
|
|
13
|
+
import sanitizeFilename from '../../common/sanitize-filename'
|
|
13
14
|
import { refsTransfers, refsStatic, refs } from '../common/ref'
|
|
14
15
|
import {
|
|
15
16
|
zipCmd,
|
|
@@ -672,7 +673,7 @@ export default class TransportAction extends Component {
|
|
|
672
673
|
}
|
|
673
674
|
|
|
674
675
|
const fromItemPath = resolve(fromPath, file.name)
|
|
675
|
-
const toItemPath = resolve(toPath, file.name)
|
|
676
|
+
const toItemPath = resolve(toPath, sanitizeFilename(file.name))
|
|
676
677
|
|
|
677
678
|
const itemTransfer = {
|
|
678
679
|
...transfer,
|
|
@@ -711,7 +712,7 @@ export default class TransportAction extends Component {
|
|
|
711
712
|
|
|
712
713
|
const batchFolders = folders.slice(i, i + batch)
|
|
713
714
|
const createFolderPromises = batchFolders.map(folder => {
|
|
714
|
-
const toItemPath = resolve(toPath, folder.name)
|
|
715
|
+
const toItemPath = resolve(toPath, sanitizeFilename(folder.name))
|
|
715
716
|
|
|
716
717
|
// Create folder itself (don't process contents)
|
|
717
718
|
const createTransfer = {
|
|
@@ -734,7 +735,7 @@ export default class TransportAction extends Component {
|
|
|
734
735
|
}
|
|
735
736
|
|
|
736
737
|
const fromItemPath = resolve(fromPath, folder.name)
|
|
737
|
-
const toItemPath = resolve(toPath, folder.name)
|
|
738
|
+
const toItemPath = resolve(toPath, sanitizeFilename(folder.name))
|
|
738
739
|
|
|
739
740
|
const itemTransfer = {
|
|
740
741
|
...transfer,
|
|
@@ -2,6 +2,7 @@ import { auto } from 'manate/react'
|
|
|
2
2
|
import { useEffect } from 'react'
|
|
3
3
|
import Layout from '../layout/layout'
|
|
4
4
|
import FileInfoModal from '../sftp/file-info-modal'
|
|
5
|
+
import FileCompareModal from '../sftp/file-compare-modal'
|
|
5
6
|
import UpdateCheck from './upgrade'
|
|
6
7
|
import SettingModal from '../setting-panel/setting-modal'
|
|
7
8
|
import TextEditor from '../text-editor/text-editor-entry'
|
|
@@ -263,6 +264,7 @@ export default auto(function Index (props) {
|
|
|
263
264
|
installSrc={installSrc}
|
|
264
265
|
/>
|
|
265
266
|
<FileInfoModal />
|
|
267
|
+
<FileCompareModal />
|
|
266
268
|
<SettingModal store={store} />
|
|
267
269
|
<MoveItemModal store={store} />
|
|
268
270
|
<div
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { getLocalFileInfo } from '../sftp/file-read'
|
|
7
7
|
import { osResolve } from '../../common/resolve'
|
|
8
8
|
import { filesize } from 'filesize'
|
|
9
|
+
import sanitizeFilename from '../../common/sanitize-filename'
|
|
9
10
|
|
|
10
11
|
const LOG_PREFIX = '[RDP-FILE-TRANSFER]'
|
|
11
12
|
|
|
@@ -245,7 +246,8 @@ export class FileTransferManager {
|
|
|
245
246
|
return
|
|
246
247
|
}
|
|
247
248
|
|
|
248
|
-
const
|
|
249
|
+
const safeName = sanitizeFilename(fileInfo.name)
|
|
250
|
+
const fullPath = osResolve(savePath[0], safeName)
|
|
249
251
|
|
|
250
252
|
const fd = await new Promise((resolve, reject) => {
|
|
251
253
|
window.fs.open(fullPath, O_WRONLY | O_CREAT | O_TRUNC, (err, fd) => {
|
|
@@ -272,12 +274,12 @@ export class FileTransferManager {
|
|
|
272
274
|
})
|
|
273
275
|
})
|
|
274
276
|
|
|
275
|
-
this.log(`Downloaded ${
|
|
277
|
+
this.log(`Downloaded ${safeName} (${filesize(fileInfo._totalSize)}) to ${fullPath}`, 'success')
|
|
276
278
|
this.hasRemoteFiles = false
|
|
277
279
|
this.pendingDownloads.clear()
|
|
278
280
|
this.notifyStateChange()
|
|
279
281
|
if (this.onDownloadComplete) {
|
|
280
|
-
this.onDownloadComplete(fullPath,
|
|
282
|
+
this.onDownloadComplete(fullPath, safeName, fileInfo._totalSize)
|
|
281
283
|
}
|
|
282
284
|
} catch (e) {
|
|
283
285
|
this.log(`Failed to save file: ${e.message}`, 'error')
|
|
@@ -56,7 +56,9 @@ export default class SessionWrapper extends Component {
|
|
|
56
56
|
broadcastInput: false,
|
|
57
57
|
keepaliveEnabled: false
|
|
58
58
|
}
|
|
59
|
-
props.tab.sshSftpSplitView
|
|
59
|
+
if (props.tab.sshSftpSplitView === undefined) {
|
|
60
|
+
props.tab.sshSftpSplitView = !!props.config.sshSftpSplitView
|
|
61
|
+
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
minWithForSplit = 640
|
|
@@ -547,6 +547,7 @@ export default class SettingCommon extends Component {
|
|
|
547
547
|
|
|
548
548
|
<div className='pd2b'>
|
|
549
549
|
<span className='inline-title mg1r'>{e('customCss')}</span>
|
|
550
|
+
<HelpIcon link='https://github.com/electerm/electerm/wiki/Custom-CSS-examples' />
|
|
550
551
|
<TextareaConfirm
|
|
551
552
|
onChange={this.handleCustomCss}
|
|
552
553
|
value={customCss}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file compare modal - shows side by side comparison of two files' attributes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import Modal from '../common/modal'
|
|
7
|
+
import resolve from '../../common/resolve'
|
|
8
|
+
import time from '../../common/time'
|
|
9
|
+
import { mode2permission, permission2mode } from '../../common/mode2permission'
|
|
10
|
+
import FileIcon from './file-icon'
|
|
11
|
+
import { filesize } from 'filesize'
|
|
12
|
+
import { refsStatic } from '../common/ref'
|
|
13
|
+
import './file-compare-modal.styl'
|
|
14
|
+
|
|
15
|
+
const e = window.translate
|
|
16
|
+
const formatTime = time
|
|
17
|
+
|
|
18
|
+
export default class FileCompareModal extends React.PureComponent {
|
|
19
|
+
state = {
|
|
20
|
+
visible: false,
|
|
21
|
+
file1: {},
|
|
22
|
+
file2: {},
|
|
23
|
+
tab: null,
|
|
24
|
+
uidTree: {},
|
|
25
|
+
gidTree: {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
componentDidMount () {
|
|
29
|
+
refsStatic.add('file-compare-modal', this)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
showFileCompareModal (data) {
|
|
33
|
+
this.setState({
|
|
34
|
+
...data,
|
|
35
|
+
visible: true
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onClose = () => {
|
|
40
|
+
this.setState({
|
|
41
|
+
file1: {},
|
|
42
|
+
file2: {},
|
|
43
|
+
visible: false
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
render () {
|
|
48
|
+
const {
|
|
49
|
+
visible,
|
|
50
|
+
file1,
|
|
51
|
+
file2,
|
|
52
|
+
tab = {},
|
|
53
|
+
uidTree = {},
|
|
54
|
+
gidTree = {}
|
|
55
|
+
} = this.state
|
|
56
|
+
if (!visible) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
const {
|
|
60
|
+
host,
|
|
61
|
+
port,
|
|
62
|
+
username
|
|
63
|
+
} = tab
|
|
64
|
+
const title = e('compare')
|
|
65
|
+
const ps = {
|
|
66
|
+
open: visible,
|
|
67
|
+
width: 800,
|
|
68
|
+
title,
|
|
69
|
+
footer: null,
|
|
70
|
+
onCancel: this.onClose
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fp1 = resolve(file1.path, file1.name)
|
|
74
|
+
const fp2 = resolve(file2.path, file2.name)
|
|
75
|
+
const ffp1 = file1.type === 'local'
|
|
76
|
+
? fp1
|
|
77
|
+
: `${username}@${host}:${port}:${fp1}`
|
|
78
|
+
const ffp2 = file2.type === 'local'
|
|
79
|
+
? fp2
|
|
80
|
+
: `${username}@${host}:${port}:${fp2}`
|
|
81
|
+
|
|
82
|
+
const perms1 = mode2permission(file1.mode)
|
|
83
|
+
const perms2 = mode2permission(file2.mode)
|
|
84
|
+
const permission1 = permission2mode(perms1)
|
|
85
|
+
const permission2 = permission2mode(perms2)
|
|
86
|
+
|
|
87
|
+
const rows = [
|
|
88
|
+
[
|
|
89
|
+
e('name'),
|
|
90
|
+
(
|
|
91
|
+
<React.Fragment key='name1'>
|
|
92
|
+
<FileIcon file={file1} height={20} />
|
|
93
|
+
{' '}
|
|
94
|
+
{file1.isSymbolicLink ? <sup className='color-blue symbolic-link-icon'>*</sup> : null}
|
|
95
|
+
{file1.name}
|
|
96
|
+
</React.Fragment>
|
|
97
|
+
),
|
|
98
|
+
(
|
|
99
|
+
<React.Fragment key='name2'>
|
|
100
|
+
<FileIcon file={file2} height={20} />
|
|
101
|
+
{' '}
|
|
102
|
+
{file2.isSymbolicLink ? <sup className='color-blue symbolic-link-icon'>*</sup> : null}
|
|
103
|
+
{file2.name}
|
|
104
|
+
</React.Fragment>
|
|
105
|
+
)
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
e('type'),
|
|
109
|
+
file1.isDirectory ? e('folder') : e('file'),
|
|
110
|
+
file2.isDirectory ? e('folder') : e('file')
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
e('size'),
|
|
114
|
+
file1.isDirectory ? '-' : filesize(file1.size || 0),
|
|
115
|
+
file2.isDirectory ? '-' : filesize(file2.size || 0)
|
|
116
|
+
],
|
|
117
|
+
[e('mode'), permission1, permission2],
|
|
118
|
+
[e('fullPath'), ffp1, ffp2],
|
|
119
|
+
[e('owner'), uidTree['' + file1.owner] || file1.owner, uidTree['' + file2.owner] || file2.owner],
|
|
120
|
+
[e('group'), gidTree['' + file1.group] || file1.group, gidTree['' + file2.group] || file2.group],
|
|
121
|
+
[e('accessTime'), formatTime(file1.accessTime), formatTime(file2.accessTime)],
|
|
122
|
+
[e('modifyTime'), formatTime(file1.modifyTime), formatTime(file2.modifyTime)]
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Modal {...ps}>
|
|
127
|
+
<table className='file-compare-table'>
|
|
128
|
+
<tbody>
|
|
129
|
+
{rows.map(([label, v1, v2], i) => {
|
|
130
|
+
const same = String(v1 ?? '') === String(v2 ?? '')
|
|
131
|
+
const cls = same ? '' : 'file-compare-diff'
|
|
132
|
+
return (
|
|
133
|
+
<tr key={i}>
|
|
134
|
+
<td className='bold file-compare-label'>{label}</td>
|
|
135
|
+
<td className={cls}>{v1}</td>
|
|
136
|
+
<td className={cls}>{v2}</td>
|
|
137
|
+
</tr>
|
|
138
|
+
)
|
|
139
|
+
})}
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
142
|
+
</Modal>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -123,6 +123,13 @@ export default class FileMode extends React.PureComponent {
|
|
|
123
123
|
return String(value).replace(/'/g, "''")
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
escapePosixShellArg = (value) => {
|
|
127
|
+
// Within single quotes, the only special character is the single quote itself.
|
|
128
|
+
// Escape it by closing the quote, inserting an escaped quote, and reopening:
|
|
129
|
+
// ' -> '\''
|
|
130
|
+
return String(value).replace(/'/g, "'\\''")
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
normalizeRemoteWindowsPath = (value) => {
|
|
127
134
|
return String(value).replace(/^\/([a-zA-Z]:)/, '$1')
|
|
128
135
|
}
|
|
@@ -161,9 +168,12 @@ export default class FileMode extends React.PureComponent {
|
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
calcLocal = async (folder) => {
|
|
171
|
+
const safeFolder = isWin
|
|
172
|
+
? this.escapePowerShellPath(folder)
|
|
173
|
+
: this.escapePosixShellArg(folder)
|
|
164
174
|
const cmd = isWin
|
|
165
|
-
? `Get-ChildItem -Recurse '${
|
|
166
|
-
: `du -sh '${
|
|
175
|
+
? `Get-ChildItem -Recurse '${safeFolder}' | Measure-Object -Property Length -Sum`
|
|
176
|
+
: `du -sh '${safeFolder}'`
|
|
167
177
|
const func = isWin ? 'runWinCmd' : 'run'
|
|
168
178
|
const res = await window.fs[func](cmd).catch(window.store.onError)
|
|
169
179
|
return this.getSize(res)
|
|
@@ -174,7 +184,8 @@ export default class FileMode extends React.PureComponent {
|
|
|
174
184
|
if (platform === 'windows') {
|
|
175
185
|
return this.calcRemoteWin(folder)
|
|
176
186
|
}
|
|
177
|
-
const
|
|
187
|
+
const safeFolder = this.escapePosixShellArg(folder)
|
|
188
|
+
const cmd = `du -sh '${safeFolder}'`
|
|
178
189
|
const r = await runCmd(
|
|
179
190
|
this.state.pid,
|
|
180
191
|
cmd
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
fileOperationsMap,
|
|
26
26
|
isWin, transferTypeMap, typeMap,
|
|
27
27
|
paneMap,
|
|
28
|
-
|
|
28
|
+
isMacJs, maxEditFileSize, ctrlOrCmd
|
|
29
29
|
} from '../../common/constants'
|
|
30
30
|
import sorter from '../../common/index-sorter'
|
|
31
31
|
import { getFolderFromFilePath, getLocalFileInfo } from './file-read'
|
|
@@ -38,6 +38,7 @@ import generate from '../../common/uid'
|
|
|
38
38
|
import sanitizeFilename from '../../common/sanitize-filename'
|
|
39
39
|
import { refsStatic, refs, filesRef } from '../common/ref'
|
|
40
40
|
import iconsMap from '../sys-menu/icons-map'
|
|
41
|
+
import message from '../common/message'
|
|
41
42
|
|
|
42
43
|
const e = window.translate
|
|
43
44
|
|
|
@@ -377,6 +378,46 @@ export default class FileSection extends React.Component {
|
|
|
377
378
|
})
|
|
378
379
|
}
|
|
379
380
|
|
|
381
|
+
getExt = (name = '') => {
|
|
382
|
+
const parts = String(name).split('.')
|
|
383
|
+
if (parts.length < 2) {
|
|
384
|
+
return ''
|
|
385
|
+
}
|
|
386
|
+
return parts[parts.length - 1].toLowerCase()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
showCompare = () => {
|
|
390
|
+
const { type } = this.props
|
|
391
|
+
const selected = this.props.getSelectedFiles().filter(f => !f.isDirectory)
|
|
392
|
+
if (selected.length !== 2) {
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
refsStatic.get('file-compare-modal')?.showFileCompareModal({
|
|
396
|
+
file1: selected[0],
|
|
397
|
+
file2: selected[1],
|
|
398
|
+
tab: this.props.tab,
|
|
399
|
+
uidTree: this.props[`${type}UidTree`],
|
|
400
|
+
gidTree: this.props[`${type}GidTree`]
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
canCompare = () => {
|
|
405
|
+
const { selectedFiles } = this.props
|
|
406
|
+
if (!selectedFiles || selectedFiles.size !== 2) {
|
|
407
|
+
return false
|
|
408
|
+
}
|
|
409
|
+
const selected = this.props.getSelectedFiles()
|
|
410
|
+
if (selected.length !== 2) {
|
|
411
|
+
return false
|
|
412
|
+
}
|
|
413
|
+
if (selected.some(f => f.isDirectory || !f.id)) {
|
|
414
|
+
return false
|
|
415
|
+
}
|
|
416
|
+
const ext1 = this.getExt(selected[0].name)
|
|
417
|
+
const ext2 = this.getExt(selected[1].name)
|
|
418
|
+
return ext1 === ext2
|
|
419
|
+
}
|
|
420
|
+
|
|
380
421
|
cancelNew = (type) => {
|
|
381
422
|
let list = this.props[type]
|
|
382
423
|
list = list.filter(p => p.id)
|
|
@@ -467,8 +508,8 @@ export default class FileSection extends React.Component {
|
|
|
467
508
|
let selectedFiles = [file]
|
|
468
509
|
if (isSameSide) {
|
|
469
510
|
if (
|
|
470
|
-
(e.ctrlKey && !
|
|
471
|
-
(e.metaKey &&
|
|
511
|
+
(e.ctrlKey && !isMacJs) ||
|
|
512
|
+
(e.metaKey && isMacJs)
|
|
472
513
|
) {
|
|
473
514
|
const isSelected = some(
|
|
474
515
|
selectedFilesOld,
|
|
@@ -617,9 +658,15 @@ export default class FileSection extends React.Component {
|
|
|
617
658
|
tempPath = window.pre.resolve(path, name)
|
|
618
659
|
} else {
|
|
619
660
|
const id = generate()
|
|
661
|
+
const safeName = sanitizeFilename(name)
|
|
620
662
|
tempPath = window.pre.resolve(
|
|
621
|
-
window.pre.tempDir, `electerm-temp-${id}-${
|
|
663
|
+
window.pre.tempDir, `electerm-temp-${id}-${safeName}`
|
|
622
664
|
)
|
|
665
|
+
// Defense-in-depth: verify the resolved path stays within tempDir
|
|
666
|
+
if (!tempPath.startsWith(window.pre.tempDir + window.pre.sep)) {
|
|
667
|
+
message.error(e('invalidTempFilePath'))
|
|
668
|
+
return
|
|
669
|
+
}
|
|
623
670
|
await window.fs.writeFile(tempPath, text)
|
|
624
671
|
}
|
|
625
672
|
this.watchingFile = tempPath
|
|
@@ -637,9 +684,15 @@ export default class FileSection extends React.Component {
|
|
|
637
684
|
tempPath = window.pre.resolve(path, name)
|
|
638
685
|
} else {
|
|
639
686
|
const id = generate()
|
|
687
|
+
const safeName = sanitizeFilename(name)
|
|
640
688
|
tempPath = window.pre.resolve(
|
|
641
|
-
window.pre.tempDir, `electerm-temp-${id}-${
|
|
689
|
+
window.pre.tempDir, `electerm-temp-${id}-${safeName}`
|
|
642
690
|
)
|
|
691
|
+
// Defense-in-depth: verify the resolved path stays within tempDir
|
|
692
|
+
if (!tempPath.startsWith(window.pre.tempDir + window.pre.sep)) {
|
|
693
|
+
message.error(e('invalidTempFilePath'))
|
|
694
|
+
return
|
|
695
|
+
}
|
|
643
696
|
await window.fs.writeFile(tempPath, text)
|
|
644
697
|
}
|
|
645
698
|
this.watchingFile = tempPath
|
|
@@ -1020,7 +1073,7 @@ export default class FileSection extends React.Component {
|
|
|
1020
1073
|
res.push({
|
|
1021
1074
|
func: 'doTransferSelected',
|
|
1022
1075
|
icon: iconType,
|
|
1023
|
-
text: `${e('selected')}(${len})`
|
|
1076
|
+
text: `${transferText}:${e('selected')}(${len})`
|
|
1024
1077
|
})
|
|
1025
1078
|
}
|
|
1026
1079
|
if (
|
|
@@ -1158,6 +1211,13 @@ export default class FileSection extends React.Component {
|
|
|
1158
1211
|
text: e('info')
|
|
1159
1212
|
})
|
|
1160
1213
|
}
|
|
1214
|
+
if (this.canCompare()) {
|
|
1215
|
+
res.push({
|
|
1216
|
+
func: 'showCompare',
|
|
1217
|
+
icon: 'SwapOutlined',
|
|
1218
|
+
text: e('compare')
|
|
1219
|
+
})
|
|
1220
|
+
}
|
|
1161
1221
|
return res
|
|
1162
1222
|
}
|
|
1163
1223
|
|
|
@@ -1027,6 +1027,11 @@ export default class Sftp extends Component {
|
|
|
1027
1027
|
let np = await this.parsePath(type, this.state[nt])
|
|
1028
1028
|
if (type === typeMap.remote) {
|
|
1029
1029
|
np = normalizeRemotePath(np)
|
|
1030
|
+
} else if (np.length > 1 && np.endsWith('/')) {
|
|
1031
|
+
// Strip trailing slash except for root path and Windows drive roots
|
|
1032
|
+
if (!/^[a-zA-Z]:\/$/.test(np)) {
|
|
1033
|
+
np = np.replace(/\/+$/, '')
|
|
1034
|
+
}
|
|
1030
1035
|
}
|
|
1031
1036
|
if (!isValidPath(np)) {
|
|
1032
1037
|
return notification.warning({
|
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
SaveOutlined,
|
|
29
29
|
PlayCircleFilled,
|
|
30
30
|
StopOutlined,
|
|
31
|
-
DownloadOutlined
|
|
31
|
+
DownloadOutlined,
|
|
32
|
+
SwapOutlined
|
|
32
33
|
} from '@ant-design/icons'
|
|
33
34
|
import IconHolder from './icon-holder'
|
|
34
35
|
|
|
@@ -60,5 +61,6 @@ export default {
|
|
|
60
61
|
SaveOutlined,
|
|
61
62
|
PlayCircleFilled,
|
|
62
63
|
StopOutlined,
|
|
63
|
-
DownloadOutlined
|
|
64
|
+
DownloadOutlined,
|
|
65
|
+
SwapOutlined
|
|
64
66
|
}
|
|
@@ -25,7 +25,8 @@ export default Store => {
|
|
|
25
25
|
// Store srcId (bookmark id) if available, otherwise store basic connection info
|
|
26
26
|
if (tab.srcId) {
|
|
27
27
|
tabsByBatch[batch].push({
|
|
28
|
-
srcId: tab.srcId
|
|
28
|
+
srcId: tab.srcId,
|
|
29
|
+
sshSftpSplitView: tab.sshSftpSplitView
|
|
29
30
|
})
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -82,6 +83,12 @@ export default Store => {
|
|
|
82
83
|
// Open from bookmark
|
|
83
84
|
window.openTabBatch = batch
|
|
84
85
|
store.onSelectBookmark(tabInfo.srcId)
|
|
86
|
+
// Apply saved sshSftpSplitView state to the newly created tab
|
|
87
|
+
if (tabInfo.sshSftpSplitView !== undefined) {
|
|
88
|
+
store.updateTab(store.activeTabId, {
|
|
89
|
+
sshSftpSplitView: tabInfo.sshSftpSplitView
|
|
90
|
+
})
|
|
91
|
+
}
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
}
|