@electerm/electerm-react 2.8.8 → 2.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/client/common/constants.js +3 -3
  2. package/client/common/pre.js +1 -120
  3. package/client/components/bookmark-form/ai-bookmark-form.jsx +324 -0
  4. package/client/components/bookmark-form/bookmark-form.styl +1 -1
  5. package/client/components/bookmark-form/bookmark-schema.js +179 -0
  6. package/client/components/bookmark-form/common/ai-category-select.jsx +32 -0
  7. package/client/components/bookmark-form/common/category-select.jsx +2 -4
  8. package/client/components/bookmark-form/common/fields.jsx +0 -10
  9. package/client/components/bookmark-form/config/rdp.js +0 -1
  10. package/client/components/bookmark-form/config/session-config.js +3 -1
  11. package/client/components/bookmark-form/config/spice.js +44 -0
  12. package/client/components/bookmark-form/config/vnc.js +1 -2
  13. package/client/components/bookmark-form/fix-bookmark-default.js +134 -0
  14. package/client/components/bookmark-form/index.jsx +74 -13
  15. package/client/components/session/session.jsx +13 -3
  16. package/client/components/setting-panel/keywords-transport.jsx +0 -1
  17. package/client/components/shortcuts/shortcut-handler.js +11 -5
  18. package/client/components/sidebar/index.jsx +11 -1
  19. package/client/components/spice/spice-session.jsx +276 -0
  20. package/client/components/tabs/add-btn-menu.jsx +9 -2
  21. package/client/components/terminal/attach-addon-custom.js +20 -76
  22. package/client/components/terminal/terminal.jsx +34 -28
  23. package/client/components/terminal/transfer-client-base.js +232 -0
  24. package/client/components/terminal/trzsz-client.js +306 -0
  25. package/client/components/terminal/xterm-loader.js +109 -0
  26. package/client/components/terminal/zmodem-client.js +13 -166
  27. package/client/components/text-editor/simple-editor.jsx +1 -2
  28. package/client/entry/electerm.jsx +0 -2
  29. package/client/store/system-menu.js +10 -0
  30. package/package.json +1 -1
  31. package/client/common/trzsz.js +0 -46
  32. package/client/components/bookmark-form/common/wiki-alert.jsx +0 -9
  33. package/client/components/terminal/fs.js +0 -59
@@ -0,0 +1,276 @@
1
+ import { PureComponent, createRef } from 'react'
2
+ import { createTerm } from '../terminal/terminal-apis'
3
+ import deepCopy from 'json-deep-copy'
4
+ import clone from '../../common/to-simple-obj'
5
+ import { handleErr } from '../../common/fetch'
6
+ import {
7
+ statusMap
8
+ } from '../../common/constants'
9
+ import {
10
+ Spin
11
+ } from 'antd'
12
+ import {
13
+ ReloadOutlined
14
+ } from '@ant-design/icons'
15
+ import RemoteFloatControl from '../common/remote-float-control'
16
+
17
+ async function loadSpiceModule () {
18
+ if (window.spiceHtml5) return
19
+ const mod = await import('spice-client')
20
+ window.spiceHtml5 = {
21
+ SpiceMainConn: mod.SpiceMainConn,
22
+ sendCtrlAltDel: mod.sendCtrlAltDel
23
+ }
24
+ }
25
+
26
+ export default class SpiceSession extends PureComponent {
27
+ constructor (props) {
28
+ super(props)
29
+ this.state = {
30
+ loading: false,
31
+ connected: false
32
+ }
33
+ this.spiceConn = null
34
+ this.screenId = `spice-screen-${props.tab.id}`
35
+ }
36
+
37
+ domRef = createRef()
38
+
39
+ componentDidMount () {
40
+ this.remoteInit()
41
+ }
42
+
43
+ componentWillUnmount () {
44
+ this.cleanup()
45
+ }
46
+
47
+ cleanup = () => {
48
+ if (this.spiceConn) {
49
+ try {
50
+ this.spiceConn.stop()
51
+ } catch (e) {}
52
+ this.spiceConn = null
53
+ }
54
+ }
55
+
56
+ setStatus = status => {
57
+ const id = this.props.tab?.id
58
+ this.props.editTab(id, {
59
+ status
60
+ })
61
+ }
62
+
63
+ buildWsUrl = (port, type = 'spice', extra = '') => {
64
+ const { host, tokenElecterm } = this.props.config
65
+ const { id } = this.props.tab
66
+ if (window.et.buildWsUrl) {
67
+ return window.et.buildWsUrl(
68
+ host,
69
+ port,
70
+ tokenElecterm,
71
+ id,
72
+ type,
73
+ extra
74
+ )
75
+ }
76
+ return `ws://${host}:${port}/${type}/${id}?token=${tokenElecterm}${extra}`
77
+ }
78
+
79
+ calcCanvasSize = () => {
80
+ const { width, height } = this.props
81
+ return {
82
+ width: width - 10,
83
+ height: height - 80
84
+ }
85
+ }
86
+
87
+ handleSendCtrlAltDel = () => {
88
+ if (this.spiceConn && window.spiceHtml5?.sendCtrlAltDel) {
89
+ window.spiceHtml5.sendCtrlAltDel(this.spiceConn)
90
+ }
91
+ }
92
+
93
+ getControlProps = (options = {}) => {
94
+ const {
95
+ fixedPosition = true,
96
+ showExitFullscreen = true,
97
+ className = ''
98
+ } = options
99
+
100
+ return {
101
+ isFullScreen: this.props.fullscreen,
102
+ onSendCtrlAltDel: this.handleSendCtrlAltDel,
103
+ screens: [],
104
+ currentScreen: null,
105
+ onSelectScreen: () => {},
106
+ fixedPosition,
107
+ showExitFullscreen,
108
+ className
109
+ }
110
+ }
111
+
112
+ renderControl = () => {
113
+ const contrlProps = this.getControlProps({
114
+ fixedPosition: false,
115
+ showExitFullscreen: false,
116
+ className: 'mg1l'
117
+ })
118
+ return (
119
+ <div className='pd1 fix session-v-info'>
120
+ <div className='fleft'>
121
+ <ReloadOutlined
122
+ onClick={this.handleReInit}
123
+ className='mg2r mg1l pointer'
124
+ />
125
+ {this.renderInfo()}
126
+ </div>
127
+ <div className='fright'>
128
+ {this.props.fullscreenIcon()}
129
+ <RemoteFloatControl {...contrlProps} />
130
+ </div>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ remoteInit = async () => {
136
+ this.setState({
137
+ loading: true
138
+ })
139
+ const { config } = this.props
140
+ const { id } = this.props
141
+ const tab = window.store.applyProfile(deepCopy(this.props.tab || {}))
142
+ const {
143
+ type,
144
+ term: terminalType,
145
+ password
146
+ } = tab
147
+ const opts = clone({
148
+ term: terminalType || config.terminalType,
149
+ tabId: id,
150
+ uid: tab.id,
151
+ srcTabId: tab.id,
152
+ termType: type,
153
+ ...tab
154
+ })
155
+
156
+ const r = await createTerm(opts)
157
+ .catch(err => {
158
+ const text = err.message
159
+ handleErr({ message: text })
160
+ })
161
+ if (!r) {
162
+ this.setState({ loading: false })
163
+ this.setStatus(statusMap.error)
164
+ return
165
+ }
166
+
167
+ const { pid, port } = r
168
+ this.pid = pid
169
+
170
+ try {
171
+ await loadSpiceModule()
172
+ } catch (e) {
173
+ console.error('[SPICE] Failed to load SPICE module:', e)
174
+ this.setState({ loading: false })
175
+ this.setStatus(statusMap.error)
176
+ return
177
+ }
178
+
179
+ this.setStatus(statusMap.success)
180
+
181
+ const { width, height } = this.calcCanvasSize()
182
+ const wsUrl = this.buildWsUrl(port, 'spice', `&width=${width}&height=${height}`)
183
+
184
+ try {
185
+ const SpiceMainConn = window.spiceHtml5.SpiceMainConn
186
+ const spiceOpts = {
187
+ uri: wsUrl,
188
+ password: password || '',
189
+ screen_id: this.screenId,
190
+ onsuccess: () => {
191
+ this.setState({
192
+ loading: false,
193
+ connected: true
194
+ })
195
+ this.setStatus(statusMap.success)
196
+ },
197
+ onerror: (e) => {
198
+ console.error('[SPICE] Connection error:', e)
199
+ this.setState({ loading: false })
200
+ this.setStatus(statusMap.error)
201
+ },
202
+ onagent: () => {}
203
+ }
204
+
205
+ this.spiceConn = new SpiceMainConn(spiceOpts)
206
+
207
+ this.setState({
208
+ loading: false
209
+ })
210
+ } catch (e) {
211
+ console.error('[SPICE] Connection failed:', e)
212
+ this.setState({ loading: false })
213
+ this.setStatus(statusMap.error)
214
+ }
215
+ }
216
+
217
+ handleReInit = () => {
218
+ this.cleanup()
219
+ this.props.reloadTab(
220
+ this.props.tab
221
+ )
222
+ }
223
+
224
+ renderInfo () {
225
+ const {
226
+ host,
227
+ port,
228
+ username
229
+ } = this.props.tab
230
+ return (
231
+ <span className='mg2l mg2r'>
232
+ {username}@{host}:{port}
233
+ </span>
234
+ )
235
+ }
236
+
237
+ render () {
238
+ const { width: w, height: h } = this.props
239
+ const { loading } = this.state
240
+ const { width: innerWidth, height: innerHeight } = this.calcCanvasSize()
241
+ const wrapperStyle = {
242
+ width: innerWidth + 'px',
243
+ height: innerHeight + 'px',
244
+ overflow: 'hidden'
245
+ }
246
+ const contrlProps = this.getControlProps()
247
+ return (
248
+ <Spin spinning={loading}>
249
+ <div
250
+ className='rdp-session-wrap session-v-wrap'
251
+ style={{
252
+ width: w + 'px',
253
+ height: h + 'px'
254
+ }}
255
+ >
256
+ {this.renderControl()}
257
+ <RemoteFloatControl {...contrlProps} />
258
+ <div
259
+ style={wrapperStyle}
260
+ className='spice-scroll-wrapper'
261
+ >
262
+ <div
263
+ ref={this.domRef}
264
+ id={this.screenId}
265
+ className='spice-session-wrap session-v-wrap'
266
+ style={{
267
+ width: '100%',
268
+ height: '100%'
269
+ }}
270
+ />
271
+ </div>
272
+ </div>
273
+ </Spin>
274
+ )
275
+ }
276
+ }
@@ -5,7 +5,8 @@
5
5
  import React, { useCallback } from 'react'
6
6
  import {
7
7
  CodeFilled,
8
- RightSquareFilled
8
+ RightSquareFilled,
9
+ RobotOutlined
9
10
  } from '@ant-design/icons'
10
11
  import BookmarksList from '../sidebar/bookmark-select'
11
12
  import DragHandle from '../common/drag-handle'
@@ -23,7 +24,7 @@ export default function AddBtnMenu ({
23
24
  addPanelWidth,
24
25
  setAddPanelWidth
25
26
  }) {
26
- const { onNewSsh } = window.store
27
+ const { onNewSsh, onNewSshAI } = window.store
27
28
  const cls = 'pd2x pd1y context-item pointer'
28
29
  const addTabBtn = window.store.hasNodePty
29
30
  ? (
@@ -80,6 +81,12 @@ export default function AddBtnMenu ({
80
81
  <CodeFilled /> {e('newBookmark')}
81
82
  </div>
82
83
  {addTabBtn}
84
+ <div
85
+ className={cls}
86
+ onClick={onNewSshAI}
87
+ >
88
+ <RobotOutlined /> {e('createBookmarkByAI')}
89
+ </div>
83
90
  </div>
84
91
  <div className='add-menu-list'>
85
92
  <BookmarksList
@@ -1,57 +1,46 @@
1
- /**
2
- * customize AttachAddon
3
- */
4
- import { AttachAddon } from '@xterm/addon-attach'
1
+ import { loadAttachAddon } from './xterm-loader.js'
5
2
 
6
- export default class AttachAddonCustom extends AttachAddon {
3
+ export default class AttachAddonCustom {
7
4
  constructor (term, socket, isWindowsShell) {
8
- super(socket)
9
5
  this.term = term
10
6
  this.socket = socket
11
7
  this.isWindowsShell = isWindowsShell
12
- // Output suppression state for shell integration injection
13
8
  this.outputSuppressed = false
14
9
  this.suppressedData = []
15
10
  this.suppressTimeout = null
16
11
  this.onSuppressionEndCallback = null
17
- // Track if we've received initial data from the terminal
18
12
  this.hasReceivedInitialData = false
19
13
  this.onInitialDataCallback = null
14
+ this._bidirectional = true
15
+ this._disposables = []
16
+ this._socket = socket
17
+ this.decoder = new TextDecoder('utf-8')
18
+ }
19
+
20
+ _initBase = async () => {
21
+ const AttachAddon = await loadAttachAddon()
22
+ const base = new AttachAddon(this._socket, { bidirectional: this._bidirectional })
23
+ this._sendData = base._sendData.bind(base)
20
24
  }
21
25
 
22
- /**
23
- * Set callback for when initial data is received
24
- * @param {Function} callback - Called when first data arrives
25
- */
26
26
  onInitialData = (callback) => {
27
27
  if (this.hasReceivedInitialData) {
28
- // Already received, call immediately
29
28
  callback()
30
29
  } else {
31
30
  this.onInitialDataCallback = callback
32
31
  }
33
32
  }
34
33
 
35
- /**
36
- * Start suppressing output - used during shell integration injection
37
- * @param {number} timeout - Max time to suppress in ms (safety fallback)
38
- * @param {Function} onEnd - Callback when suppression ends
39
- */
40
34
  startOutputSuppression = (timeout = 3000, onEnd = null) => {
41
35
  this.outputSuppressed = true
42
36
  this.suppressedData = []
43
37
  this.onSuppressionEndCallback = onEnd
44
- // Safety timeout to ensure we always resume
45
38
  this.suppressTimeout = setTimeout(() => {
46
39
  console.warn('[AttachAddon] Output suppression timeout reached, resuming')
47
40
  this.stopOutputSuppression(false)
48
41
  }, timeout)
49
42
  }
50
43
 
51
- /**
52
- * Stop suppressing output and optionally discard buffered data
53
- * @param {boolean} discard - If true, discard buffered data; if false, write it to terminal
54
- */
55
44
  stopOutputSuppression = (discard = true) => {
56
45
  if (this.suppressTimeout) {
57
46
  clearTimeout(this.suppressTimeout)
@@ -60,14 +49,12 @@ export default class AttachAddonCustom extends AttachAddon {
60
49
  this.outputSuppressed = false
61
50
 
62
51
  if (!discard && this.suppressedData.length > 0) {
63
- // Write buffered data to terminal
64
52
  for (const data of this.suppressedData) {
65
53
  this.writeToTerminalDirect(data)
66
54
  }
67
55
  }
68
56
  this.suppressedData = []
69
57
 
70
- // Call the end callback if set
71
58
  if (this.onSuppressionEndCallback) {
72
59
  const callback = this.onSuppressionEndCallback
73
60
  this.onSuppressionEndCallback = null
@@ -75,77 +62,43 @@ export default class AttachAddonCustom extends AttachAddon {
75
62
  }
76
63
  }
77
64
 
78
- /**
79
- * Check if we should resume output based on OSC 633 detection
80
- * Called when shell integration is detected
81
- */
82
65
  onShellIntegrationDetected = () => {
83
66
  if (this.outputSuppressed) {
84
- this.stopOutputSuppression(true) // Discard the integration command output
67
+ this.stopOutputSuppression(true)
85
68
  }
86
69
  }
87
70
 
88
- activate (terminal = this.term) {
89
- this.trzsz = window.newTrzsz(
90
- this.writeToTerminal,
91
- this.sendToServer,
92
- terminal.cols,
93
- this.isWindowsShell
94
- )
95
-
71
+ activate = async (terminal = this.term) => {
72
+ await this._initBase()
96
73
  this.addSocketListener(this._socket, 'message', this.onMsg)
97
74
 
98
75
  if (this._bidirectional) {
99
- this._disposables.push(terminal.onData((data) => this.trzsz.processTerminalInput(data)))
100
- this._disposables.push(terminal.onBinary((data) => this.trzsz.processBinaryInput(data)))
76
+ this._disposables.push(terminal.onData((data) => this.sendToServer(data)))
77
+ this._disposables.push(terminal.onBinary((data) => this.sendToServer(new Uint8Array(data))))
101
78
  }
102
79
 
103
- this._disposables.push(terminal.onResize((size) => this.trzsz.setTerminalColumns(size.cols)))
104
-
105
80
  this._disposables.push(this.addSocketListener(this._socket, 'close', () => this.dispose()))
106
81
  this._disposables.push(this.addSocketListener(this._socket, 'error', () => this.dispose()))
107
82
  }
108
83
 
109
84
  onMsg = (ev) => {
110
- // Check if it's a JSON zmodem control message
111
85
  if (typeof ev.data === 'string') {
112
86
  try {
113
87
  const msg = JSON.parse(ev.data)
114
- if (msg.action === 'zmodem-event') {
115
- // Let zmodem-client handle this, don't write to terminal
88
+ if (msg.action === 'zmodem-event' || msg.action === 'trzsz-event') {
116
89
  return
117
90
  }
118
- } catch (e) {
119
- // Not JSON, continue processing
120
- }
91
+ } catch (e) {}
121
92
  }
122
93
 
123
- // When in alternate screen mode (like vim, less, or TUI apps like Claude Code),
124
- // bypass trzsz processing to avoid interference with the application's display
125
- if (this.term?.buffer?.active?.type === 'alternate') {
126
- this.writeToTerminal(ev.data)
127
- } else {
128
- this.trzsz.processServerOutput(ev.data)
129
- }
94
+ this.writeToTerminal(ev.data)
130
95
  }
131
96
 
132
- /**
133
- * Check if data contains OSC 633 shell integration sequences
134
- * @param {string} str - Data string to check
135
- * @returns {boolean} True if OSC 633 sequence detected
136
- */
137
97
  checkForShellIntegration = (str) => {
138
- // OSC 633 sequences: ESC]633;X where X is A, B, C, D, E, or P
139
- // ESC is character code 27 (0x1b)
140
- // Use includes with the actual characters to avoid lint warning
141
98
  const ESC = String.fromCharCode(27)
142
99
  return str.includes(ESC + ']633;')
143
100
  }
144
101
 
145
- /**
146
- * Write directly to terminal, bypassing suppression check
147
- * Used for flushing buffered data
148
- */
149
102
  writeToTerminalDirect = (data) => {
150
103
  const { term } = this
151
104
  if (term.parent?.onZmodem) {
@@ -163,22 +116,18 @@ export default class AttachAddonCustom extends AttachAddon {
163
116
  return
164
117
  }
165
118
 
166
- // Track initial data arrival
167
119
  if (!this.hasReceivedInitialData) {
168
120
  this.hasReceivedInitialData = true
169
121
  if (this.onInitialDataCallback) {
170
122
  const callback = this.onInitialDataCallback
171
123
  this.onInitialDataCallback = null
172
- // Call after a micro-delay to ensure this data is written first
173
124
  setTimeout(callback, 0)
174
125
  }
175
126
  }
176
127
 
177
- // Check for shell integration in the data (only when suppressing)
178
128
  if (this.outputSuppressed) {
179
129
  let str = data
180
130
  if (typeof data !== 'string') {
181
- // Convert to string to check for OSC 633
182
131
  const decoder = this.decoder || new TextDecoder('utf-8')
183
132
  try {
184
133
  str = decoder.decode(data instanceof ArrayBuffer ? data : new Uint8Array(data))
@@ -187,14 +136,11 @@ export default class AttachAddonCustom extends AttachAddon {
187
136
  }
188
137
  }
189
138
 
190
- // If we detect OSC 633, shell integration is working
191
139
  if (this.checkForShellIntegration(str)) {
192
140
  this.onShellIntegrationDetected()
193
- // Don't buffer this - just discard the integration output
194
141
  return
195
142
  }
196
143
 
197
- // Buffer the data while suppressed
198
144
  this.suppressedData.push(data)
199
145
  return
200
146
  }
@@ -213,8 +159,6 @@ export default class AttachAddonCustom extends AttachAddon {
213
159
  const { term } = this
214
160
  term?.parent?.notifyOnData()
215
161
  const str = this.decoder.decode(data)
216
- // CWD tracking is now handled by shell integration automatically
217
- // No need to parse PS1 markers
218
162
  term?.write(str)
219
163
  }
220
164