@electerm/electerm-react 2.4.16 → 2.4.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.
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react'
1
+ import React, { useState, useEffect, useRef } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import {
4
4
  CloseOutlined
@@ -26,15 +26,40 @@ const notify = (messages) => {
26
26
  let activeMessages = []
27
27
 
28
28
  function MessageItem ({ id, type, content, duration, onRemove, timestamp }) {
29
+ const timeoutIdRef = useRef(null)
30
+
29
31
  useEffect(() => {
30
32
  if (duration !== 0) {
31
33
  const timer = setTimeout(onRemove, duration * 1000)
32
- return () => clearTimeout(timer)
34
+ timeoutIdRef.current = timer
35
+ return () => {
36
+ clearTimeout(timeoutIdRef.current)
37
+ timeoutIdRef.current = null
38
+ }
33
39
  }
34
40
  }, [duration, onRemove, timestamp])
35
41
 
42
+ const handleMouseEnter = () => {
43
+ if (timeoutIdRef.current) {
44
+ clearTimeout(timeoutIdRef.current)
45
+ timeoutIdRef.current = null
46
+ }
47
+ }
48
+
49
+ const handleMouseLeave = () => {
50
+ if (duration !== 0) {
51
+ const timer = setTimeout(onRemove, duration * 1000)
52
+ timeoutIdRef.current = timer
53
+ }
54
+ }
55
+
36
56
  return (
37
- <div className={classnames('message-item', type)} id={`message-${id}`}>
57
+ <div
58
+ className={classnames('message-item', type)}
59
+ id={`message-${id}`}
60
+ onMouseEnter={handleMouseEnter}
61
+ onMouseLeave={handleMouseLeave}
62
+ >
38
63
  <div className='message-content-wrap'>
39
64
  {messageIcons[type]}
40
65
  <div className='message-content'>{content}</div>
@@ -43,11 +43,9 @@
43
43
  margin-left 8px
44
44
  cursor pointer
45
45
  font-size 12px
46
- color var(--text-dark)
47
- opacity 0
48
- transition opacity 0.2s
46
+ color var(--text)
49
47
  &:hover
50
- color var(--text-light)
48
+ font-weight bold
51
49
 
52
50
  @keyframes message-fade-in
53
51
  from
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react'
1
+ import React, { useState, useEffect, useRef } from 'react'
2
2
  import { CloseOutlined } from '@ant-design/icons'
3
3
  import classnames from 'classnames'
4
4
  import generateId from '../../common/uid'
@@ -70,17 +70,41 @@ export function NotificationContainer () {
70
70
  }
71
71
 
72
72
  function NotificationItem ({ message, description, type, onClose, duration = 18.5 }) {
73
+ const timeoutRef = useRef(null)
74
+
73
75
  useEffect(() => {
74
76
  if (duration > 0) {
75
- const timer = setTimeout(onClose, duration * 1000)
76
- return () => clearTimeout(timer)
77
+ timeoutRef.current = setTimeout(onClose, duration * 1000)
78
+ }
79
+ return () => {
80
+ if (timeoutRef.current) {
81
+ clearTimeout(timeoutRef.current)
82
+ timeoutRef.current = null
83
+ }
84
+ }
85
+ }, [])
86
+
87
+ const handleMouseEnter = () => {
88
+ if (timeoutRef.current) {
89
+ clearTimeout(timeoutRef.current)
90
+ timeoutRef.current = null
77
91
  }
78
- }, [duration, onClose])
92
+ }
93
+
94
+ const handleMouseLeave = () => {
95
+ if (duration > 0 && !timeoutRef.current) {
96
+ timeoutRef.current = setTimeout(onClose, duration * 1000)
97
+ }
98
+ }
79
99
 
80
100
  const className = classnames('notification', type)
81
101
 
82
102
  return (
83
- <div className={className}>
103
+ <div
104
+ className={className}
105
+ onMouseEnter={handleMouseEnter}
106
+ onMouseLeave={handleMouseLeave}
107
+ >
84
108
  <div className='notification-content'>
85
109
  <div className='notification-message'>
86
110
  <div className='notification-icon'>{messageIcons[type]}</div>
@@ -65,10 +65,6 @@ export function shortcutExtend (Cls) {
65
65
  if (isInAntdInput()) {
66
66
  return
67
67
  }
68
- if (this.cmdAddon) {
69
- this.cmdAddon.handleKey(event)
70
- }
71
-
72
68
  if (
73
69
  this.term &&
74
70
  key === 'Backspace' &&
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  /* eslint-disable no-template-curly-in-string, no-useless-escape */
18
+ import { runCmd } from './terminal-apis.js'
18
19
 
19
20
  /**
20
21
  * Get inline shell integration command for bash (one-liner format)
@@ -69,6 +70,35 @@ function getFishInlineIntegration () {
69
70
  ].join('; ')
70
71
  }
71
72
 
73
+ /**
74
+ * Get inline shell integration command for sh/ash (one-liner format)
75
+ * Uses PS1 injection as sh/ash lack PROMPT_COMMAND or advanced traps.
76
+ */
77
+ function getShInlineIntegration () {
78
+ return [
79
+ 'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
80
+ 'then export ELECTERM_SHELL_INTEGRATION=1',
81
+ '__e_esc() { printf "%s" "$1" | sed "s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g"; }',
82
+ // We wrap the current PS1 with OSC 633 sequences.
83
+ // \033]633;P;Cwd=... \007 marks the directory
84
+ // \033]633;A \007 marks the start of the prompt
85
+ 'export PS1="\\e]633;P;Cwd=$(__e_esc "$PWD")\\a\\e]633;A\\a${PS1:-# }"',
86
+ 'fi'
87
+ ].join('; ')
88
+ }
89
+
90
+ export function detectShellType (shellStr) {
91
+ if (shellStr.includes('bash')) {
92
+ return 'bash'
93
+ } else if (shellStr.includes('zsh')) {
94
+ return 'zsh'
95
+ } else if (shellStr.includes('fish')) {
96
+ return 'fish'
97
+ } else {
98
+ return 'sh'
99
+ }
100
+ }
101
+
72
102
  /**
73
103
  * Get shell integration command based on detected shell type
74
104
  * @param {string} shellType - 'bash', 'zsh', or 'fish'
@@ -84,7 +114,7 @@ export function getInlineShellIntegration (shellType) {
84
114
  return getFishInlineIntegration()
85
115
  default:
86
116
  // Try bash as default for sh-compatible shells
87
- return getBashInlineIntegration()
117
+ return getShInlineIntegration()
88
118
  }
89
119
  }
90
120
 
@@ -112,27 +142,23 @@ export function getShellIntegrationCommand (shellType = 'bash') {
112
142
  const cmd = getInlineShellIntegration(shellType)
113
143
  return wrapSilent(cmd, shellType)
114
144
  }
145
+ export async function detectRemoteShell (pid) {
146
+ // 1. We try the version variables first.
147
+ // 2. We try your verified fish check: fish --version ...
148
+ // 3. We use ps -p $$ to check the process name (highly reliable in Linux/Docker).
149
+ // This syntax is safe for Bash, Zsh, and Fish.
150
+ const cmd = 'fish --version 2>/dev/null | grep -q fish && echo fish || { env | grep -q ZSH_VERSION && echo zsh || { env | grep -q BASH_VERSION && echo bash || { ps -p $$ -o comm= 2>/dev/null || echo sh; }; }; }'
115
151
 
116
- /**
117
- * Detect shell type from shell path or login script
118
- * @param {string} shellPath - Path to shell executable or login script
119
- * @returns {string} Shell type: 'bash', 'zsh', 'fish', or 'bash' (default)
120
- */
121
- export function detectShellType (shellPath = '') {
122
- if (!shellPath) return 'bash'
123
-
124
- const normalizedPath = shellPath.toLowerCase()
152
+ const r = await runCmd(pid, cmd)
153
+ .catch((err) => {
154
+ console.error('detectRemoteShell error', err)
155
+ return 'sh'
156
+ })
125
157
 
126
- if (normalizedPath.includes('zsh')) {
127
- return 'zsh'
128
- } else if (normalizedPath.includes('fish')) {
129
- return 'fish'
130
- } else if (normalizedPath.includes('bash')) {
131
- return 'bash'
132
- } else if (normalizedPath.includes('sh')) {
133
- // Generic sh, try bash compatibility
134
- return 'bash'
135
- }
158
+ const shell = r.trim().toLowerCase()
136
159
 
137
- return 'bash'
160
+ if (shell.includes('fish')) return 'fish'
161
+ if (shell.includes('zsh')) return 'zsh'
162
+ if (shell.includes('bash')) return 'bash'
163
+ return 'sh' // Fallback for sh/ash/dash
138
164
  }
@@ -52,11 +52,13 @@ import AIIcon from '../icons/ai-icon.jsx'
52
52
  import { formatBytes } from '../../common/byte-format.js'
53
53
  import {
54
54
  getShellIntegrationCommand,
55
+ detectRemoteShell,
55
56
  detectShellType
56
57
  } from './shell.js'
57
58
  import * as fs from './fs.js'
58
59
  import iconsMap from '../sys-menu/icons-map.jsx'
59
60
  import { refs, refsStatic } from '../common/ref.js'
61
+ import ExternalLink from '../common/external-link.jsx'
60
62
  import createDefaultLogPath from '../../common/default-log-path.js'
61
63
  import SearchResultBar from './terminal-search-bar'
62
64
 
@@ -79,6 +81,9 @@ class Term extends Component {
79
81
  this.id = `term-${this.props.tab.id}`
80
82
  refs.add(this.id, this)
81
83
  this.currentInput = ''
84
+ this.shellInjected = false
85
+ this.shellType = null
86
+ this.manualCommandHistory = new Set()
82
87
  }
83
88
 
84
89
  domRef = createRef()
@@ -215,6 +220,41 @@ class Term extends Component {
215
220
  }
216
221
  }
217
222
  }
223
+
224
+ // Check for shell integration related config changes
225
+ const prevShowSuggestions = prevProps.config.showCmdSuggestions
226
+ const currShowSuggestions = props.config.showCmdSuggestions
227
+ const prevSftpFollow = prevProps.sftpPathFollowSsh
228
+ const currSftpFollow = props.sftpPathFollowSsh
229
+
230
+ if (
231
+ (!prevShowSuggestions && currShowSuggestions) ||
232
+ (!prevSftpFollow && currSftpFollow)
233
+ ) {
234
+ // Config was toggled to true, try to inject shell integration if not already done
235
+ if (this.canInjectShellIntegration() && !this.shellInjected) {
236
+ // If there's an active execution queue, add to it
237
+ if (this.executionQueue && this.executionQueue.length > 0) {
238
+ this.executionQueue.unshift({
239
+ type: 'shell_integration',
240
+ execute: async () => {
241
+ await this.injectShellIntegration()
242
+ }
243
+ })
244
+ } else {
245
+ // No active queue, inject directly
246
+ this.injectShellIntegration()
247
+ }
248
+ }
249
+ }
250
+ if (
251
+ !prevSftpFollow &&
252
+ currSftpFollow &&
253
+ this.isLocal() &&
254
+ isWin
255
+ ) {
256
+ return this.warnSftpFollowUnsupported()
257
+ }
218
258
  }
219
259
 
220
260
  timers = {}
@@ -293,6 +333,14 @@ class Term extends Component {
293
333
  })
294
334
  }
295
335
 
336
+ warnSftpFollowUnsupported = () => {
337
+ message.warning(
338
+ <span>
339
+ Fish shell/windows shell is not supported for SFTP follow SSH path feature. See: <ExternalLink to='https://github.com/electerm/electerm/wiki/Warning-about-sftp-follow-ssh-path-function'>wiki</ExternalLink>
340
+ </span>
341
+ , 7)
342
+ }
343
+
296
344
  pasteShortcut = (e) => {
297
345
  if (this.pasteTextTooLong()) {
298
346
  this.askUserConfirm()
@@ -596,7 +644,7 @@ class Term extends Component {
596
644
  }
597
645
  const r = []
598
646
  for (const filePath of files) {
599
- const stat = await getLocalFileInfo(filePath).catch(console.log)
647
+ const stat = await getLocalFileInfo(filePath)
600
648
  r.push({ ...stat, filePath })
601
649
  }
602
650
  return r
@@ -945,6 +993,12 @@ class Term extends Component {
945
993
  }
946
994
  // Handle Enter
947
995
  if (d === '\r' || d === '\n') {
996
+ // Add to manual command history if shell integration is not available
997
+ if (this.currentInput.trim() && this.shouldUseManualHistory()) {
998
+ this.manualCommandHistory.add(this.currentInput.trim())
999
+ // Also add to global history for suggestions
1000
+ window.store.addCmdHistory(this.currentInput.trim())
1001
+ }
948
1002
  this.currentInput = ''
949
1003
  return
950
1004
  }
@@ -1042,6 +1096,7 @@ class Term extends Component {
1042
1096
  term.onData(this.onData)
1043
1097
  this.term = term
1044
1098
  term.onSelectionChange(this.onSelectionChange)
1099
+ term.attachCustomKeyEventHandler(this.handleKeyboardEvent.bind(this))
1045
1100
  await this.remoteInit(term)
1046
1101
  }
1047
1102
 
@@ -1060,7 +1115,7 @@ class Term extends Component {
1060
1115
  // })
1061
1116
  // }
1062
1117
 
1063
- runInitScript = () => {
1118
+ runInitScript = async () => {
1064
1119
  window.store.triggerResize()
1065
1120
  const {
1066
1121
  startDirectory,
@@ -1072,20 +1127,51 @@ class Term extends Component {
1072
1127
  if (startFolder) {
1073
1128
  scripts.unshift({ script: `cd "${startFolder}"`, delay: 0 })
1074
1129
  }
1075
- this.pendingRunScripts = scripts
1076
1130
 
1077
- // Inject shell integration from client-side (works for both local and remote)
1078
- // Skip on Windows as shell integration is not supported there
1131
+ // Create unified execution queue
1132
+ this.executionQueue = []
1133
+
1134
+ // Add shell integration injection to queue if needed
1079
1135
  if (this.canInjectShellIntegration()) {
1080
- this.injectShellIntegration()
1081
- } else {
1082
- // No shell integration, run scripts immediately
1083
- this.startDelayedScripts()
1136
+ this.executionQueue.push({
1137
+ type: 'shell_integration',
1138
+ execute: async () => {
1139
+ await this.injectShellIntegration()
1140
+ }
1141
+ })
1084
1142
  }
1143
+
1144
+ // Add delayed scripts to queue
1145
+ scripts.forEach(script => {
1146
+ this.executionQueue.push({
1147
+ type: 'delayed_script',
1148
+ script: script.script,
1149
+ delay: script.delay || 0,
1150
+ execute: () => {
1151
+ if (script.script) {
1152
+ this.attachAddon._sendData(script.script + '\r')
1153
+ }
1154
+ }
1155
+ })
1156
+ })
1157
+
1158
+ this.processExecutionQueue()
1159
+ }
1160
+
1161
+ shouldUseManualHistory = () => {
1162
+ const useManual = this.props.config.showCmdSuggestions &&
1163
+ (this.shellType === 'sh' || (isWin && this.isLocal()))
1164
+ return useManual
1085
1165
  }
1086
1166
 
1087
1167
  canInjectShellIntegration = () => {
1088
- return this.isSsh() || this.isLocal()
1168
+ const { config } = this.props
1169
+ const canInject = (config.showCmdSuggestions || this.props.sftpPathFollowSsh) &&
1170
+ (
1171
+ this.isSsh() ||
1172
+ (this.isLocal() && !isWin)
1173
+ )
1174
+ return canInject
1089
1175
  }
1090
1176
 
1091
1177
  isSsh = () => {
@@ -1095,70 +1181,93 @@ class Term extends Component {
1095
1181
 
1096
1182
  isLocal = () => {
1097
1183
  const { host, type } = this.props.tab
1098
- return !host && (type === 'local' || type === undefined)
1184
+ return !host &&
1185
+ (type === 'local' || type === undefined)
1099
1186
  }
1100
1187
 
1101
1188
  /**
1102
- * Start running delayed scripts
1103
- * Called after shell integration injection completes (or immediately if disabled)
1189
+ * Process the unified execution queue one item at a time
1104
1190
  */
1105
- startDelayedScripts = () => {
1106
- const runScripts = this.pendingRunScripts
1107
- if (runScripts && runScripts.length) {
1108
- this.delayedScripts = deepCopy(runScripts)
1109
- this.timers.timerDelay = setTimeout(this.runDelayedScripts, this.delayedScripts[0].delay || 0)
1191
+ processExecutionQueue = async () => {
1192
+ if (!this.executionQueue || this.executionQueue.length === 0) {
1193
+ return
1194
+ }
1195
+
1196
+ const item = this.executionQueue.shift()
1197
+
1198
+ try {
1199
+ if (item.type === 'shell_integration') {
1200
+ await item.execute()
1201
+ } else if (item.type === 'delayed_script') {
1202
+ item.execute()
1203
+ // Wait for the specified delay before processing next item
1204
+ if (item.delay > 0) {
1205
+ await new Promise(resolve => {
1206
+ this.timers.timerDelay = setTimeout(resolve, item.delay)
1207
+ })
1208
+ }
1209
+ }
1210
+ } catch (error) {
1211
+ console.error('[Shell Integration] Error processing queue item:', item.type, error)
1110
1212
  }
1111
- this.pendingRunScripts = null
1213
+
1214
+ // Process next item
1215
+ this.processExecutionQueue()
1112
1216
  }
1113
1217
 
1114
1218
  /**
1115
1219
  * Inject shell integration commands from client-side
1116
1220
  * This replaces the server-side source xxx.xxx approach
1117
1221
  * Uses output suppression to hide the injection command
1222
+ * Returns a promise that resolves when injection is complete
1118
1223
  */
1119
- injectShellIntegration = () => {
1120
- // Detect shell type from login script or local shell config
1121
- let shellType = 'bash'
1224
+ injectShellIntegration = async () => {
1225
+ if (this.shellInjected) {
1226
+ return Promise.resolve()
1227
+ }
1228
+
1229
+ let shellType
1122
1230
  if (this.isLocal()) {
1123
1231
  const { config } = this.props
1124
1232
  const localShell = isMac ? config.execMac : config.execLinux
1125
1233
  shellType = detectShellType(localShell)
1234
+ } else if (this.isSsh()) {
1235
+ shellType = await detectRemoteShell(this.pid)
1126
1236
  }
1127
1237
 
1128
- const isRemote = this.isSsh()
1238
+ this.shellType = shellType
1239
+ if (shellType === 'fish') {
1240
+ if (this.props.sftpPathFollowSsh) {
1241
+ this.warnSftpFollowUnsupported()
1242
+ }
1243
+ return Promise.resolve()
1244
+ }
1245
+
1246
+ // Don't inject for sh type shells unless sftpPathFollowSsh is true
1247
+ if (shellType === 'sh' && !this.props.sftpPathFollowSsh) {
1248
+ return Promise.resolve()
1249
+ }
1129
1250
 
1130
- // Remote sessions might need longer timeout for shell integration detection
1131
1251
  const integrationCmd = getShellIntegrationCommand(shellType)
1132
1252
 
1133
- if (integrationCmd && this.attachAddon) {
1253
+ return new Promise((resolve) => {
1134
1254
  // Wait for initial data (prompt/banner) to arrive before injecting
1135
1255
  this.attachAddon.onInitialData(() => {
1136
1256
  if (this.attachAddon) {
1137
1257
  // Start suppressing output before sending the integration command
1138
1258
  // This hides the command and its output until OSC 633 is detected
1139
- const suppressionTimeout = isRemote ? 5000 : 3000
1140
- // Pass callback to run delayed scripts after suppression ends
1259
+ const suppressionTimeout = this.isSsh() ? 5000 : 3000
1260
+ // Pass callback to resolve the promise after suppression ends
1141
1261
  this.attachAddon.startOutputSuppression(suppressionTimeout, () => {
1142
- this.startDelayedScripts()
1262
+ this.shellInjected = true
1263
+ resolve()
1143
1264
  })
1144
1265
  this.attachAddon._sendData(integrationCmd)
1266
+ } else {
1267
+ resolve()
1145
1268
  }
1146
1269
  })
1147
- }
1148
- }
1149
-
1150
- runDelayedScripts = () => {
1151
- const { delayedScripts } = this
1152
- if (delayedScripts && delayedScripts.length > 0) {
1153
- const obj = delayedScripts.shift()
1154
- if (obj.script) {
1155
- this.attachAddon._sendData(obj.script + '\r')
1156
- }
1157
- if (delayedScripts.length > 0) {
1158
- const nextDelay = delayedScripts[0].delay || 0
1159
- this.timers.timerDelay = setTimeout(this.runDelayedScripts, nextDelay)
1160
- }
1161
- }
1270
+ })
1162
1271
  }
1163
1272
 
1164
1273
  setStatus = status => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "2.4.16",
3
+ "version": "2.4.28",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",