@electerm/electerm-react 5.1.25 → 5.3.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 (55) hide show
  1. package/client/common/build-map.js +12 -0
  2. package/client/common/constants.js +12 -0
  3. package/client/common/db.js +7 -5
  4. package/client/common/import-task.js +174 -0
  5. package/client/common/term-of-use.jsx +69 -0
  6. package/client/components/ai/ai-config-props.js +8 -0
  7. package/client/components/bookmark-form/ai-bookmark-form.jsx +18 -15
  8. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  9. package/client/components/bookmark-form/common/bookmark-select.jsx +6 -5
  10. package/client/components/bookmark-form/common/fields.jsx +1 -1
  11. package/client/components/bookmark-form/common/profile-item.jsx +12 -2
  12. package/client/components/bookmark-form/config/common-fields.js +11 -0
  13. package/client/components/bookmark-form/config/rdp.js +1 -1
  14. package/client/components/bookmark-form/tree-select.jsx +5 -16
  15. package/client/components/common/import-progress.jsx +90 -0
  16. package/client/components/footer/footer-entry.jsx +1 -1
  17. package/client/components/main/main.jsx +2 -0
  18. package/client/components/profile/profile-form-rdp.jsx +1 -1
  19. package/client/components/quick-commands/quick-command-transport-mod.jsx +24 -18
  20. package/client/components/rdp/rdp-cred-form.jsx +55 -0
  21. package/client/components/rdp/rdp-session.jsx +97 -6
  22. package/client/components/setting-panel/keywords-transport.jsx +1 -4
  23. package/client/components/setting-panel/setting-modal.jsx +8 -2
  24. package/client/components/setting-panel/start-session-select.jsx +5 -16
  25. package/client/components/setting-sync/setting-sync.jsx +15 -7
  26. package/client/components/sidebar/info-modal.jsx +7 -0
  27. package/client/components/spice/spice-session.jsx +40 -0
  28. package/client/components/terminal/attach-addon-custom.js +12 -10
  29. package/client/components/terminal/shell.js +22 -11
  30. package/client/components/terminal/shortcut-bar-entry.jsx +52 -1
  31. package/client/components/terminal/terminal-color-query.mjs +32 -27
  32. package/client/components/terminal/terminal-interactive.jsx +35 -0
  33. package/client/components/terminal/terminal.jsx +24 -15
  34. package/client/components/terminal-info/activity.jsx +5 -1
  35. package/client/components/terminal-info/base.jsx +37 -22
  36. package/client/components/terminal-info/disk.jsx +5 -1
  37. package/client/components/terminal-info/local-info-resolver.js +89 -0
  38. package/client/components/terminal-info/network.jsx +5 -1
  39. package/client/components/terminal-info/run-cmd.jsx +42 -3
  40. package/client/components/terminal-info/sys-info.jsx +56 -0
  41. package/client/components/terminal-info/terminal-info.jsx +4 -1
  42. package/client/components/terminal-info/terminal-info.styl +22 -7
  43. package/client/components/tree-list/bookmark-transport.jsx +2 -1
  44. package/client/components/tree-list/bookmark-upload.js +56 -41
  45. package/client/components/tree-list/tree-list-row.jsx +11 -4
  46. package/client/components/tree-list/tree-list-rows.js +6 -3
  47. package/client/components/tree-list/tree-list.jsx +4 -2
  48. package/client/store/common.js +38 -3
  49. package/client/store/load-data.js +6 -1
  50. package/client/store/setting.js +7 -4
  51. package/client/store/store.js +3 -12
  52. package/client/store/sync.js +31 -2
  53. package/client/store/tab.js +16 -11
  54. package/client/store/watch.js +10 -1
  55. package/package.json +1 -1
@@ -0,0 +1,12 @@
1
+ /**
2
+ * build id -> item Map from array
3
+ * O(n), unlike reduce + object spread which is O(n²)
4
+ */
5
+
6
+ export default (arr = [], key = 'id') => {
7
+ const map = new Map()
8
+ for (const item of arr) {
9
+ map.set(item[key], item)
10
+ }
11
+ return map
12
+ }
@@ -212,6 +212,16 @@ export const syncTypes = buildConst([
212
212
  'cloud',
213
213
  'webdav'
214
214
  ])
215
+ export const allowedSyncTypes = () => {
216
+ const custom = window.et.syncTypes
217
+ if (!Array.isArray(custom)) {
218
+ return Object.keys(syncTypes)
219
+ }
220
+ const list = custom.filter(type => syncTypes[type])
221
+ return list.length
222
+ ? list
223
+ : Object.keys(syncTypes)
224
+ }
215
225
  export const syncTokenCreateUrls = {
216
226
  gitee: 'https://gitee.com/github-zxdong262/electerm/wikis/Create%20personal%20access%20token?sort_id=3028409',
217
227
  github: 'https://github.com/electerm/electerm/wiki/Create-personal-access-token',
@@ -262,6 +272,8 @@ export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/w
262
272
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
263
273
  export const aiChatModeLsKey = 'ai-chat-mode'
264
274
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
275
+ export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
276
+ export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
265
277
  export const modals = {
266
278
  hide: 0,
267
279
  setting: 1
@@ -161,11 +161,13 @@ export async function fetchInitData (dbName) {
161
161
  const res = await find(dbName)
162
162
  const order = await getData(`${dbName}:order`)
163
163
  if (order && order.length) {
164
- res.sort((a, b) => {
165
- const ai = order.findIndex(r => r === a.id)
166
- const bi = order.findIndex(r => r === b.id)
167
- return ai - bi
168
- })
164
+ // index map instead of findIndex inside the comparator:
165
+ // comparator sort is O(n²) with findIndex, O(n log n) with a map
166
+ const orderMap = new Map(order.map((id, i) => [id, i]))
167
+ res.sort((a, b) =>
168
+ (orderMap.get(a.id) ?? -1) -
169
+ (orderMap.get(b.id) ?? -1)
170
+ )
169
171
  }
170
172
  return JSON.stringify(res)
171
173
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Shared chunked import task runner (no UI).
3
+ *
4
+ * Runs large data imports (bookmarks, quick commands, all-data) in small
5
+ * batches, yielding to the UI between batches and pausing db watchers while
6
+ * running, so a big import can not freeze or crash the app. Progress is
7
+ * reported through the ImportProgress modal mounted in main.jsx (driven via
8
+ * refsStatic) and through the onProgress callback.
9
+ */
10
+
11
+ import { action } from 'manate'
12
+ import { refsStatic } from '../components/common/ref'
13
+
14
+ export const importProgressRefKey = 'import-progress'
15
+
16
+ function yieldToUI (idleMs = 0) {
17
+ // rAF fires *before* paint; pairing it with setTimeout(0) - a macrotask
18
+ // that runs *after* paint - guarantees the browser actually paints
19
+ // between batches instead of coalescing them into one long frame.
20
+ // idleMs adds a real idle gap after paint so input events, hover and
21
+ // the mouse cursor get a chance to be handled between batches
22
+ return new Promise(resolve => {
23
+ const finish = () => {
24
+ if (idleMs > 0) {
25
+ setTimeout(resolve, idleMs)
26
+ } else {
27
+ resolve()
28
+ }
29
+ }
30
+ if (typeof requestAnimationFrame === 'function') {
31
+ requestAnimationFrame(() => setTimeout(finish, 0))
32
+ return
33
+ }
34
+ setTimeout(finish, 0)
35
+ })
36
+ }
37
+
38
+ let controller = null
39
+
40
+ export function cancelImportTask () {
41
+ if (controller) {
42
+ controller.cancelled = true
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Run an import task in UI-friendly batches with a progress modal.
48
+ * @param {Object} options
49
+ * @param {string} options.title modal title (already translated label)
50
+ * @param {Array} options.steps list of steps, each either
51
+ * `{ label, items, process(chunk, step) }` - chunked store mutations, or
52
+ * `{ label, weight, run(step) }` - a single (async) unit of work
53
+ * @param {number} options.batch items per store-mutation batch
54
+ * @param {number} options.batchIdle ms of idle time after each batch's
55
+ * paint (default 16); keeps the UI responsive to input while importing
56
+ * @param {boolean} options.cancelable show a cancel button (default true)
57
+ * @param {boolean} options.useModal show the progress modal (default true)
58
+ * @param {number} options.modalMinTotal skip the modal below this total (default 200)
59
+ * @param {string[]} options.stopWatchers db watcher names (window.watchXxx)
60
+ * to pause during the task, so chunked writes do not trigger a db diff
61
+ * per batch; watchers restart one per frame after the steps finish
62
+ * @param {Function} options.onProgress called as (current, total) per batch
63
+ * @returns {Promise<{cancelled: boolean, error: Error|null}>}
64
+ */
65
+ export async function runImportTask (options) {
66
+ const {
67
+ title = '',
68
+ steps = [],
69
+ batch = 100,
70
+ batchIdle = 16,
71
+ cancelable = true,
72
+ useModal = true,
73
+ modalMinTotal = 200,
74
+ stopWatchers = [],
75
+ onProgress
76
+ } = options
77
+ if (controller) {
78
+ console.warn('Another import task is already running')
79
+ return { cancelled: false, error: new Error('Another import task is already running') }
80
+ }
81
+ const total = steps.reduce((p, s) => {
82
+ return p + (Array.isArray(s.items) ? s.items.length : (s.weight || 1))
83
+ }, 0) + stopWatchers.length
84
+ const showModal = useModal && total >= modalMinTotal
85
+ const modal = refsStatic.get(importProgressRefKey)
86
+ controller = { cancelled: false }
87
+ let done = 0
88
+ const update = (ext) => {
89
+ if (onProgress) {
90
+ onProgress(done, total)
91
+ }
92
+ if (!showModal || !modal) {
93
+ return
94
+ }
95
+ modal.show({
96
+ title,
97
+ status: 'running',
98
+ current: done,
99
+ total,
100
+ label: '',
101
+ cancelable,
102
+ ...ext
103
+ })
104
+ }
105
+ update({})
106
+ for (const name of stopWatchers) {
107
+ window[`watch${name}`]?.stop()
108
+ }
109
+ let error = null
110
+ const restarted = new Set()
111
+ try {
112
+ for (const step of steps) {
113
+ if (controller.cancelled) {
114
+ break
115
+ }
116
+ const { label, items, process, run } = step
117
+ if (Array.isArray(items) && typeof process === 'function') {
118
+ for (let i = 0; i < items.length; i += batch) {
119
+ if (controller.cancelled) {
120
+ break
121
+ }
122
+ // paint the last batch, then leave a real idle gap so the UI
123
+ // can handle input/cursor events before the next batch
124
+ await yieldToUI(batchIdle)
125
+ const chunk = items.slice(i, i + batch)
126
+ action(() => process(chunk, step))()
127
+ done += chunk.length
128
+ update({ label })
129
+ }
130
+ } else if (typeof run === 'function') {
131
+ await yieldToUI()
132
+ await run(step)
133
+ done += step.weight || 1
134
+ update({ label })
135
+ }
136
+ }
137
+ // restart watchers one per frame - each start() synchronously diffs
138
+ // + deep-copies its collection and writes to db, so restarting all at
139
+ // once would freeze the UI right when the import "finishes"
140
+ for (const name of stopWatchers) {
141
+ await yieldToUI(batchIdle)
142
+ window[`watch${name}`]?.start()
143
+ restarted.add(name)
144
+ done += 1
145
+ update({ label: name })
146
+ }
147
+ } catch (err) {
148
+ error = err
149
+ } finally {
150
+ // on error/cancel still make sure everything is restarted
151
+ for (const name of stopWatchers) {
152
+ if (!restarted.has(name)) {
153
+ window[`watch${name}`]?.start()
154
+ }
155
+ }
156
+ }
157
+ const { cancelled } = controller
158
+ controller = null
159
+ if (error) {
160
+ if (showModal && modal) {
161
+ modal.show({ title, status: 'error', error, current: done, total, cancelable: false })
162
+ }
163
+ } else if (!cancelled) {
164
+ if (showModal && modal) {
165
+ modal.show({ title, status: 'done', current: total, total, cancelable: false })
166
+ setTimeout(() => {
167
+ modal.hide()
168
+ }, 600)
169
+ }
170
+ } else if (showModal && modal) {
171
+ modal.hide()
172
+ }
173
+ return { cancelled, error }
174
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Term of use confirmation gate for the AI config / data sync pages.
3
+ *
4
+ * Define `window.et.AITermOfUse` / `window.et.SyncTermOfUse` (injected the
5
+ * same way as `defaultAIPreset` into the built index.html `_global` data)
6
+ * to require users to confirm the term before entering those pages.
7
+ * Confirmation is saved to localStorage, so it is only asked once.
8
+ */
9
+
10
+ import Modal from '../components/common/modal'
11
+ import {
12
+ aiTermOfUseConfirmedLsKey,
13
+ syncTermOfUseConfirmedLsKey
14
+ } from './constants'
15
+ import * as ls from './safe-local-storage'
16
+
17
+ const e = window.translate
18
+
19
+ const termOfUseTypes = {
20
+ ai: {
21
+ etKey: 'AITermOfUse',
22
+ lsKey: aiTermOfUseConfirmedLsKey
23
+ },
24
+ sync: {
25
+ etKey: 'SyncTermOfUse',
26
+ lsKey: syncTermOfUseConfirmedLsKey
27
+ }
28
+ }
29
+
30
+ function isConfirmed (type) {
31
+ return ls.getItem(termOfUseTypes[type].lsKey) === 'yes'
32
+ }
33
+
34
+ /**
35
+ * Run `onConfirmed` after the term of use of `type` is confirmed.
36
+ * When no term is defined, or it was confirmed before, `onConfirmed`
37
+ * runs synchronously; otherwise a confirmation modal is shown and
38
+ * `onConfirmed` only runs when the user confirms it.
39
+ */
40
+ export function requireTermOfUse (type, onConfirmed) {
41
+ const { etKey, lsKey } = termOfUseTypes[type]
42
+ const content = window.et?.[etKey]
43
+ const text = typeof content === 'string' ? content.trim() : ''
44
+ if (!text || isConfirmed(type)) {
45
+ return onConfirmed()
46
+ }
47
+ Modal.confirm({
48
+ title: 'Terms of use',
49
+ width: 600,
50
+ maskClosable: false,
51
+ content: (
52
+ <div
53
+ style={{
54
+ maxHeight: '50vh',
55
+ overflow: 'auto',
56
+ whiteSpace: 'pre-wrap'
57
+ }}
58
+ >
59
+ {text}
60
+ </div>
61
+ ),
62
+ okText: e('haveRead'),
63
+ cancelText: e('cancel'),
64
+ onOk: () => {
65
+ ls.setItem(lsKey, 'yes')
66
+ onConfirmed()
67
+ }
68
+ })
69
+ }
@@ -27,6 +27,14 @@ export const defaultAIPresets = [
27
27
  modelAI: 'DEEPSEEK_V4_FLASH',
28
28
  authHeaderNameAI: 'Authorization: Bearer'
29
29
  },
30
+ {
31
+ id: 'apimart',
32
+ nameAI: 'ApiMart',
33
+ baseURLAI: 'https://api.apimart.ai/v1',
34
+ apiPathAI: '/chat/completions',
35
+ modelAI: 'deepseek-v4-flash',
36
+ authHeaderNameAI: 'Authorization: Bearer'
37
+ },
30
38
  {
31
39
  id: 'deepseek',
32
40
  nameAI: 'DeepSeek',
@@ -27,6 +27,7 @@ import { fixBookmarkData } from './fix-bookmark-default.js'
27
27
  import generate from '../../common/id-with-stamp'
28
28
  import AiHistory, { addHistoryItem } from '../ai/ai-history.jsx'
29
29
  import { getItem, setItem } from '../../common/safe-local-storage'
30
+ import { runImportTask } from '../../common/import-task'
30
31
 
31
32
  const STORAGE_KEY_DESC = 'ai_bookmark_description'
32
33
  const STORAGE_KEY_HISTORY = 'ai_bookmark_history'
@@ -34,16 +35,6 @@ const EVENT_NAME_HISTORY = 'ai-bookmark-history-update'
34
35
  const { TextArea } = Input
35
36
  const e = window.translate
36
37
 
37
- function yieldToUI () {
38
- return new Promise(resolve => {
39
- if (typeof requestAnimationFrame === 'function') {
40
- requestAnimationFrame(() => resolve())
41
- return
42
- }
43
- setTimeout(resolve, 0)
44
- })
45
- }
46
-
47
38
  export default function AIBookmarkForm (props) {
48
39
  const { onCancel } = props
49
40
  const [description, setDescription] = useState(() => getItem(STORAGE_KEY_DESC) || '')
@@ -183,11 +174,23 @@ export default function AIBookmarkForm (props) {
183
174
  setConfirmProgress({ current: 0, total: parsed.length })
184
175
 
185
176
  try {
186
- for (let i = 0; i < parsed.length; i++) {
187
- // Yield between synchronous store mutations so large imports stay responsive.
188
- await yieldToUI()
189
- createBookmark(parsed[i])
190
- setConfirmProgress({ current: i + 1, total: parsed.length })
177
+ const { error } = await runImportTask({
178
+ useModal: false,
179
+ batch: 50,
180
+ steps: [
181
+ {
182
+ items: parsed,
183
+ process: (chunk) => {
184
+ chunk.forEach(createBookmark)
185
+ }
186
+ }
187
+ ],
188
+ onProgress: (current, total) => {
189
+ setConfirmProgress({ current, total })
190
+ }
191
+ })
192
+ if (error) {
193
+ throw error
191
194
  }
192
195
 
193
196
  setShowConfirm(false)
@@ -26,6 +26,7 @@ const bookmarkSchema = {
26
26
  runScripts: 'array - run scripts after connected ({delay,script})',
27
27
  quickCommands: 'array - quick commands ({name,command})',
28
28
  proxy: 'string - proxy address (socks5://...)',
29
+ proxyCommand: 'string - ssh proxy command, supports %h (host) %p (port) %r (username) placeholders, e.g. `netbird ssh proxy %h %p` or `cloudflared access ssh --hostname %h`',
29
30
  x11: 'boolean - enable x11 forwarding, default is false',
30
31
  term: 'string - terminal type, default is xterm-256color, required',
31
32
  displayRaw: 'boolean - display raw output, default is false',
@@ -1,5 +1,6 @@
1
1
  // bookmark select tree (copied from legacy)
2
2
  import createTitle from '../../../common/create-title'
3
+ import buildMap from '../../../common/build-map'
3
4
  import { TreeSelect } from 'antd'
4
5
 
5
6
  const e = window.translate
@@ -16,9 +17,9 @@ const hoppingProps = [
16
17
 
17
18
  function buildTreeData (bookmarkGroups, tree) {
18
19
  const cats = bookmarkGroups
19
- const btree = cats.reduce((p, k) => ({ ...p, [k.id]: k }), {})
20
+ const btree = buildMap(cats)
20
21
  function buildSubCats (id) {
21
- const x = btree[id]
22
+ const x = btree.get(id)
22
23
  if (!x) return ''
23
24
  const y = {
24
25
  key: x.id,
@@ -34,7 +35,7 @@ function buildTreeData (bookmarkGroups, tree) {
34
35
  return y
35
36
  }
36
37
  function buildLeaf (id) {
37
- const x = tree[id]
38
+ const x = tree.get(id)
38
39
  if (!x) return ''
39
40
  return { value: x.id, key: x.id, title: createTitle(x) }
40
41
  }
@@ -58,10 +59,10 @@ function buildTreeData (bookmarkGroups, tree) {
58
59
 
59
60
  export default function BookmarkSelect (props) {
60
61
  const { bookmarks, bookmarkGroups } = props
61
- const tree = bookmarks.reduce((p, k) => ({ ...p, [k.id]: k }), {})
62
+ const tree = buildMap(bookmarks)
62
63
 
63
64
  function onSelect (id) {
64
- const item = tree[id]
65
+ const item = tree.get(id)
65
66
  if (item) {
66
67
  const selected = hoppingProps.reduce((p, k) => {
67
68
  if (item[k] !== undefined) {
@@ -170,7 +170,7 @@ export function renderFormItem (item, formItemLayout, form, ctxProps, index) {
170
170
  case 'execSettings':
171
171
  return <ExecSettingsField key={name} />
172
172
  case 'profileItem':
173
- return <ProfileItem key={name} store={ctxProps.store} profileFilter={item.profileFilter} />
173
+ return <ProfileItem key={name} store={ctxProps.store} form={form} profileFilter={item.profileFilter} />
174
174
  case 'quickCommands':
175
175
  return <Fragment key={name}>{useQuickCmds(form, ctxProps.formData || {})}</Fragment>
176
176
  case 'runScripts':
@@ -5,11 +5,20 @@
5
5
  import React from 'react'
6
6
  import { Form, Select } from 'antd'
7
7
  import { formItemLayout } from '../../../common/form-layout'
8
+ import { authTypeMap } from '../../../common/constants'
8
9
 
9
10
  const FormItem = Form.Item
10
11
  const e = window.translate
11
12
 
12
- export default function ProfileItem ({ store, profileFilter = (d) => d }) {
13
+ export default function ProfileItem ({ store, form, profileFilter = (d) => d }) {
14
+ const handleChange = (value) => {
15
+ // applyProfile only resolves credentials when authType is 'profiles',
16
+ // selecting a profile here means bookmark auth should come from it
17
+ form?.setFieldsValue({
18
+ authType: value ? authTypeMap.profiles : authTypeMap.password
19
+ })
20
+ }
21
+
13
22
  const opts = {
14
23
  options: store.profiles
15
24
  .filter(profileFilter)
@@ -18,7 +27,8 @@ export default function ProfileItem ({ store, profileFilter = (d) => d }) {
18
27
  value: d.id
19
28
  })),
20
29
  placeholder: e('profiles'),
21
- allowClear: true
30
+ allowClear: true,
31
+ onChange: handleChange
22
32
  }
23
33
 
24
34
  return (
@@ -210,6 +210,16 @@ export const commonFields = {
210
210
  label: () => e('proxy')
211
211
  },
212
212
 
213
+ proxyCommand: {
214
+ type: 'input',
215
+ name: 'proxyCommand',
216
+ label: () => `${e('proxy')} command`,
217
+ props: {
218
+ placeholder: 'netbird ssh proxy %h %p',
219
+ maxLength: 1024
220
+ }
221
+ },
222
+
213
223
  x11: {
214
224
  type: 'x11',
215
225
  name: '__x11__',
@@ -281,6 +291,7 @@ export const sshSettings = [
281
291
  },
282
292
  commonFields.enableTerminalImage,
283
293
  ...terminalSettings.slice(0, -1), // All except terminalBackground
294
+ commonFields.proxyCommand,
284
295
  commonFields.x11,
285
296
  commonFields.terminalBackground
286
297
  ]
@@ -32,7 +32,7 @@ const rdpConfig = {
32
32
  { type: 'input', name: 'host', label: () => e('host'), rules: [{ required: true, message: e('host') + ' required' }] },
33
33
  commonFields.port,
34
34
  { type: 'profileItem', name: '__profile__', label: '', profileFilter: d => !isEmpty(d.rdp) },
35
- { ...commonFields.username, rules: [{ required: true, message: e('username') + ' required' }] },
35
+ commonFields.username,
36
36
  commonFields.password,
37
37
  commonFields.description,
38
38
  { type: 'input', name: 'domain', label: () => e('domain') },
@@ -8,26 +8,15 @@ import { useState, useMemo } from 'react'
8
8
  import { defaultBookmarkGroupId, settingMap } from '../../common/constants'
9
9
  import deepCopy from 'json-deep-copy'
10
10
  import createTitle, { createTitleWithTag } from '../../common/create-title'
11
+ import buildMap from '../../common/build-map'
11
12
 
12
13
  const e = window.translate
13
14
 
14
15
  function buildData (bookmarks, bookmarkGroups, searchText = '') {
15
16
  const searchLower = searchText.toLowerCase()
16
17
  const cats = bookmarkGroups
17
- const tree = bookmarks
18
- .reduce((p, k) => {
19
- return {
20
- ...p,
21
- [k.id]: k
22
- }
23
- }, {})
24
- const btree = cats
25
- .reduce((p, k) => {
26
- return {
27
- ...p,
28
- [k.id]: k
29
- }
30
- }, {})
18
+ const tree = buildMap(bookmarks)
19
+ const btree = buildMap(cats)
31
20
 
32
21
  // Helper to check if a node matches the search
33
22
  function matchesSearch (text) {
@@ -37,7 +26,7 @@ function buildData (bookmarks, bookmarkGroups, searchText = '') {
37
26
  }
38
27
 
39
28
  function buildSubCats (id) {
40
- const x = btree[id]
29
+ const x = btree.get(id)
41
30
  if (!x) {
42
31
  return ''
43
32
  }
@@ -64,7 +53,7 @@ function buildData (bookmarks, bookmarkGroups, searchText = '') {
64
53
  return y
65
54
  }
66
55
  function buildLeaf (id) {
67
- const x = tree[id]
56
+ const x = tree.get(id)
68
57
  if (!x) {
69
58
  return ''
70
59
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared import progress modal.
3
+ *
4
+ * Mounted once in main.jsx (inside ConfigProvider, so antd theme applies)
5
+ * and driven imperatively through refsStatic by runImportTask
6
+ * (common/import-task.js) - same pattern as BatchOpRunner.
7
+ * Uses only existing utility classes (pd1y/mg1t/elli/common-err).
8
+ */
9
+
10
+ import { useCallback, useEffect, useState } from 'react'
11
+ import { Progress } from 'antd'
12
+ import Modal from './modal.jsx'
13
+ import { refsStatic } from './ref'
14
+ import { importProgressRefKey, cancelImportTask } from '../../common/import-task'
15
+
16
+ const e = window.translate
17
+
18
+ export default function ImportProgress () {
19
+ const [state, setState] = useState(null)
20
+
21
+ const show = useCallback((next) => {
22
+ setState(next)
23
+ }, [])
24
+
25
+ const hide = useCallback(() => {
26
+ setState(null)
27
+ }, [])
28
+
29
+ useEffect(() => {
30
+ refsStatic.add(importProgressRefKey, { show, hide })
31
+ return () => {
32
+ refsStatic.remove(importProgressRefKey)
33
+ }
34
+ }, [show, hide])
35
+
36
+ if (!state) {
37
+ return null
38
+ }
39
+ const {
40
+ title,
41
+ status,
42
+ current,
43
+ total,
44
+ label,
45
+ error,
46
+ cancelable
47
+ } = state
48
+ const running = status === 'running'
49
+ const percent = Math.floor(current * 100 / (total || 1))
50
+ const modalProps = {
51
+ title: title || e('import'),
52
+ open: true,
53
+ width: 420,
54
+ zIndex: 5000,
55
+ maskClosable: false,
56
+ onCancel: running
57
+ ? (cancelable ? cancelImportTask : undefined)
58
+ : hide,
59
+ footer: running && !cancelable
60
+ ? null
61
+ : (
62
+ <div className='custom-modal-footer-buttons'>
63
+ <button
64
+ type='button'
65
+ className='custom-modal-cancel-btn'
66
+ onClick={running ? cancelImportTask : hide}
67
+ >
68
+ {e('cancel')}
69
+ </button>
70
+ </div>
71
+ )
72
+ }
73
+ return (
74
+ <Modal {...modalProps}>
75
+ <div className='pd1y'>
76
+ <Progress
77
+ percent={percent}
78
+ status={status === 'error' ? 'exception' : 'active'}
79
+ format={() => `${current}/${total}`}
80
+ />
81
+ <div className='elli mg1t'>{label}</div>
82
+ {
83
+ error
84
+ ? <div className='common-err mg1t'>{error.message || String(error)}</div>
85
+ : null
86
+ }
87
+ </div>
88
+ </Modal>
89
+ )
90
+ }
@@ -151,7 +151,7 @@ export default auto(function FooterEntry (props) {
151
151
  <div className='terminal-footer-unit terminal-footer-info'>
152
152
  <InfoCircleOutlined
153
153
  onClick={handleInfoPanel}
154
- className='pointer font18 terminal-info-icon'
154
+ className='pointer font14 terminal-info-icon'
155
155
  />
156
156
  </div>
157
157
  )
@@ -38,6 +38,7 @@ import BookmarkFromHistoryModal from '../bookmark-form/bookmark-from-history-mod
38
38
  import AutoSync from '../setting-sync/auto-sync'
39
39
  import BatchOpRunner from '../batch-op/batch-op-runner'
40
40
  import UnixTimestampTooltip from '../terminal/unix-timestamp-tooltip'
41
+ import ImportProgress from '../common/import-progress.jsx'
41
42
  import { pick } from 'lodash-es'
42
43
  import deepCopy from 'json-deep-copy'
43
44
  import './wrapper.styl'
@@ -318,6 +319,7 @@ export default auto(function Index (props) {
318
319
  <BookmarkFromHistoryModal />
319
320
  <NotificationContainer />
320
321
  <BatchOpRunner />
322
+ <ImportProgress />
321
323
  <ShortcutBarEntry store={store} />
322
324
  {!isAIDisabled() && <AIConfigModal store={store} />}
323
325
  <UnixTimestampTooltip />
@@ -15,7 +15,7 @@ export default function ProfileFormRdp (props) {
15
15
  {...formItemLayout}
16
16
  label={e('username')}
17
17
  hasFeedback
18
- name={['rdp', 'userName']}
18
+ name={['rdp', 'username']}
19
19
  >
20
20
  <Input />
21
21
  </FormItem>