@electerm/electerm-react 3.10.0 → 3.11.11

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 (37) hide show
  1. package/client/common/bookmark-schemas.js +165 -0
  2. package/client/common/constants.js +7 -0
  3. package/client/common/parse-quick-connect.js +13 -10
  4. package/client/common/sanitize-filename.js +66 -0
  5. package/client/common/ws.js +25 -6
  6. package/client/common/zod.js +180 -0
  7. package/client/components/ai/agent-tool-call-card.jsx +90 -0
  8. package/client/components/ai/agent-tools.js +193 -0
  9. package/client/components/ai/agent.js +159 -0
  10. package/client/components/ai/ai-chat-entry.jsx +11 -0
  11. package/client/components/ai/ai-chat-history-item.jsx +48 -2
  12. package/client/components/ai/ai-chat.jsx +25 -6
  13. package/client/components/ai/ai-config.jsx +45 -4
  14. package/client/components/ai/ai.styl +73 -0
  15. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  16. package/client/components/bookmark-form/config/serial.js +2 -1
  17. package/client/components/common/font-select.jsx +45 -0
  18. package/client/components/main/main.jsx +3 -3
  19. package/client/components/rdp/file-transfer.js +3 -0
  20. package/client/components/session/session.jsx +2 -2
  21. package/client/components/setting-panel/setting-terminal.jsx +6 -28
  22. package/client/components/setting-panel/text-bg-modal.jsx +8 -27
  23. package/client/components/setting-sync/setting-sync-form.jsx +1 -1
  24. package/client/components/sftp/file-item.jsx +5 -4
  25. package/client/components/shortcuts/shortcut-handler.js +9 -9
  26. package/client/components/terminal/terminal-error-handle.jsx +1 -1
  27. package/client/components/terminal/terminal-interactive-ui.jsx +157 -0
  28. package/client/components/terminal/terminal-interactive.jsx +64 -163
  29. package/client/components/terminal/terminal.jsx +11 -0
  30. package/client/components/terminal-info/terminal-info-entry.jsx +11 -0
  31. package/client/components/text-editor/text-editor-entry.jsx +11 -0
  32. package/client/components/widgets/widget-form.jsx +27 -2
  33. package/client/entry/worker.js +9 -5
  34. package/client/store/mcp-handler.js +22 -2
  35. package/client/store/watch.js +38 -36
  36. package/package.json +1 -1
  37. package/client/common/safe-name.js +0 -19
@@ -1,30 +1,45 @@
1
1
  /**
2
- * handle terminal interactive operation
2
+ * handle terminal interactive operation - queue based
3
3
  */
4
4
 
5
- import { useEffect, useState } from 'react'
6
- import { Form, Button } from 'antd'
7
- import Modal from '../common/modal'
8
- import InputAutoFocus from '../common/input-auto-focus'
5
+ import { useEffect, useState, useRef, useCallback } from 'react'
9
6
  import wait from '../../common/wait'
10
-
11
- const e = window.translate
12
- const FormItem = Form.Item
7
+ import TermInteractiveUI from './terminal-interactive-ui'
13
8
 
14
9
  export default function TermInteractive () {
15
- const [opts, setter] = useState(null)
16
- const [form] = Form.useForm()
10
+ const [current, setCurrent] = useState(null)
11
+ const queueRef = useRef([])
12
+ const hasCurrentRef = useRef(false)
13
+
17
14
  function updateTab (data) {
18
15
  window.store.updateTab(data.tabId, data.update)
19
16
  }
20
- function onMsg (e) {
17
+
18
+ function processNext () {
19
+ const next = queueRef.current.shift()
20
+ if (next) {
21
+ setCurrent(next)
22
+ } else {
23
+ hasCurrentRef.current = false
24
+ setCurrent(null)
25
+ }
26
+ }
27
+
28
+ const onMsgRef = useRef(null)
29
+ onMsgRef.current = function onMsg (e) {
21
30
  if (
22
31
  e &&
23
32
  e.data &&
24
33
  typeof e.data === 'string' &&
25
34
  e.data.includes('session-interactive')
26
35
  ) {
27
- setter(JSON.parse(e.data))
36
+ const parsed = JSON.parse(e.data)
37
+ if (hasCurrentRef.current) {
38
+ queueRef.current.push(parsed)
39
+ } else {
40
+ hasCurrentRef.current = true
41
+ setCurrent(parsed)
42
+ }
28
43
  } else if (
29
44
  e &&
30
45
  e.data &&
@@ -34,166 +49,52 @@ export default function TermInteractive () {
34
49
  updateTab(JSON.parse(e.data))
35
50
  }
36
51
  }
37
- function clear () {
38
- setter(null)
39
- form.resetFields()
40
- }
41
- function onCancel () {
42
- window.et.commonWs.s({
43
- id: opts.id,
44
- results: []
45
- })
46
- clear()
47
- }
48
- function onOk () {
49
- form.submit()
50
- }
51
- function onConfirm () {
52
- window.et.commonWs.s({
53
- id: opts.id,
54
- results: [opts.options.confirmResult || 'yes']
55
- })
56
- clear()
57
- }
58
- function onIgnore () {
59
- window.et.commonWs.s({
60
- id: opts.id,
61
- results: Object.keys(opts.options.prompts).map(() => '')
62
- })
63
- clear()
64
- }
65
- function onFinish (res) {
66
- window.et.commonWs.s({
67
- id: opts.id,
68
- results: Object.values(res)
69
- })
70
- clear()
71
- }
72
- function renderFormItem (pro, i) {
73
- const {
74
- prompt,
75
- echo
76
- } = pro
77
- const note = (opts.options.instructions || [])[i]
78
- const type = echo
79
- ? 'input'
80
- : 'password'
81
- return (
82
- <FormItem
83
- key={prompt + i}
84
- label={prompt}
85
- rules={[{
86
- required: true, message: 'required'
87
- }]}
88
- >
89
- <div>
90
- <pre>{note}</pre>
91
- </div>
92
- <FormItem noStyle name={'item' + i}>
93
- <InputAutoFocus
94
- type={type}
95
- placeholder={note}
96
- />
97
- </FormItem>
98
- </FormItem>
99
- )
52
+
53
+ function onSend (data) {
54
+ window.et.commonWs.s(data)
100
55
  }
101
- function renderConfirmBody () {
102
- const instructions = opts.options.instructions || []
103
- return (
104
- <div>
105
- {
106
- instructions.map((note, index) => {
107
- return <pre key={note + index}>{note}</pre>
108
- })
56
+
57
+ const onClose = useCallback(() => {
58
+ processNext()
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ let cancelled = false
63
+ function handler (e) {
64
+ if (!cancelled) {
65
+ onMsgRef.current(e)
66
+ }
67
+ }
68
+ async function initWatch () {
69
+ for (;;) {
70
+ if (cancelled) {
71
+ return
72
+ }
73
+ if (window.et.commonWs) {
74
+ window.et.commonWs.addEventListener('message', handler)
75
+ return
109
76
  }
110
- <FormItem>
111
- <Button
112
- type='primary'
113
- onClick={onConfirm}
114
- >
115
- {opts.options.submitText || e('submit')}
116
- </Button>
117
- <Button
118
- className='mg1l'
119
- onClick={onCancel}
120
- >
121
- {opts.options.cancelText || e('cancel')}
122
- </Button>
123
- </FormItem>
124
- </div>
125
- )
126
- }
127
- async function initWatch () {
128
- let done = false
129
- while (!done) {
130
- if (window.et.commonWs) {
131
- window.et.commonWs.addEventListener('message', onMsg)
132
- done = true
133
- } else {
134
77
  await wait(400)
135
78
  }
136
79
  }
137
- }
138
- function init () {
139
80
  initWatch()
140
- }
141
- useEffect(() => {
142
- init()
81
+ return () => {
82
+ cancelled = true
83
+ if (window.et.commonWs) {
84
+ window.et.commonWs.removeEventListener('message', handler)
85
+ }
86
+ }
143
87
  }, [])
144
- if (!opts) {
88
+
89
+ if (!current) {
145
90
  return null
146
91
  }
147
- const props = {
148
- maskClosable: false,
149
- okText: e('submit'),
150
- onCancel,
151
- onOk,
152
- closable: false,
153
- open: true,
154
- title: opts.options?.name || '?',
155
- footer: null
156
- }
92
+
157
93
  return (
158
- <Modal
159
- {...props}
160
- >
161
- {
162
- opts.options?.mode === 'confirm'
163
- ? renderConfirmBody()
164
- : (
165
- <Form
166
- form={form}
167
- layout='vertical'
168
- onFinish={onFinish}
169
- >
170
- {
171
- opts.options.prompts.map(renderFormItem)
172
- }
173
- <FormItem>
174
- <Button
175
- type='primary'
176
- htmlType='submit'
177
- >
178
- {e('submit')}
179
- </Button>
180
- <Button
181
- type='dashed'
182
- className='mg1l'
183
- onClick={onIgnore}
184
- >
185
- {e('ignore')}
186
- </Button>
187
- <Button
188
- className='mg1l'
189
- onClick={onCancel}
190
- >
191
- {e('cancel')}
192
- </Button>
193
- </FormItem>
194
- </Form>
195
- )
196
- }
197
- </Modal>
94
+ <TermInteractiveUI
95
+ opts={current}
96
+ onSend={onSend}
97
+ onClose={onClose}
98
+ />
198
99
  )
199
100
  }
@@ -1263,6 +1263,17 @@ class Term extends Component {
1263
1263
  this.handleError({ message: text, from, srcId })
1264
1264
  }
1265
1265
  })
1266
+ // Guard: component was unmounted while createTerm was pending.
1267
+ // The child process is already running; connect briefly to trigger its cleanup.
1268
+ if (this.onClose) {
1269
+ if (r && r.port) {
1270
+ try {
1271
+ const tmpSock = new WebSocket(this.buildWsUrl(r.port))
1272
+ tmpSock.onopen = () => tmpSock.close()
1273
+ } catch (_e) {}
1274
+ }
1275
+ return
1276
+ }
1266
1277
  if (typeof r === 'string' && r.includes('fail')) {
1267
1278
  return this.promote()
1268
1279
  }
@@ -0,0 +1,11 @@
1
+ import { lazy, Suspense } from 'react'
2
+
3
+ const TerminalInfo = lazy(() => import('./terminal-info'))
4
+
5
+ export default function TerminalInfoEntry (props) {
6
+ return (
7
+ <Suspense fallback={null}>
8
+ <TerminalInfo {...props} />
9
+ </Suspense>
10
+ )
11
+ }
@@ -0,0 +1,11 @@
1
+ import { lazy, Suspense } from 'react'
2
+
3
+ const TextEditor = lazy(() => import('./text-editor'))
4
+
5
+ export default function TextEditorEntry (props) {
6
+ return (
7
+ <Suspense fallback={null}>
8
+ <TextEditor {...props} />
9
+ </Suspense>
10
+ )
11
+ }
@@ -2,9 +2,10 @@
2
2
  * Widget form component
3
3
  */
4
4
  import React, { useState, useEffect } from 'react'
5
- import { Form, Input, InputNumber, Switch, Select, Button, Tooltip, Alert } from 'antd'
5
+ import { Form, Input, InputNumber, Switch, Select, Button, Tooltip, Alert, Space } from 'antd'
6
6
  import { formItemLayout, tailFormItemLayout } from '../../common/form-layout'
7
7
  import HelpIcon from '../common/help-icon'
8
+ import { nanoid } from 'nanoid'
8
9
  import BatchOpEditor from '../batch-op/batch-op-editor'
9
10
 
10
11
  export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInstance }) {
@@ -43,12 +44,36 @@ export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInsta
43
44
  }
44
45
 
45
46
  const renderFormItem = (config) => {
46
- const { name, type, description, choices } = config
47
+ const { name, type, description, choices, showGenerator } = config
47
48
  let control = null
48
49
 
49
50
  switch (type) {
50
51
  case 'string':
51
52
  control = <Input placeholder={description} />
53
+ if (showGenerator) {
54
+ return (
55
+ <Form.Item
56
+ key={name}
57
+ {...formItemLayout}
58
+ label={name}
59
+ tooltip={description}
60
+ >
61
+ <Space.Compact style={{ width: '100%' }}>
62
+ <Form.Item
63
+ noStyle
64
+ name={name}
65
+ >
66
+ <Input placeholder={description} />
67
+ </Form.Item>
68
+ <Button
69
+ onClick={() => form.setFieldValue(name, 'ett_' + nanoid())}
70
+ >
71
+ Generate
72
+ </Button>
73
+ </Space.Compact>
74
+ </Form.Item>
75
+ )
76
+ }
52
77
  break
53
78
  case 'textarea':
54
79
  control = <Input.TextArea autoSize={{ minRows: 3 }} placeholder={description} />
@@ -114,7 +114,10 @@ async function onMsg (e) {
114
114
  } else if (action === 'addEventListener') {
115
115
  const ws = self.insts[wsId]
116
116
  if (ws) {
117
- ws.cb = (e) => {
117
+ if (!ws.cbs) {
118
+ ws.cbs = {}
119
+ }
120
+ const cb = (e) => {
118
121
  send({
119
122
  wsId,
120
123
  id,
@@ -123,13 +126,14 @@ async function onMsg (e) {
123
126
  }
124
127
  })
125
128
  }
126
- ws.addEventListener(type, ws.cb)
129
+ ws.cbs[id] = cb
130
+ ws.addEventListener(type, cb)
127
131
  }
128
132
  } else if (action === 'removeEventListener') {
129
133
  const ws = self.insts[wsId]
130
- if (ws) {
131
- ws.removeEventListener(type, ws.cb)
132
- delete ws.cb
134
+ if (ws && ws.cbs && ws.cbs[id]) {
135
+ ws.removeEventListener(type, ws.cbs[id])
136
+ delete ws.cbs[id]
133
137
  }
134
138
  }
135
139
  }
@@ -172,8 +172,28 @@ export default Store => {
172
172
 
173
173
  // ==================== Bookmark APIs ====================
174
174
 
175
+ const bookmarkSensitiveFields = [
176
+ 'password', 'privateKey', 'passphrase', 'certificate', 'proxy',
177
+ 'connectionHoppings', 'sshTunnels'
178
+ ]
179
+ const bookmarkFeatureFields = [
180
+ 'connectionHoppings', 'sshTunnels', 'quickCommands', 'runScripts'
181
+ ]
182
+
183
+ function sanitizeBookmark (b) {
184
+ const safe = Object.fromEntries(
185
+ Object.entries(b).filter(([k]) => !bookmarkSensitiveFields.includes(k))
186
+ )
187
+ for (const key of bookmarkFeatureFields) {
188
+ if (Array.isArray(b[key]) && b[key].length) {
189
+ safe[`has${key.charAt(0).toUpperCase() + key.slice(1)}`] = true
190
+ }
191
+ }
192
+ return safe
193
+ }
194
+
175
195
  Store.prototype.mcpListBookmarks = function () {
176
- return deepCopy(window.store.bookmarks)
196
+ return deepCopy(window.store.bookmarks).map(sanitizeBookmark)
177
197
  }
178
198
 
179
199
  Store.prototype.mcpGetBookmark = function (args) {
@@ -182,7 +202,7 @@ export default Store => {
182
202
  if (!bookmark) {
183
203
  throw new Error(`Bookmark not found: ${args.id}`)
184
204
  }
185
- return deepCopy(bookmark)
205
+ return deepCopy(sanitizeBookmark(bookmark))
186
206
  }
187
207
 
188
208
  Store.prototype.mcpAddBookmark = async function (args) {
@@ -21,49 +21,51 @@ import dataCompare from '../common/data-compare'
21
21
 
22
22
  export default store => {
23
23
  for (const name of dbNamesForWatch) {
24
+ window[`watch${name}Running`] = false
24
25
  window[`watch${name}`] = autoRun(async () => {
25
- if (window.migrating) {
26
+ if (window.migrating || window[`watch${name}Running`]) {
26
27
  return
27
28
  }
28
- const old = refsStatic.get('oldState-' + name)
29
- const n = store.getItems(name)
30
- const { updated, added, removed } = dataCompare(
31
- old,
32
- n
33
- )
34
- for (const item of removed) {
35
- await remove(name, item.id)
36
- }
37
- for (const item of updated) {
38
- await update(item.id, item, name, false)
39
- }
40
- for (const item of added) {
41
- await insert(name, item)
42
- }
43
- const newOrder = (n || []).map(d => d.id)
44
- await update(
45
- `${name}:order`,
46
- newOrder
47
- )
48
- refsStatic.add('oldState-' + name, deepCopy(n) || [])
49
- if (name === 'bookmarks') {
50
- store.bookmarksMap = new Map(
51
- n.map(d => [d.id, d])
29
+ window[`watch${name}Running`] = true
30
+ try {
31
+ const old = refsStatic.get('oldState-' + name)
32
+ const n = store.getItems(name)
33
+ const { updated, added, removed } = dataCompare(
34
+ old,
35
+ n
52
36
  )
53
- }
54
- await store.updateLastDataUpdateTime()
55
- if (dbNamesForSync.includes(name)) {
56
- const syncSetting = store.config.syncSetting || {}
57
- const { autoSync, autoSyncInterval, autoSyncDirection } = syncSetting
58
- if (autoSync && autoSyncInterval === 0) {
59
- if (autoSyncDirection === 'download') {
60
- await store.downloadSettingAll()
61
- } else {
62
- await store.uploadSettingAll()
37
+ await Promise.all([
38
+ ...removed.map(item => remove(name, item.id)),
39
+ ...updated.map(item => update(item.id, item, name, false)),
40
+ added.length ? insert(name, added) : Promise.resolve()
41
+ ])
42
+ const newOrder = (n || []).map(d => d.id)
43
+ await update(
44
+ `${name}:order`,
45
+ newOrder
46
+ )
47
+ refsStatic.add('oldState-' + name, deepCopy(n) || [])
48
+ if (name === 'bookmarks') {
49
+ store.bookmarksMap = new Map(
50
+ n.map(d => [d.id, d])
51
+ )
52
+ }
53
+ await store.updateLastDataUpdateTime()
54
+ if (dbNamesForSync.includes(name)) {
55
+ const syncSetting = store.config.syncSetting || {}
56
+ const { autoSync, autoSyncInterval, autoSyncDirection } = syncSetting
57
+ if (autoSync && autoSyncInterval === 0) {
58
+ if (autoSyncDirection === 'download') {
59
+ await store.downloadSettingAll()
60
+ } else {
61
+ await store.uploadSettingAll()
62
+ }
63
63
  }
64
64
  }
65
+ return store[name]
66
+ } finally {
67
+ window[`watch${name}Running`] = false
65
68
  }
66
- return store[name]
67
69
  })
68
70
  window[`watch${name}`].start()
69
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.10.0",
3
+ "version": "3.11.11",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",
@@ -1,19 +0,0 @@
1
- /**
2
- * convert string to safe name
3
- * from https://github.com/Jaliborc/safe-filename/blob/master/index.js
4
- */
5
-
6
- export default (name) => {
7
- return name
8
- .replace(/\.$/, '')
9
- .replace('?', '❓')
10
- .replace('\\', ' ⃥')
11
- .replace('/', '⟋')
12
- .replace('|', '│')
13
- .replace(':', '꞉')
14
- .replace('<', 'ᐸ')
15
- .replace('>', 'ᐳ')
16
- .replace('>', 'ᐳ')
17
- .replace('"', 'ᐦ')
18
- .replace('*', '꘎')
19
- }