@electerm/electerm-react 3.8.8 → 3.9.5

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 (33) hide show
  1. package/client/common/parse-quick-connect.js +9 -1
  2. package/client/common/pre.js +0 -1
  3. package/client/components/ai/ai-config-modal.jsx +52 -0
  4. package/client/components/ai/ai-config.jsx +5 -38
  5. package/client/components/ai/get-brand.js +0 -11
  6. package/client/components/bg/custom-css.jsx +2 -1
  7. package/client/components/bookmark-form/bookmark-from-history-modal.jsx +0 -1
  8. package/client/components/bookmark-form/config/rdp.js +4 -2
  9. package/client/components/icons/heartbeat.jsx +23 -0
  10. package/client/components/main/main.jsx +5 -1
  11. package/client/components/session/session.jsx +3 -3
  12. package/client/components/setting-sync/setting-sync-form.jsx +9 -1
  13. package/client/components/setting-sync/setting-sync.jsx +2 -1
  14. package/client/components/sftp/list-table-ui.jsx +29 -54
  15. package/client/components/sftp/paged-list.jsx +44 -44
  16. package/client/components/sftp/sftp-entry.jsx +5 -4
  17. package/client/components/sidebar/info-modal.jsx +8 -2
  18. package/client/components/terminal/reconnect-overlay.jsx +2 -15
  19. package/client/components/terminal/terminal-error-handle.jsx +43 -0
  20. package/client/components/terminal/terminal.jsx +38 -38
  21. package/client/components/terminal/terminal.styl +12 -7
  22. package/client/components/terminal/unix-timestamp-tooltip.jsx +85 -0
  23. package/client/components/tree-list/bookmark-toolbar.jsx +9 -71
  24. package/client/components/tree-list/bookmark-upload.js +106 -0
  25. package/client/components/widgets/widget-control.jsx +1 -0
  26. package/client/store/common.js +3 -11
  27. package/client/store/init-state.js +1 -0
  28. package/client/store/load-data.js +2 -2
  29. package/client/store/sync.js +3 -2
  30. package/client/store/tab.js +20 -0
  31. package/package.json +1 -1
  32. package/client/components/ai/providers.js +0 -14
  33. package/client/components/terminal/socket-close-warning.jsx +0 -94
@@ -21,6 +21,12 @@
21
21
 
22
22
  const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm']
23
23
 
24
+ /**
25
+ * Deny list for opts keys - these are parsed from the URL itself
26
+ * and should not be overridable via the opts JSON parameter for safety
27
+ */
28
+ const OPTS_DENY_LIST = ['type', 'host']
29
+
24
30
  /**
25
31
  * Default ports for each protocol
26
32
  */
@@ -393,6 +399,7 @@ function parseQuickConnect (str) {
393
399
  if (optsStr) {
394
400
  try {
395
401
  const extraOpts = JSON.parse(optsStr)
402
+ OPTS_DENY_LIST.forEach(key => delete extraOpts[key])
396
403
  Object.assign(opts, extraOpts)
397
404
  } catch (err) {
398
405
  console.error('Failed to parse opts:', err)
@@ -439,5 +446,6 @@ export {
439
446
  getDefaultPort,
440
447
  getSupportedProtocols,
441
448
  SUPPORTED_PROTOCOLS,
442
- DEFAULT_PORTS
449
+ DEFAULT_PORTS,
450
+ OPTS_DENY_LIST
443
451
  }
@@ -9,7 +9,6 @@ const {
9
9
 
10
10
  const props = runSync('getConstants')
11
11
 
12
- props.env = JSON.parse(props.env)
13
12
  props.versions = JSON.parse(props.versions)
14
13
 
15
14
  window.pre = {
@@ -0,0 +1,52 @@
1
+ import Modal from '../common/modal'
2
+ import { auto } from 'manate/react'
3
+ import AIConfigForm from './ai-config'
4
+ import message from '../common/message'
5
+ import { aiConfigsArr } from './ai-config-props'
6
+ import { pick } from 'lodash-es'
7
+
8
+ const e = window.translate
9
+
10
+ export default auto(function AIConfigModal ({ store }) {
11
+ const { showAIConfigModal } = store
12
+
13
+ if (!showAIConfigModal) {
14
+ return null
15
+ }
16
+
17
+ function getInitialValues () {
18
+ const res = pick(store.config, aiConfigsArr)
19
+ if (!res.languageAI) {
20
+ res.languageAI = window.store.getLangName()
21
+ }
22
+ return res
23
+ }
24
+
25
+ function handleSubmit (values) {
26
+ window.store.updateConfig(values)
27
+ message.success(e('saved') || 'Saved')
28
+ window.store.showAIConfigModal = false
29
+ }
30
+
31
+ function handleClose () {
32
+ window.store.showAIConfigModal = false
33
+ }
34
+
35
+ return (
36
+ <Modal
37
+ open={showAIConfigModal}
38
+ onCancel={handleClose}
39
+ footer={null}
40
+ title='AI Config'
41
+ width='80%'
42
+ destroyOnClose
43
+ className='ai-config-modal'
44
+ >
45
+ <AIConfigForm
46
+ initialValues={getInitialValues()}
47
+ onSubmit={handleSubmit}
48
+ showAIConfig
49
+ />
50
+ </Modal>
51
+ )
52
+ })
@@ -6,7 +6,7 @@ import {
6
6
  Alert,
7
7
  Space
8
8
  } from 'antd'
9
- import { useEffect, useState } from 'react'
9
+ import { useEffect } from 'react'
10
10
  import Link from '../common/external-link'
11
11
  import AiCache from './ai-cache'
12
12
  import {
@@ -15,9 +15,6 @@ import {
15
15
  import Password from '../common/password'
16
16
  import AiHistory, { addHistoryItem } from './ai-history'
17
17
 
18
- // Comprehensive API provider configurations
19
- import providers from './providers'
20
-
21
18
  const STORAGE_KEY_CONFIG = 'ai_config_history'
22
19
  const EVENT_NAME_CONFIG = 'ai-config-history-update'
23
20
 
@@ -39,7 +36,6 @@ const proxyOptions = [
39
36
 
40
37
  export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig }) {
41
38
  const [form] = Form.useForm()
42
- const [modelOptions, setModelOptions] = useState([])
43
39
 
44
40
  useEffect(() => {
45
41
  if (initialValues) {
@@ -51,23 +47,6 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
51
47
  return true
52
48
  }
53
49
 
54
- const getBaseURLOptions = () => {
55
- return providers.map(provider => ({
56
- value: provider.baseURL,
57
- label: provider.label
58
- }))
59
- }
60
-
61
- const getModelOptions = (baseURL) => {
62
- const provider = providers.find(p => p.baseURL === baseURL)
63
- if (!provider) return []
64
-
65
- return provider.models.map(model => ({
66
- value: model,
67
- label: model
68
- }))
69
- }
70
-
71
50
  const handleSubmit = async (values) => {
72
51
  onSubmit(values)
73
52
  addHistoryItem(STORAGE_KEY_CONFIG, values, EVENT_NAME_CONFIG)
@@ -88,11 +67,6 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
88
67
  return { label, title }
89
68
  }
90
69
 
91
- function handleChange (v) {
92
- const options = getModelOptions(v)
93
- setModelOptions(options)
94
- }
95
-
96
70
  if (!showAIConfig) {
97
71
  return null
98
72
  }
@@ -127,12 +101,8 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
127
101
  { type: 'url', message: 'Please enter a valid URL!' }
128
102
  ]}
129
103
  >
130
- <AutoComplete
131
- options={getBaseURLOptions()}
132
- placeholder='Enter or select API provider URL'
133
- filterOption={filter}
134
- onChange={handleChange}
135
- allowClear
104
+ <Input
105
+ placeholder='Enter API provider URL'
136
106
  style={{ width: '75%' }}
137
107
  />
138
108
  </Form.Item>
@@ -156,10 +126,8 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
156
126
  name='modelAI'
157
127
  rules={[{ required: true, message: 'Please input or select a model!' }]}
158
128
  >
159
- <AutoComplete
160
- options={modelOptions}
129
+ <Input
161
130
  placeholder='Enter or select AI model'
162
- filterOption={filter}
163
131
  />
164
132
  </Form.Item>
165
133
 
@@ -202,11 +170,10 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
202
170
  >
203
171
  <AutoComplete
204
172
  options={proxyOptions}
205
- placeholder='Enter proxy URL (optional)'
206
173
  filterOption={filter}
207
174
  allowClear
208
175
  >
209
- <Input />
176
+ <Input placeholder='Enter proxy URL (optional)' />
210
177
  </AutoComplete>
211
178
  </Form.Item>
212
179
 
@@ -1,15 +1,4 @@
1
- import providers from './providers'
2
-
3
1
  export default function getBrand (baseURLAI) {
4
- // First, try to match with providers
5
- const provider = providers.find(p => p.baseURL === baseURLAI)
6
- if (provider) {
7
- return {
8
- brand: provider.label,
9
- brandUrl: provider.homepage
10
- }
11
- }
12
-
13
2
  // If no match, extract brand from URL
14
3
  try {
15
4
  const url = new URL(baseURLAI)
@@ -13,7 +13,8 @@ export default function CustomCss (props) {
13
13
  if (configLoaded) {
14
14
  const style = document.getElementById(themeDomId)
15
15
  if (style) {
16
- style.innerHTML = customCss || ''
16
+ const safeCss = (customCss || '').replace(/@import/gi, '#')
17
+ style.innerHTML = safeCss
17
18
  }
18
19
  }
19
20
  }, [customCss, configLoaded])
@@ -49,7 +49,6 @@ export default class BookmarkFromHistoryModal extends React.PureComponent {
49
49
  ...tab,
50
50
  id: generate()
51
51
  }
52
- console.log(r)
53
52
  delete r.parentId
54
53
  delete r.category
55
54
  return r
@@ -2,7 +2,7 @@ import { formItemLayout } from '../../../common/form-layout.js'
2
2
  import { terminalRdpType } from '../../../common/constants.js'
3
3
  import { createBaseInitValues, getAuthTypeDefault } from '../common/init-values.js'
4
4
  import { isEmpty } from 'lodash-es'
5
- import { commonFields } from './common-fields.js'
5
+ import { commonFields, connectionHoppingTab } from './common-fields.js'
6
6
 
7
7
  const e = window.translate
8
8
 
@@ -12,6 +12,7 @@ const rdpConfig = {
12
12
  initValues: (props) => {
13
13
  return createBaseInitValues(props, terminalRdpType, {
14
14
  port: 3389,
15
+ connectionHoppings: [],
15
16
  ...getAuthTypeDefault(props)
16
17
  })
17
18
  },
@@ -38,7 +39,8 @@ const rdpConfig = {
38
39
  commonFields.proxy,
39
40
  commonFields.type
40
41
  ]
41
- }
42
+ },
43
+ connectionHoppingTab()
42
44
  ]
43
45
  }
44
46
 
@@ -0,0 +1,23 @@
1
+ import Icon from '@ant-design/icons'
2
+
3
+ const HeartbeatSvg = () => (
4
+ <svg
5
+ viewBox='0 0 1024 1024'
6
+ fill='currentColor'
7
+ width='1em'
8
+ height='1em'
9
+ aria-hidden='true'
10
+ >
11
+ <path d='M923 283.6a260.1 260.1 0 00-56.9-82.8 264.4 264.4 0 00-84-55.5A265.3 265.3 0 00679.7 128c-38.1 0-75.4 7.5-109.8 22.3a274.5 274.5 0 00-87.7 60.8l-12.1 12.5-12.1-12.5a260.5 260.5 0 00-87.7-60.8c-34.4-14.8-71.7-22.3-109.8-22.3-38.2 0-75.5 7.5-109.9 22.3-33.4 14.3-63.3 34.9-88.9 61-25.6 26.1-45.7 56.4-59.9 90.5A278.3 278.3 0 000 416.5c0 39.6 7.7 77.2 22.9 111.6 12.8 29.6 31.5 57 55.5 81.5l356.2 358.5c12.2 12.3 29.7 19.4 47.8 19.4 18.1 0 35.6-7.1 47.7-19.4L886 609.6c24-24.5 42.7-51.9 55.5-81.5C956.3 493.7 964 456.1 964 416.5c0-37.9-7.4-74.7-22-109zM880 497.9c-10.4 24.1-26 46-46.5 65.2L480 920.7 193.5 563.1C173 543.9 157.4 522 147 497.9c-12.1-27.9-18.2-57.8-18.2-88.6 0-30 5.8-59 17.3-86.3 11.1-26.5 27.2-50.3 47.8-70.8 20.6-20.4 44.6-36.5 71.4-47.8 27.7-11.7 57.2-17.7 87.8-17.7 32.3 0 63.7 6.5 93.2 19.3 29 12.5 55 30.9 77.2 54.4l73.9 76.5 73.9-76.5c22.2-23.5 48.2-41.9 77.2-54.4 29.5-12.8 60.9-19.3 93.2-19.3 30.6 0 60.1 6 87.8 17.7 26.8 11.3 50.8 27.4 71.4 47.8 20.6 20.5 36.7 44.3 47.8 70.8 11.5 27.3 17.3 56.3 17.3 86.3 0 30.8-6.1 60.7-18.2 88.6z' />
12
+ <polyline
13
+ points='160,512 310,512 370,310 450,714 530,512 594,512 654,360 714,664 774,512 864,512'
14
+ fill='none'
15
+ stroke='currentColor'
16
+ strokeWidth='60'
17
+ strokeLinecap='round'
18
+ strokeLinejoin='round'
19
+ />
20
+ </svg>
21
+ )
22
+
23
+ export const HeartbeatIcon = props => (<Icon component={HeartbeatSvg} {...props} />)
@@ -28,6 +28,7 @@ import ConnectionHoppingWarning from './connection-hopping-warnning'
28
28
  import SshConfigLoadNotify from '../ssh-config/ssh-config-load-notify'
29
29
  import LoadSshConfigs from '../ssh-config/load-ssh-configs'
30
30
  import AIChat from '../ai/ai-chat'
31
+ import AIConfigModal from '../ai/ai-config-modal'
31
32
  import Opacity from '../common/opacity'
32
33
  import MoveItemModal from '../tree-list/move-item-modal'
33
34
  import InputContextMenu from '../common/input-context-menu'
@@ -35,6 +36,7 @@ import WorkspaceSaveModal from '../tabs/workspace-save-modal'
35
36
  import BookmarkFromHistoryModal from '../bookmark-form/bookmark-from-history-modal'
36
37
  import AutoSync from '../setting-sync/auto-sync'
37
38
  import BatchOpRunner from '../batch-op/batch-op-runner'
39
+ import UnixTimestampTooltip from '../terminal/unix-timestamp-tooltip'
38
40
  import { pick } from 'lodash-es'
39
41
  import deepCopy from 'json-deep-copy'
40
42
  import './wrapper.styl'
@@ -50,7 +52,7 @@ export default auto(function Index (props) {
50
52
  ipcOnEvent('open-about', store.openAbout)
51
53
  ipcOnEvent('new-ssh', store.onNewSsh)
52
54
  ipcOnEvent('add-tab-from-command-line', store.addTabFromCommandLine)
53
- ipcOnEvent('open-tab', (e, parsed) => store.addTab(parsed))
55
+ ipcOnEvent('open-tab', (e, parsed) => store.ipcOpenTab(parsed))
54
56
  ipcOnEvent('openSettings', store.openSetting)
55
57
  ipcOnEvent('selectall', store.selectall)
56
58
  ipcOnEvent('focused', store.focus)
@@ -295,6 +297,8 @@ export default auto(function Index (props) {
295
297
  <BookmarkFromHistoryModal />
296
298
  <NotificationContainer />
297
299
  <BatchOpRunner />
300
+ <AIConfigModal store={store} />
301
+ <UnixTimestampTooltip />
298
302
  </div>
299
303
  </ConfigProvider>
300
304
  )
@@ -14,8 +14,7 @@ import {
14
14
  FullscreenOutlined,
15
15
  PaperClipOutlined,
16
16
  CloseOutlined,
17
- ApartmentOutlined,
18
- HeartOutlined
17
+ ApartmentOutlined
19
18
  } from '@ant-design/icons'
20
19
  import {
21
20
  Tooltip,
@@ -37,6 +36,7 @@ import {
37
36
  import { SplitViewIcon } from '../icons/split-view'
38
37
  import { refs } from '../common/ref'
39
38
  import safeName from '../../common/safe-name'
39
+ import { HeartbeatIcon } from '../icons/heartbeat'
40
40
  import './session.styl'
41
41
 
42
42
  const e = window.translate
@@ -550,7 +550,7 @@ export default class SessionWrapper extends Component {
550
550
  }
551
551
  return (
552
552
  <Tooltip title={title}>
553
- <HeartOutlined {...iconProps} />
553
+ <HeartbeatIcon {...iconProps} />
554
554
  </Tooltip>
555
555
  )
556
556
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useEffect, useRef } from 'react'
9
9
  import { ArrowDownOutlined, ArrowUpOutlined, SaveOutlined, ClearOutlined } from '@ant-design/icons'
10
- import { Button, Input, Form, Alert } from 'antd'
10
+ import { Button, Input, Form, Alert, Switch } from 'antd'
11
11
  import { notification } from '../common/notification'
12
12
  import Link from '../common/external-link'
13
13
  import dayjs from 'dayjs'
@@ -80,6 +80,7 @@ export default function SyncForm (props) {
80
80
  up[syncType + 'ServerUrl'] = res.serverUrl || ''
81
81
  up[syncType + 'Username'] = res.username || ''
82
82
  up[syncType + 'Password'] = res.password || ''
83
+ up[syncType + 'SkipVerify'] = res.skipVerify || false
83
84
  }
84
85
 
85
86
  window.store.updateSyncSetting(up)
@@ -247,6 +248,13 @@ export default function SyncForm (props) {
247
248
  id='sync-input-webdav-username'
248
249
  />
249
250
  </FormItem>
251
+ <FormItem
252
+ label={createLabel('Skip SSL verify')}
253
+ name='skipVerify'
254
+ valuePropName='checked'
255
+ >
256
+ <Switch />
257
+ </FormItem>
250
258
  <FormItem
251
259
  label={createLabel(e('password'))}
252
260
  name='password'
@@ -48,7 +48,8 @@ export default auto(function SyncSettingEntry (props) {
48
48
  // WebDAV specific fields
49
49
  serverUrl: syncSetting[type + 'ServerUrl'],
50
50
  username: syncSetting[type + 'Username'],
51
- password: syncSetting[type + 'Password']
51
+ password: syncSetting[type + 'Password'],
52
+ skipVerify: syncSetting[type + 'SkipVerify'] || false
52
53
  }
53
54
  return (
54
55
  <SyncForm
@@ -2,8 +2,8 @@
2
2
  * file list table
3
3
  */
4
4
 
5
- import { Component } from 'react'
6
- import { Dropdown, Pagination } from 'antd'
5
+ import { Component, createRef } from 'react'
6
+ import { Dropdown } from 'antd'
7
7
  import classnames from 'classnames'
8
8
  import FileSection from './file-item'
9
9
  import PagedList from './paged-list'
@@ -19,7 +19,25 @@ const e = window.translate
19
19
  export default class FileListTable extends Component {
20
20
  constructor (props) {
21
21
  super(props)
22
- this.state = this.initFromProps()
22
+ this.state = {
23
+ ...this.initFromProps(),
24
+ scrollTop: 0
25
+ }
26
+ }
27
+
28
+ containerRef = createRef()
29
+
30
+ componentDidUpdate (prevProps) {
31
+ if (prevProps.fileList !== this.props.fileList) {
32
+ if (this.containerRef.current) {
33
+ this.containerRef.current.scrollTop = 0
34
+ }
35
+ this.setState({ scrollTop: 0 })
36
+ }
37
+ }
38
+
39
+ onScroll = (e) => {
40
+ this.setState({ scrollTop: e.target.scrollTop })
23
41
  }
24
42
 
25
43
  initFromProps = (pps = this.getPropsDefault()) => {
@@ -36,9 +54,7 @@ export default class FileListTable extends Component {
36
54
  }
37
55
  })
38
56
  return {
39
- pageSize: 100,
40
- properties,
41
- page: 1
57
+ properties
42
58
  }
43
59
  }
44
60
 
@@ -171,40 +187,6 @@ export default class FileListTable extends Component {
171
187
  'left'
172
188
  ]
173
189
 
174
- hasPager = () => {
175
- const {
176
- pageSize
177
- } = this.state
178
- const {
179
- fileList
180
- } = this.props
181
- const len = fileList.length
182
- return len > pageSize
183
- }
184
-
185
- onPageChange = (page) => {
186
- this.setState({ page })
187
- }
188
-
189
- renderPager () {
190
- const { page, pageSize } = this.state
191
- const { fileList } = this.props
192
- const props = {
193
- current: page,
194
- pageSize,
195
- total: fileList.length,
196
- showLessItems: true,
197
- showSizeChanger: false,
198
- simple: false,
199
- onChange: this.onPageChange
200
- }
201
- return (
202
- <div className='pd1b pager-wrap'>
203
- <Pagination {...props} />
204
- </div>
205
- )
206
- }
207
-
208
190
  // reset
209
191
  resetWidth = () => {
210
192
  this.setState(this.initFromProps())
@@ -293,24 +275,19 @@ export default class FileListTable extends Component {
293
275
 
294
276
  render () {
295
277
  const { fileList, height, type } = this.props
296
- // const tableHeaderHeight = 30
297
- // const sh = sshSftpSplitView ? 0 : 32
278
+ const containerHeight = height - 42 - 30 - 32 - 90
298
279
  const props = {
280
+ ref: this.containerRef,
299
281
  className: 'sftp-table-content overscroll-y relative',
300
282
  style: {
301
- height: height - 42 - 30 - 32 - 90
283
+ height: containerHeight
302
284
  },
303
285
  draggable: false,
286
+ onScroll: this.onScroll,
304
287
  onClick: this.handleClick,
305
288
  onDoubleClick: this.handleDoubleClick
306
289
  }
307
- const hasPager = this.hasPager()
308
- const cls = classnames(
309
- 'sftp-table relative',
310
- {
311
- 'sftp-has-pager': hasPager
312
- }
313
- )
290
+ const cls = classnames('sftp-table relative')
314
291
  const ddProps = {
315
292
  menu: {
316
293
  items: this.renderContextMenuFile(),
@@ -338,13 +315,11 @@ export default class FileListTable extends Component {
338
315
  <PagedList
339
316
  list={fileList}
340
317
  renderItem={this.renderItem}
341
- hasPager={hasPager}
342
- page={this.state.page}
343
- pageSize={this.state.pageSize}
318
+ scrollTop={this.state.scrollTop}
319
+ containerHeight={containerHeight}
344
320
  />
345
321
  </div>
346
322
  </Dropdown>
347
- {hasPager && this.renderPager()}
348
323
  </div>
349
324
  )
350
325
  }
@@ -1,56 +1,56 @@
1
1
  /**
2
- * file list module to limit files rendered to increase performance
2
+ * Virtual list for SFTP file list.
3
+ *
4
+ * Scroll state is owned by the parent (list-table-ui) via React's onScroll prop
5
+ * on the scrollable container, and passed down as `scrollTop`. This avoids the
6
+ * React lifecycle ordering bug where a child's componentDidMount fires before the
7
+ * parent div's ref is assigned, making addEventListener attach to null.
8
+ *
9
+ * Uses spacers (top/bottom divs) so the container's scrollbar reflects the full
10
+ * list height while only the visible window (± OVERSCAN) is in the DOM.
11
+ *
12
+ * offsetTop is read from rootRef.offsetTop at render time — the distance from the
13
+ * scroll container's top edge to this list's top edge (accounts for the ".." parent
14
+ * row above the list).
3
15
  */
4
16
 
5
- import { Component } from 'react'
6
- import { Pagination } from 'antd'
17
+ import { Component, createRef } from 'react'
7
18
 
8
- export default class ScrollFiles extends Component {
9
- state = {
10
- page: 1,
11
- pageSize: 100
12
- }
19
+ const ITEM_SIZE = 36 // 32px item height + 4px margin-bottom
20
+ const OVERSCAN = 5
13
21
 
14
- onChange = page => {
15
- this.setState({
16
- page
17
- })
18
- }
22
+ export default class VirtualList extends Component {
23
+ rootRef = createRef()
19
24
 
20
- renderList () {
21
- const page = this.props.page ?? this.state.page
22
- const pageSize = this.props.pageSize ?? this.state.pageSize
23
- const start = (page - 1) * pageSize
24
- const end = start + pageSize
25
- const {
26
- list, hasPager
27
- } = this.props
28
- const arr = hasPager
29
- ? list.slice(start, end)
30
- : list
31
- return arr.map((item, index) => this.props.renderItem(item, index))
32
- }
25
+ render () {
26
+ const { list, renderItem, containerHeight = 400, scrollTop = 0 } = this.props
27
+
28
+ // offsetTop: distance from scroll container top to this list's top.
29
+ // rootRef.offsetTop is relative to the nearest positioned ancestor, which is
30
+ // .sftp-table-content (position: relative) — exactly the scroll container.
31
+ // Returns 0 on first render (rootRef not yet set); harmless (renders a few extra items).
32
+ const offsetTop = this.rootRef.current?.offsetTop ?? 0
33
+
34
+ const startIndex = Math.max(
35
+ 0,
36
+ Math.floor((scrollTop - offsetTop) / ITEM_SIZE) - OVERSCAN
37
+ )
38
+ const endIndex = Math.min(
39
+ list.length - 1,
40
+ Math.ceil((scrollTop + containerHeight - offsetTop) / ITEM_SIZE) + OVERSCAN
41
+ )
42
+
43
+ const topSpacerHeight = startIndex * ITEM_SIZE
44
+ const bottomSpacerHeight = Math.max(0, (list.length - endIndex - 1) * ITEM_SIZE)
33
45
 
34
- renderPager () {
35
- const props = {
36
- current: this.state.page,
37
- pageSize: this.state.pageSize,
38
- total: this.props.list.length,
39
- showLessItems: true,
40
- showSizeChanger: false,
41
- simple: false,
42
- onChange: this.onChange
43
- }
44
46
  return (
45
- <div className='pd1b pager-wrap'>
46
- <Pagination
47
- {...props}
48
- />
47
+ <div ref={this.rootRef}>
48
+ <div style={{ height: topSpacerHeight }} />
49
+ {list.slice(startIndex, endIndex + 1).map((item, i) =>
50
+ renderItem(item, startIndex + i)
51
+ )}
52
+ <div style={{ height: bottomSpacerHeight }} />
49
53
  </div>
50
54
  )
51
55
  }
52
-
53
- render () {
54
- return this.renderList()
55
- }
56
56
  }
@@ -970,7 +970,7 @@ export default class Sftp extends Component {
970
970
  })
971
971
  }
972
972
 
973
- parsePath = (type, pth) => {
973
+ parsePath = async (type, pth) => {
974
974
  const reg = /^%([^%]+)%/
975
975
  if (!reg.test(pth)) {
976
976
  return pth
@@ -980,13 +980,14 @@ export default class Sftp extends Component {
980
980
  return pth
981
981
  }
982
982
  const envName = m[1]
983
- const envPath = window.pre.env[envName]
983
+ const envPath = await window.pre.runGlobalAsync('getEnv', envName)
984
984
  if (envPath) {
985
985
  return pth.replace(reg, envPath)
986
986
  }
987
+ return pth
987
988
  }
988
989
 
989
- onGoto = (type, e) => {
990
+ onGoto = async (type, e) => {
990
991
  e && e.preventDefault()
991
992
  if (type === typeMap.remote && !this.sftp) {
992
993
  return this.initData(true)
@@ -994,7 +995,7 @@ export default class Sftp extends Component {
994
995
  const n = `${type}Path`
995
996
  const nt = n + 'Temp'
996
997
  const oldPath = this.state[type + 'Path']
997
- const np = this.parsePath(type, this.state[nt])
998
+ const np = await this.parsePath(type, this.state[nt])
998
999
  if (!isValidPath(np)) {
999
1000
  return notification.warning({
1000
1001
  message: 'path not valid'