@electerm/electerm-react 3.12.0 → 3.15.28

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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Osc52Addon - Handles OSC 52 clipboard escape sequences
3
+ *
4
+ * OSC 52 allows terminal programs (TUI apps like vim, tmux, opencode, etc.)
5
+ * to copy and paste text via the system clipboard.
6
+ *
7
+ * Format: ESC ] 52 ; Pc ; Pd BEL (or ST)
8
+ * Pc = clipboard selection: c (clipboard), p (primary), s (secondary)
9
+ * Pd = base64-encoded content, or "?" to read
10
+ *
11
+ * Write request: ESC ] 52 ; c ; <base64data> BEL
12
+ * -> decodes base64 and writes to system clipboard
13
+ *
14
+ * Read request: ESC ] 52 ; c ; ? BEL
15
+ * -> reads system clipboard, encodes base64, sends response
16
+ */
17
+
18
+ export class Osc52Addon {
19
+ constructor () {
20
+ this.terminal = undefined
21
+ this._disposables = []
22
+ this._sendData = null
23
+ }
24
+
25
+ /**
26
+ * Set the function used to send data back to the PTY
27
+ * @param {function} sendDataFn - function(string) to send data to server
28
+ */
29
+ setSendData (sendDataFn) {
30
+ this._sendData = sendDataFn
31
+ }
32
+
33
+ activate (terminal) {
34
+ this.terminal = terminal
35
+
36
+ if (terminal.parser && terminal.parser.registerOscHandler) {
37
+ const oscHandler = terminal.parser.registerOscHandler(52, (data) => {
38
+ return this._handleOsc52(data)
39
+ })
40
+ this._disposables.push(oscHandler)
41
+ }
42
+ }
43
+
44
+ dispose () {
45
+ this.terminal = null
46
+ if (this._disposables) {
47
+ this._disposables.forEach(d => d.dispose())
48
+ this._disposables.length = 0
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Handle OSC 52 clipboard sequence
54
+ * @param {string} data - The OSC data after "52;"
55
+ * @returns {boolean} Whether the sequence was handled
56
+ */
57
+ _handleOsc52 (data) {
58
+ if (!data) return false
59
+
60
+ // Parse: Pc;Pd where Pc is selection target and Pd is payload
61
+ const semicolonIdx = data.indexOf(';')
62
+ if (semicolonIdx === -1) return false
63
+
64
+ const target = data.substring(0, semicolonIdx)
65
+ const payload = data.substring(semicolonIdx + 1)
66
+
67
+ // Only handle clipboard target ('c'), also accept 'c' among multiple targets
68
+ if (!target.includes('c')) return false
69
+
70
+ if (payload === '?') {
71
+ // Read request - send clipboard content back
72
+ return this._handleReadRequest()
73
+ }
74
+
75
+ // Write request - decode and write to clipboard
76
+ return this._handleWriteRequest(payload)
77
+ }
78
+
79
+ /**
80
+ * Handle clipboard read request (ESC]52;c;? BEL)
81
+ * Reads system clipboard and sends response back to the terminal
82
+ */
83
+ _handleReadRequest () {
84
+ try {
85
+ const text = window.pre.readClipboard()
86
+ const base64 = text ? this._encodeBase64(text) : ''
87
+ this._sendResponse(base64)
88
+ } catch (e) {
89
+ console.error('OSC 52 clipboard read failed:', e)
90
+ this._sendResponse('')
91
+ }
92
+ return true
93
+ }
94
+
95
+ /**
96
+ * Handle clipboard write request (ESC]52;c;<base64> BEL)
97
+ * Decodes base64 content and writes to system clipboard
98
+ */
99
+ _handleWriteRequest (base64Data) {
100
+ try {
101
+ const text = this._decodeBase64(base64Data)
102
+ if (text) {
103
+ window.pre.writeClipboard(text)
104
+ }
105
+ } catch (e) {
106
+ console.error('OSC 52 clipboard write failed:', e)
107
+ }
108
+ return true
109
+ }
110
+
111
+ /**
112
+ * Send OSC 52 response back to the terminal (for read requests)
113
+ * @param {string} base64Data - base64-encoded clipboard content
114
+ */
115
+ _sendResponse (base64Data) {
116
+ if (!this._sendData) return
117
+ // Response format: ESC ] 52 ; c ; <base64> BEL
118
+ const response = `\x1b]52;c;${base64Data}\x07`
119
+ this._sendData(response)
120
+ }
121
+
122
+ /**
123
+ * Encode a string to base64 (handles UTF-8 properly)
124
+ */
125
+ _encodeBase64 (str) {
126
+ const encoder = new TextEncoder()
127
+ const bytes = encoder.encode(str)
128
+ let binary = ''
129
+ for (let i = 0; i < bytes.length; i++) {
130
+ binary += String.fromCharCode(bytes[i])
131
+ }
132
+ return btoa(binary)
133
+ }
134
+
135
+ /**
136
+ * Decode a base64 string (handles UTF-8 properly)
137
+ */
138
+ _decodeBase64 (base64) {
139
+ const binary = atob(base64)
140
+ const bytes = new Uint8Array(binary.length)
141
+ for (let i = 0; i < binary.length; i++) {
142
+ bytes[i] = binary.charCodeAt(i)
143
+ }
144
+ const decoder = new TextDecoder()
145
+ return decoder.decode(bytes)
146
+ }
147
+ }
@@ -49,3 +49,12 @@ export function setTerminalLogPath (pid, logPath) {
49
49
  action: 'set-terminal-log-path'
50
50
  })
51
51
  }
52
+
53
+ export function startTerminalLogFile (pid, logFilePath, addTimeStampToTermLog) {
54
+ return fetch({
55
+ pid,
56
+ logFilePath,
57
+ addTimeStampToTermLog,
58
+ action: 'start-terminal-log-file'
59
+ })
60
+ }
@@ -7,6 +7,8 @@ import {
7
7
  Dropdown
8
8
  } from 'antd'
9
9
  import message from '../common/message'
10
+ import { notification } from '../common/notification'
11
+ import ShowItem from '../common/show-item.jsx'
10
12
  import Modal from '../common/modal'
11
13
  import classnames from 'classnames'
12
14
  import './terminal.styl'
@@ -30,11 +32,13 @@ import { XmodemClient } from './xmodem-client.js'
30
32
  import DropFileModal from './drop-file-modal.jsx'
31
33
  import keyControlPressed from '../../common/key-control-pressed.js'
32
34
  import NormalBuffer from './normal-buffer.jsx'
33
- import { createTerm, resizeTerm } from './terminal-apis.js'
35
+ import { createTerm, resizeTerm, startTerminalLogFile, toggleTerminalLog } from './terminal-apis.js'
34
36
  import { shortcutExtend, shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
35
37
  import { KeywordHighlighterAddon } from './highlight-addon.js'
36
38
  import { getFilePath, isUnsafeFilename } from '../../common/file-drop-utils.js'
39
+ import { getFolderFromFilePath } from '../sftp/file-read.js'
37
40
  import { CommandTrackerAddon } from './command-tracker-addon.js'
41
+ import { Osc52Addon } from './osc52-addon.js'
38
42
  import AIIcon from '../icons/ai-icon.jsx'
39
43
  import {
40
44
  getShellIntegrationCommand,
@@ -72,6 +76,9 @@ class Term extends Component {
72
76
  saveTerminalLogToFile: !!this.props.config.saveTerminalLogToFile,
73
77
  addTimeStampToTermLog: !!this.props.config.addTimeStampToTermLog,
74
78
  logPath: this.props.config.sessionLogPath || createDefaultLogPath(),
79
+ logFileName: '',
80
+ recording: false,
81
+ recordingFilePath: '',
75
82
  passType: 'password',
76
83
  lines: [],
77
84
  searchResults: [],
@@ -198,6 +205,9 @@ class Term extends Component {
198
205
  this.encode || this.props.tab.encode || 'utf-8'
199
206
  )
200
207
  await this.attachAddon.activate(this.term)
208
+ if (this.osc52Addon) {
209
+ this.osc52Addon.setSendData(this.attachAddon._sendData.bind(this.attachAddon))
210
+ }
201
211
  }
202
212
 
203
213
  getValue = (props, type, name) => {
@@ -385,7 +395,8 @@ class Term extends Component {
385
395
  if (isUnsafeFilename(p)) {
386
396
  return message.error('File name contains unsafe characters')
387
397
  }
388
- this.runQuickCommand(`cd "${p}"`)
398
+ const isWinPath = /^[a-zA-Z]:\\/.test(p)
399
+ this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
389
400
  }
390
401
 
391
402
  onDrop = e => {
@@ -676,8 +687,101 @@ class Term extends Component {
676
687
  )
677
688
  }
678
689
 
690
+ getTerminalBufferText = () => {
691
+ const { addTimeStampToTermLog } = this.state
692
+ const buffer = this.term.buffer.active
693
+ const len = buffer.length
694
+ const rawLines = []
695
+ for (let i = 0; i < len; i++) {
696
+ const line = buffer.getLine(i)
697
+ rawLines.push(line ? line.translateToString(false) : '')
698
+ }
699
+ // trim trailing blank lines before applying timestamps
700
+ while (rawLines.length && !rawLines[rawLines.length - 1].trim()) {
701
+ rawLines.pop()
702
+ }
703
+ if (!addTimeStampToTermLog) {
704
+ return rawLines.join('\n')
705
+ }
706
+ return rawLines.map(text => {
707
+ const now = new Date()
708
+ const ts = `[${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}] `
709
+ return ts + text
710
+ }).join('\n')
711
+ }
712
+
713
+ syncTermInfo = (stateUpdate) => {
714
+ this.setState(stateUpdate)
715
+ const infoUpdate = pick(stateUpdate, ['saveTerminalLogToFile', 'addTimeStampToTermLog', 'logPath', 'logFileName'])
716
+ if (Object.keys(infoUpdate).length) {
717
+ refs.get('term-info-' + this.props.tab.id)?.setState(infoUpdate)
718
+ }
719
+ }
720
+
721
+ openLogSaveDialog = async (titleKey) => {
722
+ const { logName } = this.props
723
+ const result = await window.api.saveDialog({
724
+ title: e(titleKey),
725
+ defaultPath: logName + '.log',
726
+ filters: [
727
+ { name: 'Log files', extensions: ['log'] }
728
+ ],
729
+ properties: ['createDirectory', 'showOverwriteConfirmation']
730
+ })
731
+ if (result.canceled || !result.filePath) {
732
+ return null
733
+ }
734
+ return result.filePath
735
+ }
736
+
737
+ onSaveTerminalLog = async () => {
738
+ const filePath = await this.openLogSaveDialog('saveTerminalLogToFile')
739
+ if (!filePath) {
740
+ return
741
+ }
742
+ const content = this.getTerminalBufferText()
743
+ await window.fs.writeFile(filePath, content).catch(window.store.onError)
744
+ const { addTimeStampToTermLog } = this.state
745
+ startTerminalLogFile(this.pid, filePath, addTimeStampToTermLog).catch(window.store.onError)
746
+ const { path: logPath, name: logFileName } = getFolderFromFilePath(filePath, false)
747
+ this.syncTermInfo({ saveTerminalLogToFile: true, logPath, logFileName })
748
+ notification.success({
749
+ message: e('saveTerminalLogToFile'),
750
+ description: <ShowItem to={filePath}>{filePath}</ShowItem>,
751
+ duration: 5
752
+ })
753
+ }
754
+
755
+ onRecord = async () => {
756
+ const filePath = await this.openLogSaveDialog('record')
757
+ if (!filePath) {
758
+ return
759
+ }
760
+ const { addTimeStampToTermLog } = this.state
761
+ startTerminalLogFile(this.pid, filePath, addTimeStampToTermLog).catch(window.store.onError)
762
+ const { path: logPath, name: logFileName } = getFolderFromFilePath(filePath, false)
763
+ this.syncTermInfo({ saveTerminalLogToFile: true, logPath, logFileName })
764
+ this.setState({ recording: true, recordingFilePath: filePath })
765
+ notification.success({
766
+ message: e('record'),
767
+ description: <ShowItem to={filePath}>{filePath}</ShowItem>,
768
+ duration: 5
769
+ })
770
+ }
771
+
772
+ onStopRecord = () => {
773
+ const { recordingFilePath } = this.state
774
+ toggleTerminalLog(this.pid).catch(window.store.onError)
775
+ this.syncTermInfo({ saveTerminalLogToFile: false })
776
+ this.setState({ recording: false, recordingFilePath: '' })
777
+ notification.success({
778
+ message: e('stopRecord'),
779
+ description: <ShowItem to={recordingFilePath}>{recordingFilePath}</ShowItem>
780
+ })
781
+ }
782
+
679
783
  renderContextMenu = () => {
680
- const { hasSelection } = this.state
784
+ const { hasSelection, recording } = this.state
681
785
  const copyed = true
682
786
  const copyShortcut = this.getShortcut('terminal_copy')
683
787
  const pasteShortcut = this.getShortcut('terminal_paste')
@@ -730,6 +834,16 @@ class Term extends Component {
730
834
  icon: <iconsMap.SearchOutlined />,
731
835
  label: e('search'),
732
836
  extra: searchShortcut
837
+ },
838
+ {
839
+ key: 'onSaveTerminalLog',
840
+ icon: <iconsMap.SaveOutlined />,
841
+ label: e('saveTerminalLogToFile')
842
+ },
843
+ {
844
+ key: recording ? 'onStopRecord' : 'onRecord',
845
+ icon: recording ? <iconsMap.StopOutlined /> : <iconsMap.PlayCircleFilled />,
846
+ label: e(recording ? 'stopRecord' : 'record')
733
847
  }
734
848
  ]
735
849
  if (isSerial) {
@@ -1016,6 +1130,8 @@ class Term extends Component {
1016
1130
  term.loadAddon(this.fitAddon)
1017
1131
  term.loadAddon(this.searchAddon)
1018
1132
  term.loadAddon(this.cmdAddon)
1133
+ this.osc52Addon = new Osc52Addon()
1134
+ term.loadAddon(this.osc52Addon)
1019
1135
  if (tab.enableTerminalImage) {
1020
1136
  const ImageAddon = await loadImageAddon()
1021
1137
  this.imageAddon = new ImageAddon({
@@ -66,6 +66,68 @@ export class XmodemClient extends TransferClientBase {
66
66
  case 'session-error':
67
67
  this.onError(msg.error)
68
68
  break
69
+ case 'auto-trigger-receive':
70
+ this.handleAutoReceive(msg.name)
71
+ break
72
+ case 'auto-trigger-send':
73
+ this.handleAutoSend()
74
+ break
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Auto-triggered: device is sending a file, electerm should receive it.
80
+ * Opens save folder dialog then starts xmodem receive.
81
+ * @param {string} fileName - Original filename from the device
82
+ */
83
+ async handleAutoReceive (fileName) {
84
+ if (this.isActive) return
85
+
86
+ this.isActive = true
87
+ this.writeBanner('RECEIVE', null)
88
+
89
+ const savePath = await this.openSaveFolderSelect()
90
+ if (savePath) {
91
+ this.savePath = savePath
92
+ this.sendToServer({
93
+ event: 'start-receive'
94
+ })
95
+ this.sendToServer({
96
+ event: 'set-save-path',
97
+ path: savePath,
98
+ name: fileName
99
+ })
100
+ } else {
101
+ this.isActive = false
102
+ this.writeToTerminal('\r\n\x1b[33mXMODEM Receive cancelled\x1b[0m\r\n')
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Auto-triggered: device wants to receive a file, electerm should send it.
108
+ * Opens file select dialog then starts xmodem send.
109
+ */
110
+ async handleAutoSend () {
111
+ if (this.isActive) return
112
+
113
+ this.isActive = true
114
+ this.writeBanner('SEND', null)
115
+
116
+ const files = await this.openFileSelect({
117
+ title: 'Choose file to send via XMODEM',
118
+ message: 'Choose file to send via XMODEM'
119
+ })
120
+ if (files && files.length > 0) {
121
+ this.sendToServer({
122
+ event: 'start-send'
123
+ })
124
+ this.sendToServer({
125
+ event: 'send-files',
126
+ files
127
+ })
128
+ } else {
129
+ this.isActive = false
130
+ this.writeToTerminal('\r\n\x1b[33mXMODEM Send cancelled\x1b[0m\r\n')
69
131
  }
70
132
  }
71
133
 
@@ -8,7 +8,7 @@ import {
8
8
  Button
9
9
  } from 'antd'
10
10
  import defaults from '../../common/default-setting'
11
- import { toggleTerminalLog, toggleTerminalLogTimestamp, setTerminalLogPath } from '../terminal/terminal-apis'
11
+ import { toggleTerminalLog, toggleTerminalLogTimestamp } from '../terminal/terminal-apis'
12
12
  import {
13
13
  ClockCircleOutlined,
14
14
  BorderlessTableOutlined,
@@ -18,7 +18,9 @@ import {
18
18
  PartitionOutlined
19
19
  } from '@ant-design/icons'
20
20
  import { refs } from '../common/ref'
21
- import LogPathEdit from './log-path-edit'
21
+ import ShowItem from '../common/show-item'
22
+ import { osResolve } from '../../common/resolve'
23
+ import createDefaultLogPath from '../../common/default-log-path'
22
24
 
23
25
  const e = window.translate
24
26
 
@@ -35,15 +37,20 @@ export default class TerminalInfoBase extends Component {
35
37
  state = {
36
38
  saveTerminalLogToFile: false,
37
39
  addTimeStampToTermLog: false,
38
- logPath: ''
40
+ logPath: '',
41
+ logFileName: ''
39
42
  }
40
43
 
41
44
  componentDidMount () {
45
+ const { pid } = this.props
46
+ refs.add('term-info-' + pid, this)
42
47
  this.getState()
43
48
  }
44
49
 
45
50
  componentWillUnmount () {
46
51
  clearTimeout(this.timer)
52
+ const { pid } = this.props
53
+ refs.remove('term-info-' + pid)
47
54
  }
48
55
 
49
56
  handleToggleTimestamp = () => {
@@ -74,17 +81,6 @@ export default class TerminalInfoBase extends Component {
74
81
  })
75
82
  }
76
83
 
77
- onLogPathChange = (v) => {
78
- const { pid } = this.props
79
- setTerminalLogPath(pid, v)
80
- refs.get('term-' + pid)?.setState({
81
- logPath: v
82
- })
83
- this.setState({
84
- logPath: v
85
- })
86
- }
87
-
88
84
  handleToggle = () => {
89
85
  const { saveTerminalLogToFile, addTimeStampToTermLog } = this.state
90
86
  const {
@@ -112,7 +108,8 @@ export default class TerminalInfoBase extends Component {
112
108
  this.setState({
113
109
  saveTerminalLogToFile: term.state.saveTerminalLogToFile,
114
110
  addTimeStampToTermLog: term.state.addTimeStampToTermLog,
115
- logPath: term.state.logPath
111
+ logPath: term.state.logPath,
112
+ logFileName: term.state.logFileName || ''
116
113
  })
117
114
  } else {
118
115
  this.timer = setTimeout(this.getState, 100)
@@ -166,39 +163,45 @@ export default class TerminalInfoBase extends Component {
166
163
  render () {
167
164
  const {
168
165
  id,
169
- logName,
170
- pid
166
+ logName
171
167
  } = this.props
172
- const { saveTerminalLogToFile, logPath } = this.state
168
+ const { saveTerminalLogToFile, logPath, logFileName } = this.state
173
169
  const name = e('saveTerminalLogToFile')
170
+ const base = logPath || createDefaultLogPath()
171
+ const fileName = logFileName || (logName + '.log')
172
+ const fullPath = osResolve(base, fileName)
174
173
  return (
175
174
  <div className='terminal-info-section terminal-info-base'>
176
- <div className='fix'>
177
- <span className='fleft'><b>ID:</b> {id}</span>
178
- <span className='fright'>
179
- <Switch
180
- checkedChildren={name}
181
- unCheckedChildren={name}
182
- checked={saveTerminalLogToFile}
183
- onChange={this.handleToggle}
184
- className='mg1r mg1b'
185
- />
186
- {
187
- this.renderTimestamp()
188
- }
189
- </span>
175
+ <div className='pd1b'>
176
+ <b>ID:</b> {id}
177
+ </div>
178
+ <div className='pd1b'>
179
+ <Switch
180
+ checkedChildren={name}
181
+ unCheckedChildren={name}
182
+ checked={saveTerminalLogToFile}
183
+ onChange={this.handleToggle}
184
+ className='mg1r mg1b'
185
+ />
186
+ {
187
+ this.renderTimestamp()
188
+ }
190
189
  </div>
190
+ {
191
+ saveTerminalLogToFile
192
+ ? (
193
+ <div className='pd1b font-xs color-grey'>
194
+ {e('terminalLogPath')}: {fullPath} <ShowItem to={fullPath} />
195
+ </div>
196
+ )
197
+ : null
198
+ }
191
199
  <div className='pd2y'>
192
200
  {
193
201
  this.renderInfoSelection()
194
202
  }
195
203
  </div>
196
- <LogPathEdit
197
- pid={pid}
198
- logPath={logPath}
199
- logName={logName}
200
- setLogPath={this.onLogPathChange}
201
- />
204
+
202
205
  </div>
203
206
  )
204
207
  }
@@ -12,10 +12,11 @@ import { osResolve } from '../../common/resolve'
12
12
 
13
13
  const e = window.translate
14
14
 
15
- export default function LogPathEdit ({ pid, logPath, logName, setLogPath }) {
15
+ export default function LogPathEdit ({ pid, logPath, logName, logFileName, setLogPath }) {
16
16
  const defaultPath = createDefaultLogPath()
17
17
  const base = logPath || defaultPath
18
- const fullPath = osResolve(base, logName + '.log')
18
+ const fileName = logFileName || (logName + '.log')
19
+ const fullPath = osResolve(base, fileName)
19
20
 
20
21
  const testAndSet = async (v) => {
21
22
  if (v) {
@@ -298,7 +298,7 @@ export default Store => {
298
298
  }
299
299
 
300
300
  Store.prototype.aiConfigMissing = function () {
301
- return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI').some(k => !window.store.config[k])
301
+ return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
302
302
  }
303
303
 
304
304
  Store.prototype.clearHistory = function () {
@@ -52,6 +52,20 @@ export async function addTabFromCommandLine (store, opts) {
52
52
  if (isHelp) {
53
53
  return store.openAbout(infoTabs.cmd)
54
54
  }
55
+ // Check if argv contains a protocol URL (e.g., ssh://user@host)
56
+ // and use parseQuickConnect for proper parsing
57
+ if (argv && argv.length) {
58
+ const protocolUrl = argv.find(arg =>
59
+ /^(ssh|telnet|rdp|vnc|serial|spice|ftp|http|https|electerm):\/\//i.test(arg)
60
+ )
61
+ if (protocolUrl) {
62
+ const parsed = parseQuickConnect(protocolUrl)
63
+ if (parsed) {
64
+ return store.ipcOpenTab(parsed)
65
+ }
66
+ }
67
+ }
68
+
55
69
  const conf = getHost(argv, options)
56
70
  const update = {
57
71
  passphrase: options.passphrase,