@electerm/electerm-react 3.0.6 → 3.1.6

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 (28) hide show
  1. package/client/common/constants.js +4 -2
  2. package/client/common/default-setting.js +1 -0
  3. package/client/components/bookmark-form/config/ftp.js +1 -0
  4. package/client/components/file-transfer/conflict-resolve.jsx +30 -4
  5. package/client/components/file-transfer/transfer-queue.jsx +10 -4
  6. package/client/components/file-transfer/transfer.jsx +7 -4
  7. package/client/components/file-transfer/transports-action-store.jsx +14 -2
  8. package/client/components/session/sessions.jsx +1 -0
  9. package/client/components/setting-panel/setting-terminal.jsx +2 -1
  10. package/client/components/setting-sync/server-data-status.jsx +2 -1
  11. package/client/components/setting-sync/setting-sync-form.jsx +93 -15
  12. package/client/components/setting-sync/setting-sync.jsx +5 -1
  13. package/client/components/sftp/address-bookmark-item.jsx +1 -1
  14. package/client/components/sftp/address-bookmark.jsx +11 -1
  15. package/client/components/sftp/file-item.jsx +1 -1
  16. package/client/components/sidebar/transfer-history-modal.jsx +2 -2
  17. package/client/components/tabs/tabs.styl +2 -1
  18. package/client/components/tabs/window-control.jsx +2 -0
  19. package/client/components/terminal/reconnect-overlay.jsx +27 -0
  20. package/client/components/terminal/socket-close-warning.jsx +94 -0
  21. package/client/components/terminal/terminal.jsx +80 -46
  22. package/client/components/terminal/terminal.styl +12 -0
  23. package/client/components/terminal/transfer-client-base.js +38 -17
  24. package/client/components/terminal-info/network.jsx +0 -1
  25. package/client/store/sync.js +129 -3
  26. package/client/store/tab.js +2 -1
  27. package/client/store/transfer-list.js +3 -3
  28. package/package.json +1 -1
@@ -190,13 +190,15 @@ export const syncTypes = buildConst([
190
190
  'github',
191
191
  'gitee',
192
192
  'custom',
193
- 'cloud'
193
+ 'cloud',
194
+ 'webdav'
194
195
  ])
195
196
  export const syncTokenCreateUrls = {
196
197
  gitee: 'https://gitee.com/github-zxdong262/electerm/wikis/Create%20personal%20access%20token?sort_id=3028409',
197
198
  github: 'https://github.com/electerm/electerm/wiki/Create-personal-access-token',
198
199
  custom: 'https://github.com/electerm/electerm/wiki/Custom-sync-server',
199
- cloud: 'https://electerm-cloud.html5beta.com'
200
+ cloud: 'https://electerm-cloud.html5beta.com',
201
+ webdav: 'https://github.com/electerm/electerm/wiki/WebDAV-sync'
200
202
  }
201
203
  export const settingSyncId = 'setting-sync'
202
204
  export const settingTerminalId = 'setting-terminal'
@@ -72,6 +72,7 @@ export default {
72
72
  sessionLogPath: '',
73
73
  sshSftpSplitView: false,
74
74
  showCmdSuggestions: false,
75
+ autoReconnectTerminal: false,
75
76
  startDirectoryLocal: '',
76
77
  allowMultiInstance: false,
77
78
  disableDeveloperTool: false
@@ -34,6 +34,7 @@ const ftpConfig = {
34
34
  { type: 'password', name: 'password', label: () => e('password') },
35
35
  { type: 'switch', name: 'secure', label: () => e('secure'), valuePropName: 'checked' },
36
36
  commonFields.encode,
37
+ commonFields.proxy,
37
38
  commonFields.type
38
39
  ]
39
40
  }
@@ -31,25 +31,42 @@ export default class ConfirmModalStore extends Component {
31
31
  transferToConfirm: null
32
32
  }
33
33
  this.queue = []
34
+ this.queuedTransferIds = new Set()
35
+ this.activeTransferId = null
34
36
  this.id = 'transfer-conflict'
35
37
  refsStatic.add(this.id, this)
36
38
  }
37
39
 
38
40
  addConflict = (transfer) => {
41
+ const transferId = transfer?.id
42
+ if (!transferId) {
43
+ return
44
+ }
45
+ if (this.activeTransferId === transferId || this.queuedTransferIds.has(transferId)) {
46
+ return
47
+ }
39
48
  this.queue.push(transfer)
40
- if (!this.state.transferToConfirm) {
49
+ this.queuedTransferIds.add(transferId)
50
+ if (!this.activeTransferId) {
41
51
  this.showNext()
42
52
  }
43
53
  }
44
54
 
45
55
  showNext = () => {
46
56
  const next = this.queue.shift()
57
+ if (next?.id) {
58
+ this.queuedTransferIds.delete(next.id)
59
+ }
60
+ this.activeTransferId = next?.id || null
47
61
  this.setState({
48
62
  transferToConfirm: next
49
63
  })
50
64
  }
51
65
 
52
66
  act = (action) => {
67
+ if (!this.state.transferToConfirm) {
68
+ return
69
+ }
53
70
  const { id, transferBatch } = this.state.transferToConfirm
54
71
  const toAll = action.includes('All')
55
72
  const policy = toAll ? action.replace('All', '') : action
@@ -60,10 +77,17 @@ export default class ConfirmModalStore extends Component {
60
77
  if (doFilter) {
61
78
  // Update all existing transfers with same batch ID in DOM
62
79
  const prefix = `tr-${transferBatch}-`
80
+ const pendingConflictIds = new Set([
81
+ id,
82
+ ...this.queue
83
+ .filter(d => d.transferBatch === transferBatch)
84
+ .map(d => d.id)
85
+ ])
63
86
  for (const [key, r] of window.refsTransfers.entries()) {
64
87
  if (key.startsWith(prefix)) {
65
- if (key !== trid) {
66
- r.resolvePolicy = policy
88
+ r.resolvePolicy = policy
89
+ const transferId = r.props.transfer?.id
90
+ if (key !== trid && pendingConflictIds.has(transferId)) {
67
91
  r.onDecision(policy)
68
92
  }
69
93
  }
@@ -72,9 +96,11 @@ export default class ConfirmModalStore extends Component {
72
96
  }
73
97
 
74
98
  // Resolve current conflict
75
- refsTransfers.get(trid)?.onDecision(policy)
99
+ const currentTransfer = refsTransfers.get(trid)
100
+ currentTransfer?.onDecision(policy)
76
101
 
77
102
  // Move to the next item
103
+ this.activeTransferId = null
78
104
  this.setState({
79
105
  transferToConfirm: null
80
106
  }, this.showNext)
@@ -41,7 +41,12 @@ export default class Queue extends Component {
41
41
  return new Promise((resolve, reject) => {
42
42
  const { fileTransfers } = window.store
43
43
  const [id, updateObj] = args
44
+ let completed = false
44
45
  const end = () => {
46
+ if (completed) {
47
+ return
48
+ }
49
+ completed = true
45
50
  this.currentRun && this.currentRun.stop()
46
51
  resolve()
47
52
  }
@@ -99,10 +104,11 @@ export default class Queue extends Component {
99
104
  })()
100
105
  }
101
106
 
102
- // For non-transfer operations, check immediately
103
- // if (!isTransferInit) {
104
- // checkCompletion()
105
- // }
107
+ // Progress and delete updates are synchronous store mutations.
108
+ // Resolve them here so the queue cannot stall waiting for another reaction.
109
+ if (!isTransferInit) {
110
+ checkCompletion()
111
+ }
106
112
  }
107
113
 
108
114
  function checkCompletion () {
@@ -77,11 +77,11 @@ export default class TransportAction extends Component {
77
77
  }
78
78
 
79
79
  localCheckExist = (path) => {
80
- return getLocalFileInfo(path).catch(console.log)
80
+ return getLocalFileInfo(path)
81
+ .catch(() => null)
81
82
  }
82
83
 
83
84
  remoteCheckExist = (path, tabId) => {
84
- // return true
85
85
  const sftp = refs.get('sftp-' + tabId)?.sftp
86
86
  if (!sftp) {
87
87
  console.log('remoteCheckExist error', 'sftp not exist')
@@ -373,10 +373,12 @@ export default class TransportAction extends Component {
373
373
  toFile
374
374
  })
375
375
  if (transfer.resolvePolicy) {
376
- return this.onDecision(transfer.resolvePolicy)
376
+ this.onDecision(transfer.resolvePolicy)
377
+ return true
377
378
  }
378
379
  if (this.resolvePolicy) {
379
- return this.onDecision(this.resolvePolicy)
380
+ this.onDecision(this.resolvePolicy)
381
+ return true
380
382
  }
381
383
  const transferWithToFile = {
382
384
  ...copy(transfer),
@@ -386,6 +388,7 @@ export default class TransportAction extends Component {
386
388
  refsStatic.get('transfer-conflict')?.addConflict(transferWithToFile)
387
389
  return true
388
390
  }
391
+ return false
389
392
  }
390
393
 
391
394
  onDecision = (policy) => {
@@ -12,6 +12,11 @@ import { refsStatic } from '../common/ref'
12
12
  window.initingFtpTabIds = new Set()
13
13
 
14
14
  export default class TransportsActionStore extends Component {
15
+ constructor (props) {
16
+ super(props)
17
+ this.pendingInitIds = new Set()
18
+ }
19
+
15
20
  componentDidMount () {
16
21
  this.control()
17
22
  }
@@ -29,6 +34,12 @@ export default class TransportsActionStore extends Component {
29
34
  const {
30
35
  fileTransfers
31
36
  } = store
37
+ this.pendingInitIds = new Set(
38
+ Array.from(this.pendingInitIds).filter(id => {
39
+ const transfer = fileTransfers.find(t => t.id === id)
40
+ return transfer && transfer.inited !== true
41
+ })
42
+ )
32
43
 
33
44
  // First loop: Handle same type transfers
34
45
  for (const t of fileTransfers) {
@@ -57,7 +68,7 @@ export default class TransportsActionStore extends Component {
57
68
  inited,
58
69
  pausing
59
70
  } = t
60
- return typeTo !== typeFrom && inited && pausing !== true
71
+ return typeTo !== typeFrom && (inited || this.pendingInitIds.has(t.id)) && pausing !== true
61
72
  }).length
62
73
 
63
74
  if (count >= maxTransport) {
@@ -80,7 +91,7 @@ export default class TransportsActionStore extends Component {
80
91
 
81
92
  const isTransfer = typeTo !== typeFrom
82
93
 
83
- if (inited || !isTransfer) {
94
+ if (inited || this.pendingInitIds.has(id) || !isTransfer) {
84
95
  continue
85
96
  }
86
97
 
@@ -95,6 +106,7 @@ export default class TransportsActionStore extends Component {
95
106
 
96
107
  if (count < maxTransport) {
97
108
  count++
109
+ this.pendingInitIds.add(id)
98
110
  refsStatic.get('transfer-queue')?.addToQueue(
99
111
  'update',
100
112
  id,
@@ -11,6 +11,7 @@ import pixed from '../layout/pixed'
11
11
  export default class Sessions extends Component {
12
12
  // Function to reload a tab using store.reloadTab
13
13
  reloadTab = (tab) => {
14
+ window.store.updateTab(tab.id, tab)
14
15
  window.store.reloadTab(tab.id)
15
16
  }
16
17
 
@@ -589,7 +589,8 @@ export default class SettingTerminal extends Component {
589
589
  'ctrlOrMetaOpenTerminalLink',
590
590
  'sftpPathFollowSsh',
591
591
  'sshSftpSplitView',
592
- 'showCmdSuggestions'
592
+ 'showCmdSuggestions',
593
+ 'autoReconnectTerminal'
593
594
  ].map(d => this.renderToggle(d))
594
595
  }
595
596
  <div className='pd1b'>{e('terminalBackSpaceMode')}</div>
@@ -1,3 +1,4 @@
1
+ import { syncTypes } from '../../common/constants'
1
2
  import { useState } from 'react'
2
3
  import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
3
4
  import dayjs from 'dayjs'
@@ -10,7 +11,7 @@ export default function ServerDataStatus (props) {
10
11
  const [loading, setLoading] = useState(false)
11
12
  const token = store.getSyncToken(type)
12
13
  const gistId = store.getSyncGistId(type)
13
- const canSync = token && (gistId || type === 'custom' || type === 'cloud')
14
+ const canSync = token && (gistId || type === 'custom' || type === 'cloud' || type === syncTypes.webdav)
14
15
 
15
16
  async function handleReload () {
16
17
  setLoading(true)
@@ -41,6 +41,10 @@ export default function SyncForm (props) {
41
41
  if (syncType === syncTypes.cloud) {
42
42
  return !props.formData.token
43
43
  }
44
+ if (syncType === syncTypes.webdav) {
45
+ const { serverUrl, username, password } = props.formData
46
+ return !serverUrl || !username || !password
47
+ }
44
48
  const {
45
49
  token,
46
50
  gistId
@@ -70,6 +74,14 @@ export default function SyncForm (props) {
70
74
  } else {
71
75
  up[syncType + 'Proxy'] = ''
72
76
  }
77
+
78
+ // Handle WebDAV specific fields
79
+ if (syncType === syncTypes.webdav) {
80
+ up[syncType + 'ServerUrl'] = res.serverUrl || ''
81
+ up[syncType + 'Username'] = res.username || ''
82
+ up[syncType + 'Password'] = res.password || ''
83
+ }
84
+
73
85
  window.store.updateSyncSetting(up)
74
86
  const test = await window.store.testSyncToken(syncType, res.gistId)
75
87
  if (isError(test)) {
@@ -180,6 +192,9 @@ export default function SyncForm (props) {
180
192
  </p>
181
193
  )
182
194
  }
195
+ if (syncType === syncTypes.webdav) {
196
+ return createWebdavItems()
197
+ }
183
198
  if (syncType !== syncTypes.custom) {
184
199
  return null
185
200
  }
@@ -199,18 +214,73 @@ export default function SyncForm (props) {
199
214
  </FormItem>
200
215
  )
201
216
  }
217
+ function createWebdavItems () {
218
+ return (
219
+ <div>
220
+ <FormItem
221
+ label={createLabel('URL')}
222
+ name='serverUrl'
223
+ normalize={trim}
224
+ rules={[{
225
+ max: 500, message: '500 chars max'
226
+ }, {
227
+ required: true, message: 'Server URL is required'
228
+ }]}
229
+ >
230
+ <Input
231
+ placeholder='https://your-webdav-server.com/remote.php/dav/files/username'
232
+ id='sync-input-webdav-server-url'
233
+ />
234
+ </FormItem>
235
+ <FormItem
236
+ label={createLabel(e('username'))}
237
+ name='username'
238
+ normalize={trim}
239
+ rules={[{
240
+ max: 200, message: '200 chars max'
241
+ }, {
242
+ required: true, message: 'Username is required'
243
+ }]}
244
+ >
245
+ <Input
246
+ placeholder='WebDAV username'
247
+ id='sync-input-webdav-username'
248
+ />
249
+ </FormItem>
250
+ <FormItem
251
+ label={createLabel(e('password'))}
252
+ name='password'
253
+ normalize={trim}
254
+ rules={[{
255
+ max: 200, message: '200 chars max'
256
+ }, {
257
+ required: true, message: 'Password is required'
258
+ }]}
259
+ >
260
+ <Password
261
+ placeholder='WebDAV password'
262
+ id='sync-input-webdav-password'
263
+ />
264
+ </FormItem>
265
+ </div>
266
+ )
267
+ }
202
268
  const desc = syncType === syncTypes.custom
203
269
  ? 'jwt secret'
204
- : 'personal access token'
270
+ : syncType === syncTypes.webdav
271
+ ? 'WebDAV credentials'
272
+ : 'personal access token'
205
273
  const idDesc = syncType === syncTypes.custom
206
274
  ? 'user id'
207
- : 'gist ID'
275
+ : syncType === syncTypes.webdav
276
+ ? 'WebDAV server'
277
+ : 'gist ID'
208
278
  const tokenLabel = createLabel('token', desc)
209
279
  const gistLabel = createLabel('gist', idDesc)
210
280
  const syncPasswordName = e('encrypt') + ' ' + e('password')
211
281
  const syncPasswordLabel = createLabel(syncPasswordName, '')
212
282
  function createIdItem () {
213
- if (syncType === syncTypes.cloud) {
283
+ if (syncType === syncTypes.cloud || syncType === syncTypes.webdav) {
214
284
  return null
215
285
  }
216
286
  return (
@@ -231,7 +301,7 @@ export default function SyncForm (props) {
231
301
  )
232
302
  }
233
303
  function createPasswordItem () {
234
- if (syncType === syncTypes.cloud) {
304
+ if (syncType === syncTypes.cloud || syncType === syncTypes.webdav) {
235
305
  return null
236
306
  }
237
307
  return (
@@ -271,17 +341,11 @@ export default function SyncForm (props) {
271
341
  type: syncType,
272
342
  status: props.serverStatus
273
343
  }
274
- return (
275
- <Form
276
- onFinish={save}
277
- form={form}
278
- className='form-wrap pd1x'
279
- name={'setting-sync-form' + syncType}
280
- layout='vertical'
281
- initialValues={props.formData}
282
- >
283
- {renderWarning()}
284
- {createUrlItem()}
344
+ function createTokenItem () {
345
+ if (syncType === syncTypes.webdav) {
346
+ return null
347
+ }
348
+ return (
285
349
  <FormItem
286
350
  label={tokenLabel}
287
351
  hasFeedback
@@ -298,6 +362,20 @@ export default function SyncForm (props) {
298
362
  id={createId('token')}
299
363
  />
300
364
  </FormItem>
365
+ )
366
+ }
367
+ return (
368
+ <Form
369
+ onFinish={save}
370
+ form={form}
371
+ className='form-wrap pd1x'
372
+ name={'setting-sync-form' + syncType}
373
+ layout='vertical'
374
+ initialValues={props.formData}
375
+ >
376
+ {renderWarning()}
377
+ {createUrlItem()}
378
+ {createTokenItem()}
301
379
  {
302
380
  createIdItem()
303
381
  }
@@ -44,7 +44,11 @@ export default auto(function SyncSettingEntry (props) {
44
44
  apiUrl: syncSetting[type + 'ApiUrl'],
45
45
  lastSyncTime: syncSetting[type + 'LastSyncTime'],
46
46
  syncPassword: syncSetting[type + 'SyncPassword'],
47
- proxy: syncSetting[type + 'Proxy']
47
+ proxy: syncSetting[type + 'Proxy'],
48
+ // WebDAV specific fields
49
+ serverUrl: syncSetting[type + 'ServerUrl'],
50
+ username: syncSetting[type + 'Username'],
51
+ password: syncSetting[type + 'Password']
48
52
  }
49
53
  return (
50
54
  <SyncForm
@@ -68,7 +68,7 @@ export default class AddrBookmarkItem extends Component {
68
68
  onDrop={this.handleDrop}
69
69
  >
70
70
  {globTag}
71
- <b>{item.addr}</b>
71
+ <b className='mg1l'>{item.addr}</b>
72
72
  <CloseCircleOutlined
73
73
  className='del-addr-bookmark'
74
74
  onClick={this.handleDel}
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react'
1
2
  import { auto } from 'manate/react'
2
3
  import {
3
4
  StarOutlined,
@@ -13,6 +14,8 @@ import uid from '../../common/uid'
13
14
  import './address-bookmark.styl'
14
15
 
15
16
  export default auto(function AddrBookmark (props) {
17
+ const [open, setOpen] = useState(false)
18
+
16
19
  function onDel (item) {
17
20
  window.store.delAddressBookmark(item)
18
21
  }
@@ -39,6 +42,11 @@ export default auto(function AddrBookmark (props) {
39
42
  handleAddAddrAct(true)
40
43
  }
41
44
 
45
+ function handleClick (type, addr) {
46
+ setOpen(false)
47
+ onClickHistory(type, addr)
48
+ }
49
+
42
50
  const { type, onClickHistory, host } = props
43
51
  const { store } = window
44
52
  // const cls = classnames(
@@ -58,7 +66,7 @@ export default auto(function AddrBookmark (props) {
58
66
  ? addrs.map(o => {
59
67
  return (
60
68
  <AddrBookmarkItem
61
- handleClick={onClickHistory}
69
+ handleClick={handleClick}
62
70
  type={type}
63
71
  key={o.id}
64
72
  handleDel={onDel}
@@ -100,6 +108,8 @@ export default auto(function AddrBookmark (props) {
100
108
  title={title}
101
109
  placement='bottom'
102
110
  trigger='click'
111
+ open={open}
112
+ onOpenChange={setOpen}
103
113
  >
104
114
  <StarOutlined className={props.className || ''} />
105
115
  </Popover>
@@ -998,7 +998,7 @@ export default class FileSection extends React.Component {
998
998
  const shouldShowSelectedMenu = id &&
999
999
  len > 1 &&
1000
1000
  selectedFiles.has(id)
1001
- const delTxt = shouldShowSelectedMenu ? `${e('deleteAll')}(${len})` : e('del')
1001
+ const delTxt = shouldShowSelectedMenu ? `${e('del')}:${e('selected')}(${len})` : e('del')
1002
1002
  const canPaste = hasFileInClipboardText()
1003
1003
  const showEdit = !isDirectory && id &&
1004
1004
  size < maxEditFileSize
@@ -98,8 +98,8 @@ export default memo(function TransferHistoryModal (props) {
98
98
  pageSize,
99
99
  showSizeChanger: true,
100
100
  pageSizeOptions: [5, 10, 20, 50, 100],
101
- onChange: handlePageSizeChange,
102
- position: ['topRight']
101
+ placement: 'topEnd',
102
+ onChange: handlePageSizeChange
103
103
  },
104
104
  size: 'small',
105
105
  rowKey: 'id'
@@ -167,7 +167,8 @@
167
167
  z-index 900
168
168
  border-radius 0 0 3px 3px
169
169
  padding 0
170
- background var(--main-lighter)
170
+ background var(--main)
171
+ -webkit-app-region no-drag
171
172
 
172
173
  .window-control-box
173
174
  display inline-block
@@ -23,9 +23,11 @@ export default auto(function WindowControl (props) {
23
23
  }
24
24
  const maximize = () => {
25
25
  window.pre.runGlobalAsync('maximize')
26
+ window.store.isMaximized = true
26
27
  }
27
28
  const unmaximize = () => {
28
29
  window.pre.runGlobalAsync('unmaximize')
30
+ window.store.isMaximized = false
29
31
  }
30
32
  const closeApp = () => {
31
33
  window.store.exit()
@@ -0,0 +1,27 @@
1
+ import { memo } from 'react'
2
+ import { Button } from 'antd'
3
+ import { LoadingOutlined } from '@ant-design/icons'
4
+
5
+ const e = window.translate
6
+
7
+ export default memo(function ReconnectOverlay ({ countdown, onCancel }) {
8
+ if (countdown === null || countdown === undefined) {
9
+ return null
10
+ }
11
+ return (
12
+ <div className='terminal-reconnect-overlay'>
13
+ <div className='terminal-reconnect-box'>
14
+ <LoadingOutlined className='terminal-reconnect-icon' />
15
+ <div className='terminal-reconnect-msg'>
16
+ {e('autoReconnectTerminal')}: {countdown}s
17
+ </div>
18
+ <Button
19
+ size='small'
20
+ onClick={onCancel}
21
+ >
22
+ {e('cancel')}
23
+ </Button>
24
+ </div>
25
+ </div>
26
+ )
27
+ })
@@ -0,0 +1,94 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { Button } from 'antd'
3
+ import { ReloadOutlined } from '@ant-design/icons'
4
+ import { notification } from '../common/notification'
5
+
6
+ const e = window.translate
7
+ const COUNTDOWN_SECONDS = 3
8
+
9
+ export function showSocketCloseWarning ({
10
+ tabId,
11
+ tab,
12
+ autoReconnect,
13
+ delTab,
14
+ reloadTab
15
+ }) {
16
+ const key = `open${Date.now()}`
17
+
18
+ function closeNotification () {
19
+ notification.destroy(key)
20
+ }
21
+
22
+ const descriptionNode = (
23
+ <SocketCloseDescription
24
+ autoReconnect={autoReconnect}
25
+ onClose={() => {
26
+ closeNotification()
27
+ delTab(tabId)
28
+ }}
29
+ onReload={() => {
30
+ closeNotification()
31
+ reloadTab({ ...tab, autoReConnect: (tab.autoReConnect || 0) + 1 })
32
+ }}
33
+ />
34
+ )
35
+
36
+ notification.warning({
37
+ key,
38
+ message: e('socketCloseTip'),
39
+ duration: autoReconnect ? COUNTDOWN_SECONDS + 2 : 30,
40
+ description: descriptionNode
41
+ })
42
+
43
+ return { key }
44
+ }
45
+
46
+ function SocketCloseDescription ({ autoReconnect, onClose, onReload }) {
47
+ const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
48
+ const timerRef = useRef(null)
49
+
50
+ useEffect(() => {
51
+ if (!autoReconnect) {
52
+ return
53
+ }
54
+ timerRef.current = setInterval(() => {
55
+ setCountdown(prev => {
56
+ if (prev <= 1) {
57
+ clearInterval(timerRef.current)
58
+ onReload()
59
+ return 0
60
+ }
61
+ return prev - 1
62
+ })
63
+ }, 1000)
64
+
65
+ return () => {
66
+ if (timerRef.current) {
67
+ clearInterval(timerRef.current)
68
+ }
69
+ }
70
+ }, [autoReconnect])
71
+
72
+ return (
73
+ <div className='pd2y'>
74
+ {autoReconnect && (
75
+ <div className='pd1b'>
76
+ {e('autoReconnectTerminal')}: {countdown}s
77
+ </div>
78
+ )}
79
+ <Button
80
+ className='mg1r'
81
+ type='primary'
82
+ onClick={onClose}
83
+ >
84
+ {e('close')}
85
+ </Button>
86
+ <Button
87
+ icon={<ReloadOutlined />}
88
+ onClick={onReload}
89
+ >
90
+ {e('reload')}
91
+ </Button>
92
+ </div>
93
+ )
94
+ }
@@ -3,12 +3,8 @@ import { handleErr } from '../../common/fetch.jsx'
3
3
  import { isEqual, pick, debounce, throttle } from 'lodash-es'
4
4
  import clone from '../../common/to-simple-obj.js'
5
5
  import resolve from '../../common/resolve.js'
6
- import {
7
- ReloadOutlined
8
- } from '@ant-design/icons'
9
6
  import {
10
7
  Spin,
11
- Button,
12
8
  Dropdown
13
9
  } from 'antd'
14
10
  import { notification } from '../common/notification'
@@ -50,6 +46,8 @@ import ExternalLink from '../common/external-link.jsx'
50
46
  import createDefaultLogPath from '../../common/default-log-path.js'
51
47
  import SearchResultBar from './terminal-search-bar'
52
48
  import RemoteFloatControl from '../common/remote-float-control'
49
+ import { showSocketCloseWarning } from './socket-close-warning.jsx'
50
+ import ReconnectOverlay from './reconnect-overlay.jsx'
53
51
  import {
54
52
  loadTerminal,
55
53
  loadFitAddon,
@@ -76,7 +74,8 @@ class Term extends Component {
76
74
  lines: [],
77
75
  searchResults: [],
78
76
  matchIndex: -1,
79
- totalLines: 0
77
+ totalLines: 0,
78
+ reconnectCountdown: null
80
79
  }
81
80
  this.id = `term-${this.props.tab.id}`
82
81
  refs.add(this.id, this)
@@ -1139,10 +1138,13 @@ class Term extends Component {
1139
1138
  ? typeMap.remote
1140
1139
  : typeMap.local
1141
1140
  })
1141
+ const isAutoReconnect = !!(tab.autoReConnect && this.props.config.autoReconnectTerminal)
1142
1142
  const r = await createTerm(opts)
1143
1143
  .catch(err => {
1144
- const text = err.message
1145
- handleErr({ message: text })
1144
+ if (!isAutoReconnect) {
1145
+ const text = err.message
1146
+ handleErr({ message: text })
1147
+ }
1146
1148
  })
1147
1149
  if (typeof r === 'string' && r.includes('fail')) {
1148
1150
  return this.promote()
@@ -1154,6 +1156,10 @@ class Term extends Component {
1154
1156
  loading: false
1155
1157
  })
1156
1158
  if (!r) {
1159
+ if (isAutoReconnect) {
1160
+ this.scheduleAutoReconnect(3000)
1161
+ return
1162
+ }
1157
1163
  this.setStatus(statusMap.error)
1158
1164
  return
1159
1165
  }
@@ -1254,46 +1260,70 @@ class Term extends Component {
1254
1260
  this.setStatus(
1255
1261
  statusMap.error
1256
1262
  )
1257
- if (!this.isActiveTerminal() || !window.focused) {
1258
- return false
1259
- }
1260
1263
  if (this.userTypeExit) {
1261
1264
  return this.props.delTab(this.props.tab.id)
1262
1265
  }
1263
- const key = `open${Date.now()}`
1264
- function closeMsg () {
1265
- notification.destroy(key)
1266
- }
1267
- this.socketCloseWarning = notification.warning({
1268
- key,
1269
- message: e('socketCloseTip'),
1270
- duration: 30,
1271
- description: (
1272
- <div className='pd2y'>
1273
- <Button
1274
- className='mg1r'
1275
- type='primary'
1276
- onClick={() => {
1277
- closeMsg()
1278
- this.props.delTab(this.props.tab.id)
1279
- }}
1280
- >
1281
- {e('close')}
1282
- </Button>
1283
- <Button
1284
- icon={<ReloadOutlined />}
1285
- onClick={() => {
1286
- closeMsg()
1287
- this.props.reloadTab(
1288
- this.props.tab
1289
- )
1290
- }}
1291
- >
1292
- {e('reload')}
1293
- </Button>
1294
- </div>
1295
- )
1296
- })
1266
+ const { autoReconnectTerminal } = this.props.config
1267
+ const isActive = this.isActiveTerminal()
1268
+ const isFocused = window.focused
1269
+ if (autoReconnectTerminal) {
1270
+ if (isActive && isFocused) {
1271
+ this.socketCloseWarning = showSocketCloseWarning({
1272
+ tabId: this.props.tab.id,
1273
+ tab: this.props.tab,
1274
+ autoReconnect: true,
1275
+ delTab: this.props.delTab,
1276
+ reloadTab: this.props.reloadTab
1277
+ })
1278
+ } else {
1279
+ this.scheduleAutoReconnect(3000)
1280
+ }
1281
+ } else {
1282
+ if (!isActive || !isFocused) {
1283
+ return false
1284
+ }
1285
+ this.socketCloseWarning = showSocketCloseWarning({
1286
+ tabId: this.props.tab.id,
1287
+ tab: this.props.tab,
1288
+ autoReconnect: false,
1289
+ delTab: this.props.delTab,
1290
+ reloadTab: this.props.reloadTab
1291
+ })
1292
+ }
1293
+ }
1294
+
1295
+ scheduleAutoReconnect = (delay = 3000) => {
1296
+ clearTimeout(this.timers.reconnectTimer)
1297
+ clearInterval(this.timers.reconnectCountdown)
1298
+ const seconds = Math.round(delay / 1000)
1299
+ this.setState({ reconnectCountdown: seconds })
1300
+ let remaining = seconds
1301
+ this.timers.reconnectCountdown = setInterval(() => {
1302
+ remaining -= 1
1303
+ if (remaining <= 0) {
1304
+ clearInterval(this.timers.reconnectCountdown)
1305
+ this.timers.reconnectCountdown = null
1306
+ }
1307
+ this.setState({ reconnectCountdown: remaining <= 0 ? null : remaining })
1308
+ }, 1000)
1309
+ this.timers.reconnectTimer = setTimeout(() => {
1310
+ clearInterval(this.timers.reconnectCountdown)
1311
+ this.timers.reconnectCountdown = null
1312
+ this.setState({ reconnectCountdown: null })
1313
+ if (this.onClose || !this.props.config.autoReconnectTerminal) {
1314
+ return
1315
+ }
1316
+ const reconnectCount = (this.props.tab.autoReConnect || 0) + 1
1317
+ this.props.reloadTab({ ...this.props.tab, autoReConnect: reconnectCount })
1318
+ }, delay)
1319
+ }
1320
+
1321
+ handleCancelAutoReconnect = () => {
1322
+ clearTimeout(this.timers.reconnectTimer)
1323
+ clearInterval(this.timers.reconnectCountdown)
1324
+ this.timers.reconnectTimer = null
1325
+ this.timers.reconnectCountdown = null
1326
+ this.setState({ reconnectCountdown: null })
1297
1327
  }
1298
1328
 
1299
1329
  batchInput = (cmd) => {
@@ -1395,7 +1425,7 @@ class Term extends Component {
1395
1425
  totalLines: this.state.totalLines,
1396
1426
  height
1397
1427
  }
1398
- const spinCls = loading ? 'loading-wrapper' : 'hide'
1428
+ const spin = loading ? <Spin className='loading-wrapper' spinning={loading} /> : null
1399
1429
  return (
1400
1430
  <Dropdown {...dropdownProps}>
1401
1431
  <div
@@ -1412,7 +1442,11 @@ class Term extends Component {
1412
1442
  <RemoteFloatControl
1413
1443
  isFullScreen={fullscreen}
1414
1444
  />
1415
- <Spin className={spinCls} spinning={loading} />
1445
+ <ReconnectOverlay
1446
+ countdown={this.state.reconnectCountdown}
1447
+ onCancel={this.handleCancelAutoReconnect}
1448
+ />
1449
+ {spin}
1416
1450
  </div>
1417
1451
  </Dropdown>
1418
1452
  )
@@ -141,3 +141,15 @@
141
141
  .paste-text
142
142
  max-height 200px
143
143
  overflow-y scroll
144
+
145
+ .terminal-reconnect-overlay
146
+ position absolute
147
+ left 0
148
+ top 0
149
+ width 100%
150
+ height 100%
151
+ display flex
152
+ align-items center
153
+ justify-content center
154
+ z-index 110
155
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  // import { transferTypeMap } from '../../common/constants.js'
7
- import { safeGetItem, safeSetItem } from '../../common/safe-local-storage.js'
7
+ import { getItem, setItem } from '../../common/safe-local-storage.js'
8
8
  import { getLocalFileInfo } from '../sftp/file-read.js'
9
9
 
10
10
  /**
@@ -176,6 +176,8 @@ export class TransferClientBase {
176
176
 
177
177
  /**
178
178
  * Open file select dialog
179
+ * Supports window._apiControlSelectFile for e2e testing
180
+ * Set window._apiControlSelectFile = ['/path/to/file1', '/path/to/file2'] to bypass native dialog
179
181
  * @param {Object} options - Options for file selection
180
182
  * @returns {Promise<Array>} - Selected files
181
183
  */
@@ -186,20 +188,28 @@ export class TransferClientBase {
186
188
  message = 'Choose some files to send'
187
189
  } = options
188
190
 
189
- const properties = [
190
- directory ? 'openDirectory' : 'openFile',
191
- 'multiSelections',
192
- 'showHiddenFiles',
193
- 'noResolveAliases',
194
- 'treatPackageAsDirectory',
195
- 'dontAddToRecent'
196
- ]
197
-
198
- const files = await window.api.openDialog({
199
- title,
200
- message,
201
- properties
202
- }).catch(() => false)
191
+ let files
192
+ if (window._apiControlSelectFile) {
193
+ files = Array.isArray(window._apiControlSelectFile)
194
+ ? window._apiControlSelectFile
195
+ : [window._apiControlSelectFile]
196
+ delete window._apiControlSelectFile
197
+ } else {
198
+ const properties = [
199
+ directory ? 'openDirectory' : 'openFile',
200
+ 'multiSelections',
201
+ 'showHiddenFiles',
202
+ 'noResolveAliases',
203
+ 'treatPackageAsDirectory',
204
+ 'dontAddToRecent'
205
+ ]
206
+
207
+ files = await window.api.openDialog({
208
+ title,
209
+ message,
210
+ properties
211
+ }).catch(() => false)
212
+ }
203
213
 
204
214
  if (!files || !files.length) {
205
215
  return null
@@ -215,11 +225,22 @@ export class TransferClientBase {
215
225
 
216
226
  /**
217
227
  * Open save folder select dialog
228
+ * Supports window._apiControlSelectFolder for e2e testing
229
+ * Set window._apiControlSelectFolder = '/path/to/folder' to bypass native dialog
218
230
  * @returns {Promise<string>} - Selected folder path
219
231
  */
220
232
  openSaveFolderSelect = async () => {
233
+ if (window._apiControlSelectFolder) {
234
+ const folder = window._apiControlSelectFolder
235
+ delete window._apiControlSelectFolder
236
+ if (this.storageKey) {
237
+ setItem(this.storageKey, folder)
238
+ }
239
+ return folder
240
+ }
241
+
221
242
  // Try to use last saved path
222
- const lastPath = this.storageKey ? safeGetItem(this.storageKey) : null
243
+ const lastPath = this.storageKey ? getItem(this.storageKey) : null
223
244
 
224
245
  const savePaths = await window.api.openDialog({
225
246
  title: 'Choose a folder to save file(s)',
@@ -240,7 +261,7 @@ export class TransferClientBase {
240
261
  }
241
262
 
242
263
  if (this.storageKey) {
243
- safeSetItem(this.storageKey, savePaths[0])
264
+ setItem(this.storageKey, savePaths[0])
244
265
  }
245
266
  return savePaths[0]
246
267
  }
@@ -92,7 +92,6 @@ export default function TerminalInfoDisk (props) {
92
92
  },
93
93
  render: (v) => {
94
94
  if (k === 'up' || k === 'down') {
95
- console.log('render traffic', k, v)
96
95
  return filesize(v || 0)
97
96
  }
98
97
  return v
@@ -67,6 +67,13 @@ export default (Store) => {
67
67
  }
68
68
  ).join('####')
69
69
  }
70
+ if (type === syncTypes.webdav) {
71
+ // WebDAV token format: serverUrl####username####password
72
+ const serverUrl = get(window.store.config, 'syncSetting.webdavServerUrl')
73
+ const username = get(window.store.config, 'syncSetting.webdavUsername')
74
+ const password = get(window.store.config, 'syncSetting.webdavPassword')
75
+ return [serverUrl, username, password].join('####')
76
+ }
70
77
  return get(window.store.config, 'syncSetting.' + type + 'AccessToken')
71
78
  }
72
79
 
@@ -158,7 +165,13 @@ export default (Store) => {
158
165
  const types = Object.keys(syncTypes)
159
166
  for (const type of types) {
160
167
  const gistId = store.getSyncGistId(type)
161
- if (gistId) {
168
+ // For WebDAV, check if server URL is configured
169
+ if (type === syncTypes.webdav) {
170
+ const serverUrl = get(window.store.config, 'syncSetting.webdavServerUrl')
171
+ if (serverUrl) {
172
+ await store.uploadSetting(type)
173
+ }
174
+ } else if (gistId) {
162
175
  await store.uploadSetting(type)
163
176
  }
164
177
  }
@@ -183,6 +196,24 @@ export default (Store) => {
183
196
  const { store } = window
184
197
  const token = store.getSyncToken(type)
185
198
  const gistId = store.getSyncGistId(type)
199
+
200
+ // Handle WebDAV preview differently
201
+ if (type === syncTypes.webdav) {
202
+ const gist = await fetchData(
203
+ type,
204
+ 'download',
205
+ [],
206
+ token,
207
+ store.getSyncProxy(type)
208
+ )
209
+ if (gist && gist.files) {
210
+ const statusContent = get(gist, 'files["electerm-status.json"].content')
211
+ const status = statusContent ? parseJsonSafe(statusContent) : undefined
212
+ store.syncServerStatus[type] = status
213
+ }
214
+ return
215
+ }
216
+
186
217
  const gist = await fetchData(
187
218
  type,
188
219
  'getOne',
@@ -201,7 +232,7 @@ export default (Store) => {
201
232
  // await store.createGist(type)
202
233
  // gistId = store.getSyncGistId(type)
203
234
  // }
204
- if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud) {
235
+ if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud && type !== syncTypes.webdav) {
205
236
  return
206
237
  }
207
238
  const pass = store.getSyncPassword(type)
@@ -244,6 +275,31 @@ export default (Store) => {
244
275
  electermVersion: packVer,
245
276
  deviceName: window.pre.osInfo().find(r => r.k === 'hostname')?.v || 'unknown'
246
277
  }
278
+
279
+ // Handle WebDAV upload differently
280
+ if (type === syncTypes.webdav) {
281
+ const uploadData = {}
282
+ for (const [key, value] of Object.entries(objs)) {
283
+ uploadData[key] = value.content
284
+ }
285
+ uploadData['electerm-status.json'] = JSON.stringify(status)
286
+
287
+ const res = await fetchData(
288
+ type,
289
+ 'upload',
290
+ [uploadData],
291
+ token,
292
+ store.getSyncProxy(type)
293
+ )
294
+ if (res && !res.error) {
295
+ store.updateSyncSetting({
296
+ [type + 'LastSyncTime']: now
297
+ })
298
+ store.syncServerStatus[type] = status
299
+ }
300
+ return
301
+ }
302
+
247
303
  const gistData = {
248
304
  description: 'sync electerm data',
249
305
  files: {
@@ -285,10 +341,80 @@ export default (Store) => {
285
341
  // await store.createGist(type)
286
342
  // gistId = store.getSyncGistId(type)
287
343
  // }
288
- if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud) {
344
+ if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud && type !== syncTypes.webdav) {
289
345
  return
290
346
  }
291
347
  const pass = store.getSyncPassword(type)
348
+
349
+ // Handle WebDAV download differently
350
+ if (type === syncTypes.webdav) {
351
+ const gist = await fetchData(
352
+ type,
353
+ 'download',
354
+ [],
355
+ token,
356
+ store.getSyncProxy(type)
357
+ )
358
+ if (gist && gist.files) {
359
+ const statusContent = get(gist, 'files["electerm-status.json"].content')
360
+ const status = statusContent ? parseJsonSafe(statusContent) : undefined
361
+ store.syncServerStatus[type] = status
362
+
363
+ const { names, syncConfig } = store.getDataSyncNames()
364
+ for (const n of names) {
365
+ let str = get(gist, `files["${n}.json"].content`)
366
+ if (!str) {
367
+ if (n === settingMap.bookmarks) {
368
+ throw new Error(('Seems you have a empty WebDAV folder, you can try upload first'))
369
+ } else {
370
+ continue
371
+ }
372
+ }
373
+ if (!isJSON(str)) {
374
+ str = await window.pre.runGlobalAsync('decryptAsync', str, pass)
375
+ }
376
+ let arr = JSON.parse(str)
377
+ if (n === settingMap.terminalThemes) {
378
+ arr = store.fixThemes(arr)
379
+ } else if (n === settingMap.bookmarks) {
380
+ arr = fixBookmarks(arr)
381
+ }
382
+ let strOrder = get(gist, `files["${n}.order.json"].content`)
383
+ if (isJSON(strOrder)) {
384
+ strOrder = JSON.parse(strOrder)
385
+ arr.sort((a, b) => {
386
+ const ai = strOrder.findIndex(r => r === a.id)
387
+ const bi = strOrder.findIndex(r => r === b.id)
388
+ return ai - bi
389
+ })
390
+ }
391
+ store.setItems(n, arr)
392
+ }
393
+ if (syncConfig) {
394
+ const userConfig = parseJsonSafe(
395
+ get(gist, 'files["userConfig.json"].content')
396
+ )
397
+ if (userConfig) {
398
+ store.setConfig(userConfig)
399
+ }
400
+ if (userConfig && userConfig.theme) {
401
+ store.setTheme(userConfig.theme)
402
+ }
403
+ }
404
+
405
+ const up = {
406
+ [type + 'LastSyncTime']: Date.now()
407
+ }
408
+ if (pass) {
409
+ up[type + 'SyncPassword'] = pass
410
+ }
411
+ store.updateSyncSetting(up)
412
+ }
413
+ store.isSyncingSetting = false
414
+ store.isSyncDownload = false
415
+ return
416
+ }
417
+
292
418
  const gist = await fetchData(
293
419
  type,
294
420
  'getOne',
@@ -505,7 +505,8 @@ export default Store => {
505
505
  'sftpCreated',
506
506
  'sshSftpSplitView',
507
507
  'sshTunnelResults',
508
- 'displayRaw'
508
+ 'displayRaw',
509
+ 'autoReConnect'
509
510
  ]
510
511
  const { history } = store
511
512
  const index = history.findIndex(d => {
@@ -21,13 +21,13 @@ export default Store => {
21
21
  }
22
22
 
23
23
  Store.prototype.addTransferList = function (items) {
24
- // console.log('addTransferList', JSON.stringify(items, null, 2))
25
24
  const { fileTransfers } = window.store
26
25
  const transferBatch = uid()
27
- fileTransfers.push(...items.map(t => {
26
+ const nextItems = items.map(t => {
28
27
  t.transferBatch = transferBatch
29
28
  return t
30
- }))
29
+ })
30
+ fileTransfers.push(...nextItems)
31
31
  }
32
32
 
33
33
  Store.prototype.pauseAll = function () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.0.6",
3
+ "version": "3.1.6",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",