@electerm/electerm-react 2.4.16 → 2.4.18

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>
@@ -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,33 @@ 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
+ }
218
250
  }
219
251
 
220
252
  timers = {}
@@ -596,7 +628,7 @@ class Term extends Component {
596
628
  }
597
629
  const r = []
598
630
  for (const filePath of files) {
599
- const stat = await getLocalFileInfo(filePath).catch(console.log)
631
+ const stat = await getLocalFileInfo(filePath)
600
632
  r.push({ ...stat, filePath })
601
633
  }
602
634
  return r
@@ -945,6 +977,12 @@ class Term extends Component {
945
977
  }
946
978
  // Handle Enter
947
979
  if (d === '\r' || d === '\n') {
980
+ // Add to manual command history if shell integration is not available
981
+ if (this.currentInput.trim() && this.shouldUseManualHistory()) {
982
+ this.manualCommandHistory.add(this.currentInput.trim())
983
+ // Also add to global history for suggestions
984
+ window.store.addCmdHistory(this.currentInput.trim())
985
+ }
948
986
  this.currentInput = ''
949
987
  return
950
988
  }
@@ -1060,7 +1098,7 @@ class Term extends Component {
1060
1098
  // })
1061
1099
  // }
1062
1100
 
1063
- runInitScript = () => {
1101
+ runInitScript = async () => {
1064
1102
  window.store.triggerResize()
1065
1103
  const {
1066
1104
  startDirectory,
@@ -1072,20 +1110,51 @@ class Term extends Component {
1072
1110
  if (startFolder) {
1073
1111
  scripts.unshift({ script: `cd "${startFolder}"`, delay: 0 })
1074
1112
  }
1075
- this.pendingRunScripts = scripts
1076
1113
 
1077
- // Inject shell integration from client-side (works for both local and remote)
1078
- // Skip on Windows as shell integration is not supported there
1114
+ // Create unified execution queue
1115
+ this.executionQueue = []
1116
+
1117
+ // Add shell integration injection to queue if needed
1079
1118
  if (this.canInjectShellIntegration()) {
1080
- this.injectShellIntegration()
1081
- } else {
1082
- // No shell integration, run scripts immediately
1083
- this.startDelayedScripts()
1119
+ this.executionQueue.push({
1120
+ type: 'shell_integration',
1121
+ execute: async () => {
1122
+ await this.injectShellIntegration()
1123
+ }
1124
+ })
1084
1125
  }
1126
+
1127
+ // Add delayed scripts to queue
1128
+ scripts.forEach(script => {
1129
+ this.executionQueue.push({
1130
+ type: 'delayed_script',
1131
+ script: script.script,
1132
+ delay: script.delay || 0,
1133
+ execute: () => {
1134
+ if (script.script) {
1135
+ this.attachAddon._sendData(script.script + '\r')
1136
+ }
1137
+ }
1138
+ })
1139
+ })
1140
+
1141
+ this.processExecutionQueue()
1142
+ }
1143
+
1144
+ shouldUseManualHistory = () => {
1145
+ const useManual = this.props.config.showCmdSuggestions &&
1146
+ (this.shellType === 'sh' || (isWin && this.isLocal()))
1147
+ return useManual
1085
1148
  }
1086
1149
 
1087
1150
  canInjectShellIntegration = () => {
1088
- return this.isSsh() || this.isLocal()
1151
+ const { config } = this.props
1152
+ const canInject = (config.showCmdSuggestions || this.props.sftpPathFollowSsh) &&
1153
+ (
1154
+ this.isSsh() ||
1155
+ (this.isLocal() && !isWin)
1156
+ )
1157
+ return canInject
1089
1158
  }
1090
1159
 
1091
1160
  isSsh = () => {
@@ -1095,70 +1164,97 @@ class Term extends Component {
1095
1164
 
1096
1165
  isLocal = () => {
1097
1166
  const { host, type } = this.props.tab
1098
- return !host && (type === 'local' || type === undefined)
1167
+ return !host &&
1168
+ (type === 'local' || type === undefined)
1099
1169
  }
1100
1170
 
1101
1171
  /**
1102
- * Start running delayed scripts
1103
- * Called after shell integration injection completes (or immediately if disabled)
1172
+ * Process the unified execution queue one item at a time
1104
1173
  */
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)
1174
+ processExecutionQueue = async () => {
1175
+ if (!this.executionQueue || this.executionQueue.length === 0) {
1176
+ return
1110
1177
  }
1111
- this.pendingRunScripts = null
1178
+
1179
+ const item = this.executionQueue.shift()
1180
+
1181
+ try {
1182
+ if (item.type === 'shell_integration') {
1183
+ await item.execute()
1184
+ } else if (item.type === 'delayed_script') {
1185
+ item.execute()
1186
+ // Wait for the specified delay before processing next item
1187
+ if (item.delay > 0) {
1188
+ await new Promise(resolve => {
1189
+ this.timers.timerDelay = setTimeout(resolve, item.delay)
1190
+ })
1191
+ }
1192
+ }
1193
+ } catch (error) {
1194
+ console.error('[Shell Integration] Error processing queue item:', item.type, error)
1195
+ }
1196
+
1197
+ // Process next item
1198
+ this.processExecutionQueue()
1112
1199
  }
1113
1200
 
1114
1201
  /**
1115
1202
  * Inject shell integration commands from client-side
1116
1203
  * This replaces the server-side source xxx.xxx approach
1117
1204
  * Uses output suppression to hide the injection command
1205
+ * Returns a promise that resolves when injection is complete
1118
1206
  */
1119
- injectShellIntegration = () => {
1120
- // Detect shell type from login script or local shell config
1121
- let shellType = 'bash'
1207
+ injectShellIntegration = async () => {
1208
+ if (this.shellInjected) {
1209
+ return Promise.resolve()
1210
+ }
1211
+
1212
+ let shellType
1122
1213
  if (this.isLocal()) {
1123
1214
  const { config } = this.props
1124
1215
  const localShell = isMac ? config.execMac : config.execLinux
1125
1216
  shellType = detectShellType(localShell)
1217
+ } else if (this.isSsh()) {
1218
+ shellType = await detectRemoteShell(this.pid)
1219
+ }
1220
+
1221
+ this.shellType = shellType
1222
+ if (shellType === 'fish') {
1223
+ if (this.props.sftpPathFollowSsh) {
1224
+ message.warning(
1225
+ <span>
1226
+ Fish shell is not supported for SFTP follow SSH path. See: <ExternalLink to='https://github.com/electerm/electerm/wiki/Warning-about-sftp-follow-ssh-path-function'>wiki</ExternalLink>
1227
+ </span>
1228
+ , 7)
1229
+ }
1230
+ return Promise.resolve()
1126
1231
  }
1127
1232
 
1128
- const isRemote = this.isSsh()
1233
+ // Don't inject for sh type shells unless sftpPathFollowSsh is true
1234
+ if (shellType === 'sh' && !this.props.sftpPathFollowSsh) {
1235
+ return Promise.resolve()
1236
+ }
1129
1237
 
1130
- // Remote sessions might need longer timeout for shell integration detection
1131
1238
  const integrationCmd = getShellIntegrationCommand(shellType)
1132
1239
 
1133
- if (integrationCmd && this.attachAddon) {
1240
+ return new Promise((resolve) => {
1134
1241
  // Wait for initial data (prompt/banner) to arrive before injecting
1135
1242
  this.attachAddon.onInitialData(() => {
1136
1243
  if (this.attachAddon) {
1137
1244
  // Start suppressing output before sending the integration command
1138
1245
  // 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
1246
+ const suppressionTimeout = this.isSsh() ? 5000 : 3000
1247
+ // Pass callback to resolve the promise after suppression ends
1141
1248
  this.attachAddon.startOutputSuppression(suppressionTimeout, () => {
1142
- this.startDelayedScripts()
1249
+ this.shellInjected = true
1250
+ resolve()
1143
1251
  })
1144
1252
  this.attachAddon._sendData(integrationCmd)
1253
+ } else {
1254
+ resolve()
1145
1255
  }
1146
1256
  })
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
- }
1257
+ })
1162
1258
  }
1163
1259
 
1164
1260
  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.18",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",