@electerm/electerm-react 5.1.25 → 5.2.0

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 (32) hide show
  1. package/client/common/build-map.js +12 -0
  2. package/client/common/db.js +7 -5
  3. package/client/common/import-task.js +174 -0
  4. package/client/components/bookmark-form/ai-bookmark-form.jsx +18 -15
  5. package/client/components/bookmark-form/common/bookmark-select.jsx +6 -5
  6. package/client/components/bookmark-form/common/fields.jsx +1 -1
  7. package/client/components/bookmark-form/common/profile-item.jsx +12 -2
  8. package/client/components/bookmark-form/config/rdp.js +1 -1
  9. package/client/components/bookmark-form/tree-select.jsx +5 -16
  10. package/client/components/common/import-progress.jsx +90 -0
  11. package/client/components/main/main.jsx +2 -0
  12. package/client/components/profile/profile-form-rdp.jsx +1 -1
  13. package/client/components/quick-commands/quick-command-transport-mod.jsx +24 -18
  14. package/client/components/rdp/rdp-cred-form.jsx +55 -0
  15. package/client/components/rdp/rdp-session.jsx +97 -6
  16. package/client/components/setting-panel/keywords-transport.jsx +1 -4
  17. package/client/components/setting-panel/setting-modal.jsx +8 -2
  18. package/client/components/setting-panel/start-session-select.jsx +5 -16
  19. package/client/components/spice/spice-session.jsx +40 -0
  20. package/client/components/terminal/terminal-color-query.mjs +32 -27
  21. package/client/components/terminal/terminal.jsx +13 -9
  22. package/client/components/tree-list/bookmark-transport.jsx +2 -1
  23. package/client/components/tree-list/bookmark-upload.js +56 -41
  24. package/client/components/tree-list/tree-list-row.jsx +11 -4
  25. package/client/components/tree-list/tree-list-rows.js +6 -3
  26. package/client/components/tree-list/tree-list.jsx +4 -2
  27. package/client/store/common.js +19 -1
  28. package/client/store/load-data.js +6 -1
  29. package/client/store/store.js +3 -12
  30. package/client/store/sync.js +31 -2
  31. package/client/store/watch.js +10 -1
  32. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { PureComponent, createRef } from 'react'
2
2
  import { createTerm } from '../terminal/terminal-apis'
3
3
  import deepCopy from 'json-deep-copy'
4
+ import { pick } from 'lodash-es'
4
5
  import clone from '../../common/to-simple-obj'
5
6
  import { handleErr } from '../../common/fetch'
6
7
  import {
@@ -24,6 +25,8 @@ import { readClipboardAsync } from '../../common/clipboard'
24
25
  import RemoteFloatControl from '../common/remote-float-control'
25
26
  import SwitchLabel from '../common/switch'
26
27
  import HelpIcon from '../common/help-icon'
28
+ import Modal from '../common/modal'
29
+ import RdpCredForm from './rdp-cred-form'
27
30
  import { FileTransferManager, createFileLogger } from './file-transfer'
28
31
  import { notification } from '../common/notification'
29
32
  import message from '../common/message'
@@ -65,7 +68,8 @@ export default class RdpSession extends PureComponent {
65
68
  ...resObj,
66
69
  hasRemoteFiles: false,
67
70
  downloadBtnDisabled: true,
68
- uploadReady: false
71
+ uploadReady: false,
72
+ showCredPrompt: false
69
73
  }
70
74
  this.session = null
71
75
  this.fileTransfer = null
@@ -121,13 +125,25 @@ export default class RdpSession extends PureComponent {
121
125
  return `ws://${host}:${port}/${type}/${id}?token=${tokenElecterm}${extra}`
122
126
  }
123
127
 
124
- remoteInit = async () => {
128
+ remoteInit = async (credOverride) => {
125
129
  this.setState({
126
130
  loading: true
127
131
  })
128
132
  const { config } = this.props
129
133
  const { id } = this.props
130
- const tab = window.store.applyProfile(deepCopy(this.props.tab || {}))
134
+ const tab = {
135
+ ...window.store.applyProfile(deepCopy(this.props.tab || {})),
136
+ ...credOverride
137
+ }
138
+ // keep resolved credentials for the connect-time prompt
139
+ this.tab = tab
140
+ if (!tab.username || !tab.password) {
141
+ this.setState({
142
+ loading: false,
143
+ showCredPrompt: true
144
+ })
145
+ return
146
+ }
131
147
  const {
132
148
  type,
133
149
  term: terminalType
@@ -192,6 +208,9 @@ export default class RdpSession extends PureComponent {
192
208
  const builder = new window.ironRdp.SessionBuilder()
193
209
  builder.username(username)
194
210
  builder.password(password)
211
+ if (tab.domain) {
212
+ builder.serverDomain(tab.domain)
213
+ }
195
214
  builder.destination(destination)
196
215
  builder.proxyAddress(proxyAddress)
197
216
  builder.authToken('none')
@@ -277,12 +296,16 @@ export default class RdpSession extends PureComponent {
277
296
  this.log(`Session ended: ${info.reason()}`, 'info')
278
297
  this.onSessionEnd()
279
298
  }).catch((e) => {
280
- this.log(`Session error: ${this.formatError(e)}`, 'error')
299
+ this.showError(e)
281
300
  this.onSessionEnd()
282
301
  })
283
302
  } catch (e) {
284
- this.log(`Connection failed: ${this.formatError(e)}`, 'error')
285
- this.setState({ loading: false })
303
+ this.showError(e)
304
+ this.setState({
305
+ loading: false,
306
+ // let the user retry with different credentials on auth failure
307
+ showCredPrompt: this.isAuthError(e) ? true : this.state.showCredPrompt
308
+ })
286
309
  this.setStatus(statusMap.error)
287
310
  }
288
311
  }
@@ -307,6 +330,73 @@ export default class RdpSession extends PureComponent {
307
330
  return e?.message || e?.toString() || String(e)
308
331
  }
309
332
 
333
+ // WrongPassword(1) / LogonFailure(2)
334
+ isAuthError = (err) => {
335
+ if (err && typeof err === 'object' && '__wbg_ptr' in err && err.kind) {
336
+ try {
337
+ const kind = err.kind()
338
+ return kind === 1 || kind === 2
339
+ } catch (_) { }
340
+ }
341
+ return false
342
+ }
343
+
344
+ // map known IronErrorKind codes to a user-friendly message
345
+ errText = (err) => {
346
+ if (this.isAuthError(err)) {
347
+ return window.translate('loginFail') + ` (${this.formatError(err)})`
348
+ }
349
+ return this.formatError(err)
350
+ }
351
+
352
+ showError = (err) => {
353
+ const text = this.errText(err)
354
+ this.log(`Connection failed: ${this.formatError(err)}`, 'error')
355
+ message.error(text, 10)
356
+ }
357
+
358
+ onCredSubmit = (res) => {
359
+ this.setState({
360
+ showCredPrompt: false
361
+ })
362
+ // tear down the failed attempt before reconnecting
363
+ this.cleanup()
364
+ // reconnect with the provided credentials, not persisted to the bookmark
365
+ this.remoteInit(res)
366
+ }
367
+
368
+ onCredCancel = () => {
369
+ this.setState({
370
+ showCredPrompt: false
371
+ })
372
+ this.setStatus(statusMap.error)
373
+ }
374
+
375
+ renderCredPrompt = () => {
376
+ const {
377
+ showCredPrompt
378
+ } = this.state
379
+ if (!showCredPrompt) {
380
+ return null
381
+ }
382
+ const confirmProps = {
383
+ title: window.translate('credentialsRequired'),
384
+ footer: null,
385
+ open: true,
386
+ onCancel: this.onCredCancel
387
+ }
388
+ return (
389
+ <Modal
390
+ {...confirmProps}
391
+ >
392
+ <RdpCredForm
393
+ initialValues={pick(this.tab, ['username', 'password'])}
394
+ handleFinish={this.onCredSubmit}
395
+ />
396
+ </Modal>
397
+ )
398
+ }
399
+
310
400
  syncLocalToRemote = async () => {
311
401
  if (!this.session || this.fileUploadInProgress) {
312
402
  return
@@ -751,6 +841,7 @@ export default class RdpSession extends PureComponent {
751
841
  ref={this.canvasRef}
752
842
  />
753
843
  </div>
844
+ {this.renderCredPrompt()}
754
845
  </div>
755
846
  </Spin>
756
847
  )
@@ -4,6 +4,7 @@ import time from '../../common/time'
4
4
 
5
5
  export default class KeywordsTransport extends BookmarkTransport {
6
6
  name = 'keywords-highlight'
7
+
7
8
  beforeUpload = async (file) => {
8
9
  const { store } = this.props
9
10
  const txt = file.fileContent !== undefined
@@ -20,10 +21,6 @@ export default class KeywordsTransport extends BookmarkTransport {
20
21
  return false
21
22
  }
22
23
 
23
- renderEdit () {
24
- return null
25
- }
26
-
27
24
  handleDownload = () => {
28
25
  const { store } = this.props
29
26
  const arr = store.config.keywords || []
@@ -33,14 +33,20 @@ export default auto(function SettingModalWrap (props) {
33
33
  settingMap.bookmarks,
34
34
  settingMap.terminalThemes
35
35
  ]
36
- const { settingTab, settingItem, settingSidebarList, bookmarkSelectMode } = store
36
+ const { settingTab, settingItem, bookmarkSelectMode } = store
37
+ // settingSidebarList deep-copies the whole collection; the bookmarks tab
38
+ // ignores `list` (it renders from treeProps), so skip it there — with
39
+ // thousands of bookmarks that copy is the most expensive part of the render
40
+ const list = settingTab === settingMap.bookmarks
41
+ ? []
42
+ : store.settingSidebarList
37
43
  const props0 = {
38
44
  store,
39
45
  activeItemId: settingItem.id,
40
46
  type: settingTab,
41
47
  onClickItem: selectItem,
42
48
  shouldConfirmDel: tabsShouldConfirmDel.includes(settingTab),
43
- list: settingSidebarList
49
+ list
44
50
  }
45
51
  const { bookmarks, bookmarkGroups, widgetInstances } = store
46
52
  const formProps = {
@@ -7,6 +7,7 @@ import {
7
7
  BookOutlined
8
8
  } from '@ant-design/icons'
9
9
  import HelpIcon from '../common/help-icon'
10
+ import buildMap from '../../common/build-map'
10
11
 
11
12
  const e = window.translate
12
13
  const { SHOW_CHILD } = TreeSelect
@@ -21,22 +22,10 @@ function BookmarkSelect (props) {
21
22
 
22
23
  const buildData = () => {
23
24
  const cats = bookmarkGroups
24
- const tree = bookmarks
25
- .reduce((p, k) => {
26
- return {
27
- ...p,
28
- [k.id]: k
29
- }
30
- }, {})
31
- const btree = cats
32
- .reduce((p, k) => {
33
- return {
34
- ...p,
35
- [k.id]: k
36
- }
37
- }, {})
25
+ const tree = buildMap(bookmarks)
26
+ const btree = buildMap(cats)
38
27
  function buildSubCats (id) {
39
- const x = btree[id]
28
+ const x = btree.get(id)
40
29
  if (!x) {
41
30
  return ''
42
31
  }
@@ -55,7 +44,7 @@ function BookmarkSelect (props) {
55
44
  return y
56
45
  }
57
46
  function buildLeaf (id) {
58
- const x = tree[id]
47
+ const x = tree.get(id)
59
48
  if (!x) {
60
49
  return ''
61
50
  }
@@ -44,12 +44,52 @@ export default class SpiceSession extends PureComponent {
44
44
 
45
45
  componentDidMount () {
46
46
  this.remoteInit()
47
+ this.attachMouseScaleFix()
47
48
  }
48
49
 
49
50
  componentWillUnmount () {
51
+ this.detachMouseScaleFix()
50
52
  this.cleanup()
51
53
  }
52
54
 
55
+ // The spice client sends event.offsetX/offsetY to the remote as-is, but when
56
+ // the canvas is CSS-scaled (scaleViewport or the lib's maxWidth/maxHeight),
57
+ // those are display pixels instead of remote surface pixels. On high
58
+ // resolution remote screens the pointer then only reaches the top-left
59
+ // region. Convert display coords to surface coords (the canvas box is
60
+ // letterboxed by object-fit: contain) before the lib's handlers run.
61
+ handleMouseScaleFix = (e) => {
62
+ const canvas = this.domRef.current?.querySelector('canvas')
63
+ if (!canvas || !canvas.width || !canvas.height) return
64
+ const rect = canvas.getBoundingClientRect()
65
+ if (!rect.width || !rect.height) return
66
+ const scale = Math.min(rect.width / canvas.width, rect.height / canvas.height)
67
+ const x = (e.clientX - rect.left - (rect.width - canvas.width * scale) / 2) / scale
68
+ const y = (e.clientY - rect.top - (rect.height - canvas.height * scale) / 2) / scale
69
+ Object.defineProperty(e, 'offsetX', {
70
+ value: Math.max(0, Math.min(x, canvas.width - 1))
71
+ })
72
+ Object.defineProperty(e, 'offsetY', {
73
+ value: Math.max(0, Math.min(y, canvas.height - 1))
74
+ })
75
+ }
76
+
77
+ attachMouseScaleFix = () => {
78
+ this.domRef.current?.addEventListener(
79
+ 'mousemove',
80
+ this.handleMouseScaleFix,
81
+ true
82
+ )
83
+ }
84
+
85
+ detachMouseScaleFix = () => {
86
+ this.domRef.current?.removeEventListener(
87
+ 'mousemove',
88
+ this.handleMouseScaleFix,
89
+ true
90
+ )
91
+ }
92
+
53
93
  cleanup = () => {
54
94
  if (this.spiceConn) {
55
95
  try {
@@ -57,7 +57,8 @@ function parseHexColor (color) {
57
57
  return [
58
58
  parseInt(expanded.slice(0, 2), 16),
59
59
  parseInt(expanded.slice(2, 4), 16),
60
- parseInt(expanded.slice(4, 6), 16)
60
+ parseInt(expanded.slice(4, 6), 16),
61
+ alpha
61
62
  ]
62
63
  }
63
64
 
@@ -82,10 +83,15 @@ function parseRgbColor (color) {
82
83
  if (alpha === null || alpha <= 0) {
83
84
  return null
84
85
  }
85
- return channels
86
+ return [...channels, alpha]
86
87
  }
87
88
 
88
89
  export function parseColorToRgb (color) {
90
+ const rgba = parseColorToRgba(color)
91
+ return rgba ? rgba.slice(0, 3) : null
92
+ }
93
+
94
+ export function parseColorToRgba (color) {
89
95
  if (typeof color !== 'string') {
90
96
  return null
91
97
  }
@@ -105,46 +111,45 @@ export function colorToOscRgb (color) {
105
111
  }
106
112
 
107
113
  /**
108
- * Blend a (possibly translucent) selection color over an opaque background,
114
+ * Blend a (possibly translucent) selection colour over an opaque background,
109
115
  * producing the colour in xterm's internal format ({ css, rgba } where
110
116
  * rgba = r<<24 | g<<16 | b<<8 | a).
111
117
  *
112
118
  * xterm derives its DOM selection colour as blend(theme.background,
113
- * selectionBackground). When the terminal background is forced transparent
114
- * (DOM renderer keeps the real background behind via CSS), that blend runs
115
- * over transparent-black and a light selection turns into dark gray. This
116
- * helper recomputes it over the real visible background instead.
119
+ * selectionBackground) into `selectionBackgroundOpaque`, and separately forces
120
+ * any opaque selectionBackground down to 0.3 alpha in
121
+ * `selectionBackgroundTransparent` (xterm#2737). electerm renders the terminal
122
+ * background via CSS and hands xterm a transparent background, so the blend
123
+ * must be redone over the real visible background — starting from the
124
+ * *configured* colour so an opaque selection stays opaque (xterm's forced
125
+ * 0.3-alpha variant would make every selection translucent).
117
126
  *
118
- * `selectionColor` is an xterm color object ({ css, rgba }) e.g.
119
- * colors.selectionBackgroundTransparent; `visibleBackground` is an opaque
120
- * colour string.
127
+ * `selectionColor` is a CSS colour string from the terminal theme, e.g.
128
+ * themeConfig.selectionBackground; `visibleBackground` is an opaque colour
129
+ * string.
121
130
  */
122
131
  export function blendSelectionOverBackground (visibleBackground, selectionColor) {
123
- const bgRgb = parseColorToRgb(visibleBackground)
124
- const sel = selectionColor?.rgba
125
- if (!bgRgb || typeof sel !== 'number') {
126
- return null
127
- }
128
- const [br, bg, bb] = bgRgb
129
- const a = (sel & 255) / 255
130
- if (a <= 0) {
132
+ const bg = parseColorToRgb(visibleBackground)
133
+ const sel = parseColorToRgba(selectionColor)
134
+ if (!bg || !sel) {
131
135
  return null
132
136
  }
137
+ const [br, bgg, bb] = bg
138
+ const [sr, sg, sb, sa] = sel
133
139
  const toCss = (r, g, b, alpha) =>
134
140
  `#${[r, g, b, alpha].map(toHexByte).join('')}`
135
- const sr = sel >> 24 & 255
136
- const sg = sel >> 16 & 255
137
- const sb = sel >> 8 & 255
138
- if (a >= 1) {
141
+ if (sa >= 1) {
142
+ // Fully opaque: use the configured colour as-is, just normalised to
143
+ // 8-digit hex so both the overlay and per-cell inline styles agree.
139
144
  return {
140
145
  css: toCss(sr, sg, sb, 255),
141
- rgba: ((sel & 0xffffff) | 0xff000000) >>> 0
146
+ rgba: (sr << 24 | sg << 16 | sb << 8 | 255) >>> 0
142
147
  }
143
148
  }
144
- const r = br + Math.round((sr - br) * a)
145
- const g = bg + Math.round((sg - bg) * a)
146
- const b = bb + Math.round((sb - bb) * a)
147
- const alpha = Math.round(a * 255)
149
+ const r = br + Math.round((sr - br) * sa)
150
+ const g = bgg + Math.round((sg - bgg) * sa)
151
+ const b = bb + Math.round((sb - bb) * sa)
152
+ const alpha = Math.round(sa * 255)
148
153
  return {
149
154
  css: toCss(r, g, b, alpha),
150
155
  rgba: (r << 24 | g << 16 | b << 8 | alpha) >>> 0
@@ -1474,31 +1474,35 @@ class Term extends Component {
1474
1474
 
1475
1475
  /**
1476
1476
  * xterm computes its DOM selection colour as blend(theme.background,
1477
- * selectionBackground). The DOM renderer keeps the terminal background
1478
- * transparent (rgba(0,0,0,0)) so electerm's own CSS background / image
1479
- * shows through, which makes a light selection blend over transparent-black
1480
- * and render as dark gray. Recompute the selection over the real visible
1481
- * background so terminal:selectionBackground=rgba(...) is applied as the
1482
- * configured semi-transparent colour.
1477
+ * selectionBackground). The terminal background is forced transparent
1478
+ * (rgba(0,0,0,0)) so electerm's own CSS background / image shows through,
1479
+ * which makes the blend run over transparent-black. Recompute the selection
1480
+ * colours over the real visible background, from the *configured* theme
1481
+ * colours — xterm's internal selectionBackgroundTransparent forces opaque
1482
+ * colours down to 0.3 alpha (xterm#2737), which would make every selection
1483
+ * translucent even when the user configured an opaque one.
1483
1484
  */
1484
1485
  fixSelectionColors = (term) => {
1486
+ const themeConfig = this.props.themeConfig || {}
1485
1487
  const themeService = term?._core?._themeService
1486
1488
  if (!themeService?.modifyColors) {
1487
1489
  return
1488
1490
  }
1489
1491
  const visibleBackground = this.getVisibleTerminalBackground()
1490
- const colors = themeService.colors
1492
+ // xterm's DEFAULT_SELECTION, used when the theme omits a selection color
1493
+ const selectionFallback = 'rgba(255, 255, 255, 0.3)'
1494
+ const inactiveFallback = themeConfig.selectionBackground || selectionFallback
1491
1495
  themeService.modifyColors((c) => {
1492
1496
  const active = blendSelectionOverBackground(
1493
1497
  visibleBackground,
1494
- colors.selectionBackgroundTransparent
1498
+ themeConfig.selectionBackground || selectionFallback
1495
1499
  )
1496
1500
  if (active) {
1497
1501
  c.selectionBackgroundOpaque = active
1498
1502
  }
1499
1503
  const inactive = blendSelectionOverBackground(
1500
1504
  visibleBackground,
1501
- colors.selectionInactiveBackgroundTransparent
1505
+ themeConfig.selectionInactiveBackground || inactiveFallback
1502
1506
  )
1503
1507
  if (inactive) {
1504
1508
  c.selectionInactiveBackgroundOpaque = inactive
@@ -10,11 +10,12 @@ import {
10
10
  } from '@ant-design/icons'
11
11
  import { Button, Space } from 'antd'
12
12
  import Upload from '../common/upload'
13
+ import { beforeBookmarkUpload } from './bookmark-upload'
13
14
 
14
15
  const e = window.translate
15
16
 
16
17
  export default class BookmarkTransport extends PureComponent {
17
- beforeUpload = async (file) => {}
18
+ beforeUpload = beforeBookmarkUpload
18
19
 
19
20
  handleDownload = () => {}
20
21
 
@@ -8,7 +8,9 @@ import { action } from 'manate'
8
8
  import uid from '../../common/uid'
9
9
  import time from '../../common/time'
10
10
  import { fixBookmarks } from '../../common/db-fix'
11
- import delay from '../../common/wait'
11
+ import { runImportTask } from '../../common/import-task'
12
+
13
+ const e = window.translate
12
14
 
13
15
  function fixBookmarksId (bookmarks) {
14
16
  return bookmarks.map(item => {
@@ -21,6 +23,7 @@ function fixBookmarksId (bookmarks) {
21
23
  return item
22
24
  }).filter(Boolean)
23
25
  }
26
+
24
27
  export const bookmarkUpload = action(async (file) => {
25
28
  const { store } = window
26
29
  const { bookmarks, bookmarkGroups } = store
@@ -58,50 +61,62 @@ export const bookmarkUpload = action(async (file) => {
58
61
 
59
62
  const fixed = fixBookmarks(bookmarks1)
60
63
 
61
- fixed.forEach(bg => {
62
- if (!bmTree.has(bg.id)) {
63
- store.bookmarks.push(bg)
64
- }
64
+ await runImportTask({
65
+ title: e('import'),
66
+ batch: 200,
67
+ stopWatchers: ['bookmarks', 'bookmarkGroups'],
68
+ steps: [
69
+ {
70
+ label: e('bookmarks'),
71
+ items: fixed,
72
+ process: (chunk) => {
73
+ chunk.forEach(bg => {
74
+ if (!bmTree.has(bg.id)) {
75
+ store.bookmarks.push(bg)
76
+ }
77
+ })
78
+ }
79
+ },
80
+ {
81
+ label: e('bookmarkCategory'),
82
+ items: bookmarkGroups1,
83
+ process: (chunk) => {
84
+ chunk.forEach(bg => {
85
+ if (!bmgTree.has(bg.id)) {
86
+ store.bookmarkGroups.push(bg)
87
+ } else {
88
+ const bg1 = store.bookmarkGroups.find(
89
+ b => b.id === bg.id
90
+ )
91
+ bg1.bookmarkIds = uniq(
92
+ [
93
+ ...(bg1.bookmarkIds || []),
94
+ ...(bg.bookmarkIds || [])
95
+ ]
96
+ )
97
+ bg1.bookmarkGroupIds = uniq(
98
+ [
99
+ ...(bg1.bookmarkGroupIds || []),
100
+ ...(bg.bookmarkGroupIds || [])
101
+ ]
102
+ )
103
+ }
104
+ })
105
+ }
106
+ },
107
+ {
108
+ label: e('bookmarks'),
109
+ weight: 1,
110
+ run: () => {
111
+ store.fixBookmarkGroups()
112
+ }
113
+ }
114
+ ]
65
115
  })
66
116
 
67
- bookmarkGroups1.forEach(bg => {
68
- if (!bmgTree.has(bg.id)) {
69
- store.bookmarkGroups.push(bg)
70
- } else {
71
- const bg1 = store.bookmarkGroups.find(
72
- b => b.id === bg.id
73
- )
74
- bg1.bookmarkIds = uniq(
75
- [
76
- ...(bg1.bookmarkIds || []),
77
- ...(bg.bookmarkIds || [])
78
- ]
79
- )
80
- bg1.bookmarkGroupIds = uniq(
81
- [
82
- ...(bg1.bookmarkGroupIds || []),
83
- ...(bg.bookmarkGroupIds || [])
84
- ]
85
- )
86
- }
87
- })
88
-
89
- store.fixBookmarkGroups()
90
-
91
117
  return false
92
118
  })
93
119
 
94
120
  export async function beforeBookmarkUpload (file) {
95
- const names = [
96
- 'bookmarks',
97
- 'bookmarkGroups'
98
- ]
99
- for (const name of names) {
100
- window[`watch${name}`].stop()
101
- }
102
- bookmarkUpload(file)
103
- await delay(1000)
104
- for (const name of names) {
105
- window[`watch${name}`].start()
106
- }
121
+ return bookmarkUpload(file)
107
122
  }
@@ -1,14 +1,20 @@
1
+ import { memo } from 'react'
1
2
  import TreeExpander from './tree-expander'
2
3
  import TreeListItem from './tree-list-item'
3
4
  import TreeItemOp from './tree-item-op'
4
5
  import { treeLevelIndent } from './tree-list-layout'
5
6
  import createName from '../../common/create-title'
6
7
 
7
- export default function TreeListRow (props) {
8
+ // memoized: VirtualTreeList re-renders every scroll frame and re-invokes
9
+ // renderItem for the whole visible window; without memo each row (including
10
+ // the antd Popconfirm/Tooltip in TreeItemOp) re-renders per frame.
11
+ // `expanded` is a boolean (not the expandedKeys array) because the array is
12
+ // mutated in place, so identity-based memo comparison would miss changes.
13
+ function TreeListRow (props) {
8
14
  const {
9
15
  row,
10
16
  keyword,
11
- expandedKeys,
17
+ expanded,
12
18
  activeItemId,
13
19
  searchSelectedRowKey,
14
20
  staticList,
@@ -34,7 +40,6 @@ export default function TreeListRow (props) {
34
40
  item?.bookmarkIds?.length ||
35
41
  item?.bookmarkGroupIds?.length
36
42
  )
37
- const isGroupExpanded = Boolean(keyword) || expandedKeys.includes(item.id)
38
43
  const itemProps = {
39
44
  item,
40
45
  isGroup,
@@ -94,7 +99,7 @@ export default function TreeListRow (props) {
94
99
  level={parentId}
95
100
  group={item}
96
101
  hasChildren={groupHasChildren}
97
- shouldOpen={isGroupExpanded}
102
+ shouldOpen={expanded}
98
103
  onExpand={handleExpand}
99
104
  onUnExpand={handleUnExpand}
100
105
  />
@@ -114,3 +119,5 @@ export default function TreeListRow (props) {
114
119
  </div>
115
120
  )
116
121
  }
122
+
123
+ export default memo(TreeListRow)
@@ -37,7 +37,10 @@ export function buildVisibleTreeRows ({
37
37
  sortTitle,
38
38
  sortHost
39
39
  }) {
40
- const groupTree = bookmarkGroupTree || {}
40
+ const groupTree = bookmarkGroupTree || new Map()
41
+ const getGroup = id => groupTree instanceof Map
42
+ ? groupTree.get(id)
43
+ : groupTree[id]
41
44
  const rows = []
42
45
  const matchedRowKeys = []
43
46
  const expandedKeySet = new Set(expandedKeys || [])
@@ -72,7 +75,7 @@ export function buildVisibleTreeRows ({
72
75
  }
73
76
  const hasMatch = (group.bookmarkIds || []).some(bookmarkMatches) ||
74
77
  (group.bookmarkGroupIds || []).some(id => {
75
- return groupHasMatchedBookmarks(groupTree[id])
78
+ return groupHasMatchedBookmarks(getGroup(id))
76
79
  })
77
80
  groupMatchCache.set(group.id, hasMatch)
78
81
  return hasMatch
@@ -100,7 +103,7 @@ export function buildVisibleTreeRows ({
100
103
  : `#${group.id}`
101
104
 
102
105
  for (const groupId of group.bookmarkGroupIds || []) {
103
- visitGroup(groupTree[groupId], nextParentId, depth + 1)
106
+ visitGroup(getGroup(groupId), nextParentId, depth + 1)
104
107
  }
105
108
 
106
109
  const bookmarkIds = group.bookmarkIds || []
@@ -740,11 +740,13 @@ export default class ItemListTree extends Component {
740
740
  }
741
741
 
742
742
  renderVirtualRow = (row, editor) => {
743
+ const { keyword } = this.state
744
+ const { expandedKeys } = this.props
743
745
  return (
744
746
  <TreeListRow
745
747
  row={row}
746
- keyword={this.state.keyword}
747
- expandedKeys={this.props.expandedKeys}
748
+ keyword={keyword}
749
+ expanded={Boolean(keyword) || expandedKeys.includes(row.item.id)}
748
750
  activeItemId={this.props.activeItemId}
749
751
  searchSelectedRowKey={this.state.searchSelectedRowKey}
750
752
  staticList={this.props.staticList}