@electerm/electerm-react 5.2.0 → 5.3.5

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 (34) hide show
  1. package/client/common/constants.js +12 -1
  2. package/client/common/term-of-use.jsx +69 -0
  3. package/client/components/ai/ai-config-props.js +8 -0
  4. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  5. package/client/components/bookmark-form/config/common-fields.js +11 -0
  6. package/client/components/footer/footer-entry.jsx +1 -1
  7. package/client/components/layout/layout.jsx +0 -2
  8. package/client/components/rdp/rdp-session.jsx +1 -1
  9. package/client/components/session/session-control.jsx +1 -23
  10. package/client/components/session/session.jsx +1 -31
  11. package/client/components/session/sessions.jsx +0 -1
  12. package/client/components/setting-sync/setting-sync.jsx +15 -7
  13. package/client/components/shortcuts/shortcut-handler.js +0 -1
  14. package/client/components/sidebar/info-modal.jsx +7 -0
  15. package/client/components/terminal/attach-addon-custom.js +12 -10
  16. package/client/components/terminal/shell.js +22 -11
  17. package/client/components/terminal/shortcut-bar-entry.jsx +52 -1
  18. package/client/components/terminal/terminal-interactive.jsx +35 -0
  19. package/client/components/terminal/terminal.jsx +11 -6
  20. package/client/components/terminal-info/activity.jsx +5 -1
  21. package/client/components/terminal-info/base.jsx +37 -22
  22. package/client/components/terminal-info/disk.jsx +5 -1
  23. package/client/components/terminal-info/local-info-resolver.js +89 -0
  24. package/client/components/terminal-info/network.jsx +5 -1
  25. package/client/components/terminal-info/run-cmd.jsx +42 -3
  26. package/client/components/terminal-info/sys-info.jsx +56 -0
  27. package/client/components/terminal-info/terminal-info.jsx +4 -1
  28. package/client/components/terminal-info/terminal-info.styl +22 -7
  29. package/client/store/common.js +19 -7
  30. package/client/store/init-state.js +0 -2
  31. package/client/store/mcp-handler.js +3 -1
  32. package/client/store/setting.js +7 -4
  33. package/client/store/tab.js +16 -11
  34. package/package.json +1 -1
@@ -212,6 +212,16 @@ export const syncTypes = buildConst([
212
212
  'cloud',
213
213
  'webdav'
214
214
  ])
215
+ export const allowedSyncTypes = () => {
216
+ const custom = window.et.syncTypes
217
+ if (!Array.isArray(custom)) {
218
+ return Object.keys(syncTypes)
219
+ }
220
+ const list = custom.filter(type => syncTypes[type])
221
+ return list.length
222
+ ? list
223
+ : Object.keys(syncTypes)
224
+ }
215
225
  export const syncTokenCreateUrls = {
216
226
  gitee: 'https://gitee.com/github-zxdong262/electerm/wikis/Create%20personal%20access%20token?sort_id=3028409',
217
227
  github: 'https://github.com/electerm/electerm/wiki/Create-personal-access-token',
@@ -253,7 +263,6 @@ export const resolutionsLsKey = 'custom-resolution-key'
253
263
  export const checkedKeysLsKey = 'checked-keys'
254
264
  export const quickCommandLabelsLsKey = 'quick-command-label'
255
265
  export const localAddrBookmarkLsKey = 'local-addr-bookmark-keys'
256
- export const dismissDelKeyTipLsKey = 'dismiss-del-key-tip'
257
266
  export const treeSortLsKey = 'tree-sort'
258
267
  export const sshTunnelHelpLink = 'https://github.com/electerm/electerm/wiki/How-to-use-ssh-tunnel'
259
268
  export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-format'
@@ -262,6 +271,8 @@ export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/w
262
271
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
263
272
  export const aiChatModeLsKey = 'ai-chat-mode'
264
273
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
274
+ export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
275
+ export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
265
276
  export const modals = {
266
277
  hide: 0,
267
278
  setting: 1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Term of use confirmation gate for the AI config / data sync pages.
3
+ *
4
+ * Define `window.et.AITermOfUse` / `window.et.SyncTermOfUse` (injected the
5
+ * same way as `defaultAIPreset` into the built index.html `_global` data)
6
+ * to require users to confirm the term before entering those pages.
7
+ * Confirmation is saved to localStorage, so it is only asked once.
8
+ */
9
+
10
+ import Modal from '../components/common/modal'
11
+ import {
12
+ aiTermOfUseConfirmedLsKey,
13
+ syncTermOfUseConfirmedLsKey
14
+ } from './constants'
15
+ import * as ls from './safe-local-storage'
16
+
17
+ const e = window.translate
18
+
19
+ const termOfUseTypes = {
20
+ ai: {
21
+ etKey: 'AITermOfUse',
22
+ lsKey: aiTermOfUseConfirmedLsKey
23
+ },
24
+ sync: {
25
+ etKey: 'SyncTermOfUse',
26
+ lsKey: syncTermOfUseConfirmedLsKey
27
+ }
28
+ }
29
+
30
+ function isConfirmed (type) {
31
+ return ls.getItem(termOfUseTypes[type].lsKey) === 'yes'
32
+ }
33
+
34
+ /**
35
+ * Run `onConfirmed` after the term of use of `type` is confirmed.
36
+ * When no term is defined, or it was confirmed before, `onConfirmed`
37
+ * runs synchronously; otherwise a confirmation modal is shown and
38
+ * `onConfirmed` only runs when the user confirms it.
39
+ */
40
+ export function requireTermOfUse (type, onConfirmed) {
41
+ const { etKey, lsKey } = termOfUseTypes[type]
42
+ const content = window.et?.[etKey]
43
+ const text = typeof content === 'string' ? content.trim() : ''
44
+ if (!text || isConfirmed(type)) {
45
+ return onConfirmed()
46
+ }
47
+ Modal.confirm({
48
+ title: 'Terms of use',
49
+ width: 600,
50
+ maskClosable: false,
51
+ content: (
52
+ <div
53
+ style={{
54
+ maxHeight: '50vh',
55
+ overflow: 'auto',
56
+ whiteSpace: 'pre-wrap'
57
+ }}
58
+ >
59
+ {text}
60
+ </div>
61
+ ),
62
+ okText: e('haveRead'),
63
+ cancelText: e('cancel'),
64
+ onOk: () => {
65
+ ls.setItem(lsKey, 'yes')
66
+ onConfirmed()
67
+ }
68
+ })
69
+ }
@@ -27,6 +27,14 @@ export const defaultAIPresets = [
27
27
  modelAI: 'DEEPSEEK_V4_FLASH',
28
28
  authHeaderNameAI: 'Authorization: Bearer'
29
29
  },
30
+ {
31
+ id: 'apimart',
32
+ nameAI: 'ApiMart',
33
+ baseURLAI: 'https://api.apimart.ai/v1',
34
+ apiPathAI: '/chat/completions',
35
+ modelAI: 'deepseek-v4-flash',
36
+ authHeaderNameAI: 'Authorization: Bearer'
37
+ },
30
38
  {
31
39
  id: 'deepseek',
32
40
  nameAI: 'DeepSeek',
@@ -26,6 +26,7 @@ const bookmarkSchema = {
26
26
  runScripts: 'array - run scripts after connected ({delay,script})',
27
27
  quickCommands: 'array - quick commands ({name,command})',
28
28
  proxy: 'string - proxy address (socks5://...)',
29
+ proxyCommand: 'string - ssh proxy command, supports %h (host) %p (port) %r (username) placeholders, e.g. `netbird ssh proxy %h %p` or `cloudflared access ssh --hostname %h`',
29
30
  x11: 'boolean - enable x11 forwarding, default is false',
30
31
  term: 'string - terminal type, default is xterm-256color, required',
31
32
  displayRaw: 'boolean - display raw output, default is false',
@@ -210,6 +210,16 @@ export const commonFields = {
210
210
  label: () => e('proxy')
211
211
  },
212
212
 
213
+ proxyCommand: {
214
+ type: 'input',
215
+ name: 'proxyCommand',
216
+ label: () => `${e('proxy')} command`,
217
+ props: {
218
+ placeholder: 'netbird ssh proxy %h %p',
219
+ maxLength: 1024
220
+ }
221
+ },
222
+
213
223
  x11: {
214
224
  type: 'x11',
215
225
  name: '__x11__',
@@ -281,6 +291,7 @@ export const sshSettings = [
281
291
  },
282
292
  commonFields.enableTerminalImage,
283
293
  ...terminalSettings.slice(0, -1), // All except terminalBackground
294
+ commonFields.proxyCommand,
284
295
  commonFields.x11,
285
296
  commonFields.terminalBackground
286
297
  ]
@@ -151,7 +151,7 @@ export default auto(function FooterEntry (props) {
151
151
  <div className='terminal-footer-unit terminal-footer-info'>
152
152
  <InfoCircleOutlined
153
153
  onClick={handleInfoPanel}
154
- className='pointer font18 terminal-info-icon'
154
+ className='pointer font14 terminal-info-icon'
155
155
  />
156
156
  </div>
157
157
  )
@@ -119,7 +119,6 @@ export default auto(function Layout (props) {
119
119
  'isMaximized',
120
120
  'config',
121
121
  'resolutions',
122
- 'hideDelKeyTip',
123
122
  'fileOperation',
124
123
  'pinnedQuickCommandBar',
125
124
  'tabsHeight',
@@ -181,7 +180,6 @@ export default auto(function Layout (props) {
181
180
  'activeTabId3',
182
181
  'batch',
183
182
  'resolutions',
184
- 'hideDelKeyTip',
185
183
  'fileOperation',
186
184
  'file',
187
185
  'pinnedQuickCommandBar',
@@ -137,7 +137,7 @@ export default class RdpSession extends PureComponent {
137
137
  }
138
138
  // keep resolved credentials for the connect-time prompt
139
139
  this.tab = tab
140
- if (!tab.username || !tab.password) {
140
+ if (!tab.username) {
141
141
  this.setState({
142
142
  loading: false,
143
143
  showCredPrompt: true
@@ -6,7 +6,6 @@ import {
6
6
  SearchOutlined,
7
7
  FullscreenOutlined,
8
8
  PaperClipOutlined,
9
- CloseOutlined,
10
9
  ApartmentOutlined,
11
10
  MoreOutlined,
12
11
  ColumnWidthOutlined,
@@ -37,8 +36,6 @@ export default function SessionControl (props) {
37
36
  keepaliveEnabled,
38
37
  broadcastInput,
39
38
  wrapDisabled,
40
- delKeyPressed,
41
- hideDelKeyTip,
42
39
  onChangePane,
43
40
  toggleCheckSftpPathFollowSsh,
44
41
  onSshSftpSplitView,
@@ -47,7 +44,6 @@ export default function SessionControl (props) {
47
44
  toggleWrap,
48
45
  onFullscreen,
49
46
  onOpenSearch,
50
- onDismissDelKeyTip,
51
47
  onExitGracefully
52
48
  } = props
53
49
 
@@ -108,26 +104,11 @@ export default function SessionControl (props) {
108
104
  )
109
105
  }
110
106
 
111
- function renderDelTip (isS) {
112
- if (!isS || hideDelKeyTip || !delKeyPressed) {
113
- return null
114
- }
115
- return (
116
- <div className='type-tab'>
117
- <span className='mg1r'>Try <b>Shift + Backspace</b>?</span>
118
- <CloseOutlined
119
- onClick={onDismissDelKeyTip}
120
- className='pointer'
121
- />
122
- </div>
123
- )
124
- }
125
-
126
107
  function renderSftpPathFollowControl () {
127
108
  if (isDisabled) {
128
109
  return null
129
110
  }
130
- const { pane, enableSsh, sshSftpSplitView } = tab
111
+ const { enableSsh } = tab
131
112
  const checkTxt = e('sftpPathFollowSsh')
132
113
  const checkProps = {
133
114
  onClick: toggleCheckSftpPathFollowSsh,
@@ -138,8 +119,6 @@ export default function SessionControl (props) {
138
119
  }
139
120
  )
140
121
  }
141
- const isS = pane === paneMap.terminal ||
142
- sshSftpSplitView
143
122
  return (
144
123
  <>
145
124
  {
@@ -153,7 +132,6 @@ export default function SessionControl (props) {
153
132
  )
154
133
  : null
155
134
  }
156
- {renderDelTip(isS)}
157
135
  </>
158
136
  )
159
137
  }
@@ -46,7 +46,6 @@ export default class SessionWrapper extends Component {
46
46
  key: Math.random(),
47
47
  splitSize: [50, 50],
48
48
  sessionOptions: null,
49
- delKeyPressed: false,
50
49
  broadcastInput: false,
51
50
  keepaliveEnabled: false,
52
51
  wrapDisabled: false
@@ -59,10 +58,6 @@ export default class SessionWrapper extends Component {
59
58
  minWithForSplit = 640
60
59
  minHeightForSplit = 400
61
60
 
62
- componentWillUnmount () {
63
- clearTimeout(this.backspaceKeyPressedTimer)
64
- }
65
-
66
61
  getDom = () => {
67
62
  return this.domRef.current
68
63
  }
@@ -178,27 +173,6 @@ export default class SessionWrapper extends Component {
178
173
  e && e.dataTransfer && e.dataTransfer.clearData()
179
174
  }
180
175
 
181
- onDelKeyPressed = () => {
182
- this.setState({
183
- delKeyPressed: true
184
- })
185
- this.backspaceKeyPressedTimer = setTimeout(() => {
186
- this.setState({
187
- delKeyPressed: false
188
- })
189
- }, 5000)
190
- }
191
-
192
- handleChangeDelMode = (backspaceMode) => {
193
- this.setState({
194
- backspaceMode
195
- })
196
- }
197
-
198
- handleDismissDelKeyTip = () => {
199
- window.store.dismissDelKeyTip()
200
- }
201
-
202
176
  setCwd = (cwd) => {
203
177
  this.setState({
204
178
  cwd
@@ -351,8 +325,7 @@ export default class SessionWrapper extends Component {
351
325
  this,
352
326
  [
353
327
  'onChangePane',
354
- 'setCwd',
355
- 'onDelKeyPressed'
328
+ 'setCwd'
356
329
  ]),
357
330
  ...this.computePosition(),
358
331
  width,
@@ -633,8 +606,6 @@ export default class SessionWrapper extends Component {
633
606
  keepaliveEnabled={this.state.keepaliveEnabled}
634
607
  broadcastInput={this.state.broadcastInput}
635
608
  wrapDisabled={this.state.wrapDisabled}
636
- delKeyPressed={this.state.delKeyPressed}
637
- hideDelKeyTip={this.props.hideDelKeyTip}
638
609
  onChangePane={(pane) => this.onChangePane(pane)}
639
610
  toggleCheckSftpPathFollowSsh={this.toggleCheckSftpPathFollowSsh}
640
611
  onSshSftpSplitView={this.handleSshSftpSplitView}
@@ -643,7 +614,6 @@ export default class SessionWrapper extends Component {
643
614
  toggleWrap={this.toggleWrap}
644
615
  onFullscreen={this.handleFullscreen}
645
616
  onOpenSearch={this.handleOpenSearch}
646
- onDismissDelKeyTip={this.handleDismissDelKeyTip}
647
617
  onExitGracefully={this.handleExitGracefully}
648
618
  />
649
619
  {this.renderViews()}
@@ -69,7 +69,6 @@ export default class Sessions extends Component {
69
69
  height,
70
70
  ...pick(this.props, [
71
71
  'resolutions',
72
- 'hideDelKeyTip',
73
72
  'fileOperation',
74
73
  'pinnedQuickCommandBar',
75
74
  'tabsHeight',
@@ -3,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import { Tabs, Spin } from 'antd'
6
+ import { useEffect } from 'react'
6
7
  import SyncForm from './setting-sync-form'
7
- import { syncTypes, syncDataMaps } from '../../common/constants'
8
+ import { allowedSyncTypes, syncDataMaps } from '../../common/constants'
8
9
  import DataTransport from './data-import'
9
10
  import DataSelect from './data-select'
10
11
  import { pick } from 'lodash-es'
@@ -24,6 +25,15 @@ export default auto(function SyncSettingEntry (props) {
24
25
  const {
25
26
  store
26
27
  } = window
28
+ const types = allowedSyncTypes()
29
+ const type = types.includes(props.syncType)
30
+ ? props.syncType
31
+ : types[0]
32
+ useEffect(() => {
33
+ if (store.syncType !== type) {
34
+ store.syncType = type
35
+ }
36
+ }, [type])
27
37
  function renderForm () {
28
38
  const syncProps = {
29
39
  ...syncSetting,
@@ -31,12 +41,11 @@ export default auto(function SyncSettingEntry (props) {
31
41
  'isSyncingSetting',
32
42
  'isSyncDownload',
33
43
  'isSyncUpload',
34
- 'syncType',
35
44
  'serverStatus'
36
45
  ]),
37
- serverStatus: deepCopy(store.syncServerStatus[props.syncType])
46
+ syncType: type,
47
+ serverStatus: deepCopy(store.syncServerStatus[type])
38
48
  }
39
- const type = props.syncType
40
49
  const formData = {
41
50
  gistId: syncSetting[type + 'GistId'],
42
51
  token: syncSetting[type + 'AccessToken'],
@@ -59,8 +68,7 @@ export default auto(function SyncSettingEntry (props) {
59
68
  />
60
69
  )
61
70
  }
62
-
63
- const syncItems = Object.keys(syncTypes).map(type => {
71
+ const syncItems = types.map(type => {
64
72
  return {
65
73
  key: type,
66
74
  label: type,
@@ -84,7 +92,7 @@ export default auto(function SyncSettingEntry (props) {
84
92
  <DataTransport {...dataImportProps} />
85
93
  <Spin spinning={store.isSyncingSetting}>
86
94
  <Tabs
87
- activeKey={store.syncType}
95
+ activeKey={type}
88
96
  onChange={handleChange}
89
97
  items={syncItems}
90
98
  />
@@ -187,7 +187,6 @@ export function shortcutExtend (Cls) {
187
187
  !altKey &&
188
188
  !ctrlKey
189
189
  ) {
190
- this.props.onDelKeyPressed()
191
190
  const delKey = this.props.config.backspaceMode === '^?' ? 8 : 127
192
191
  const altDelDelKey = delKey === 8 ? 127 : 8
193
192
  const char = String.fromCharCode(shiftKey ? delKey : altDelDelKey)
@@ -127,6 +127,7 @@ export default auto(function InfoModal (props) {
127
127
  devDependencies,
128
128
  dependencies,
129
129
  languageRepo,
130
+ privacyNoticeLink,
130
131
  author: {
131
132
  name: authorName,
132
133
  email,
@@ -203,6 +204,12 @@ export default auto(function InfoModal (props) {
203
204
  {bugReportLink}
204
205
  </Link>
205
206
  </p>
207
+ <p className='mg1b'>
208
+ <InfoCircleOutlined /> <b className='mg1r'>{e('privacyNotice')} ➾</b>
209
+ <Link to={privacyNoticeLink} className='mg1l'>
210
+ {privacyNoticeLink}
211
+ </Link>
212
+ </p>
206
213
  <p className='mg1b'>
207
214
  <HighlightOutlined /> <b className='mg1r'>{e('changeLog')} ➾</b>
208
215
  <Link to={releaseLink} className='mg1l'>
@@ -98,12 +98,6 @@ export default class AttachAddonCustom {
98
98
  }
99
99
  }
100
100
 
101
- onShellIntegrationDetected = () => {
102
- if (this.outputSuppressed) {
103
- this.stopOutputSuppression(true)
104
- }
105
- }
106
-
107
101
  activate = async (terminal = this.term) => {
108
102
  await this._initBase()
109
103
  this.addSocketListener(this._socket, 'message', this.onMsg)
@@ -177,9 +171,10 @@ export default class AttachAddonCustom {
177
171
  }
178
172
  }
179
173
 
180
- checkForShellIntegration = (str) => {
174
+ // Index of the first OSC 633 sequence in the chunk, or -1.
175
+ indexOfShellIntegration = (str) => {
181
176
  const ESC = String.fromCharCode(27)
182
- return str.includes(ESC + ']633;')
177
+ return str.indexOf(ESC + ']633;')
183
178
  }
184
179
 
185
180
  writeToTerminalDirect = (data) => {
@@ -232,8 +227,15 @@ export default class AttachAddonCustom {
232
227
  }
233
228
 
234
229
  if (this.outputSuppressed) {
235
- if (this.checkForShellIntegration(str)) {
236
- this.onShellIntegrationDetected()
230
+ const oscIdx = this.indexOfShellIntegration(str)
231
+ if (oscIdx !== -1) {
232
+ // Shell integration is confirmed active. The pty often coalesces the
233
+ // tail of the echoed injection command and the first OSC 633 output
234
+ // into one chunk, so write only from the first OSC sequence on —
235
+ // everything before it is echo and stays hidden. A leading newline
236
+ // keeps the fresh prompt off the injection-time prompt line.
237
+ this.stopOutputSuppression(true)
238
+ this._enqueueWrite('\r\n' + str.slice(oscIdx))
237
239
  return
238
240
  }
239
241
  this.suppressedData.push(data)
@@ -27,7 +27,7 @@ function getBashInlineIntegration () {
27
27
  'if [[ $- == *i* ]] && [[ -z "${ELECTERM_SHELL_INTEGRATION:-}" ]]',
28
28
  'then export ELECTERM_SHELL_INTEGRATION=1',
29
29
  '__e_esc() { local v="$1"; v="${v//\\\\/\\\\\\\\}"; v="${v//;/\\\\x3b}"; printf \'%s\' "$v"; }',
30
- '__e_pre() { [[ "$BASH_COMMAND" == "$PROMPT_COMMAND" ]] && return; [[ "$BASH_COMMAND" == "__e_"* ]] && return; [[ "${__e_in:-0}" == "0" ]] && { __e_in=1; printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$BASH_COMMAND")"; }; }',
30
+ '__e_pre() { local p; [[ "$BASH_COMMAND" == __e_* || "$BASH_COMMAND" == PROMPT_COMMAND=* ]] && return; [[ "$BASH_COMMAND" == "$PROMPT_COMMAND" ]] && return; if [[ -n "${PROMPT_COMMAND:-}" ]]; then local IFS=\';\'; for p in $PROMPT_COMMAND; do p="${p#"${p%%[! ]*}"}"; [[ "$BASH_COMMAND" == "$p" ]] && return; done; fi; [[ "${__e_in:-0}" == "0" ]] && { __e_in=1; printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$BASH_COMMAND")"; }; }',
31
31
  '__e_cmd() { local c="$?"; [[ "${__e_in:-0}" == "1" ]] && { printf \'\\e]633;D;%s\\a\' "$c"; __e_in=0; }; printf \'\\e]633;P;Cwd=%s\\a\\e]633;A\\a\' "$(__e_esc "$PWD")"; return "$c"; }',
32
32
  'trap \'__e_pre\' DEBUG',
33
33
  'PROMPT_COMMAND="__e_cmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"',
@@ -40,17 +40,19 @@ function getBashInlineIntegration () {
40
40
  * Properly formatted for semicolon joining
41
41
  */
42
42
  function getZshInlineIntegration () {
43
- // Each statement is complete and can be joined with semicolons
44
- // Note: 'then' must have a space/newline before the next command, not semicolon
43
+ // Register hooks by appending to the precmd/preexec hook arrays directly.
44
+ // add-zsh-hook is an autoloadable shell function: on systems like NixOS
45
+ // fpath may not include the standard functions directory, the autoload
46
+ // fails silently (stderr is suppressed) and the hooks never run, breaking
47
+ // path following. Appending to the arrays works on every zsh.
45
48
  return [
46
49
  'if [[ -o interactive ]] && [[ -z "${ELECTERM_SHELL_INTEGRATION:-}" ]]',
47
50
  'then export ELECTERM_SHELL_INTEGRATION=1',
48
51
  '__e_esc() { local v="$1"; v="${v//\\\\/\\\\\\\\}"; v="${v//;/\\\\x3b}"; builtin printf \'%s\' "$v"; }',
49
52
  '__e_preexec() { __e_cmd="$1"; builtin printf \'\\e]633;E;%s\\a\\e]633;C\\a\' "$(__e_esc "$1")"; }',
50
53
  '__e_precmd() { local c="$?"; [[ -n "$__e_cmd" ]] && builtin printf \'\\e]633;D;%s\\a\' "$c"; __e_cmd=""; builtin printf \'\\e]633;P;Cwd=%s\\a\\e]633;A\\a\' "$(__e_esc "$PWD")"; }',
51
- 'autoload -Uz add-zsh-hook',
52
- 'add-zsh-hook precmd __e_precmd',
53
- 'add-zsh-hook preexec __e_preexec',
54
+ 'precmd_functions+=(__e_precmd)',
55
+ 'preexec_functions+=(__e_preexec)',
54
56
  'fi'
55
57
  ].join('; ')
56
58
  }
@@ -78,11 +80,13 @@ function getShInlineIntegration () {
78
80
  return [
79
81
  'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
80
82
  '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:-# }"',
83
+ '__e_esc() { printf \'%s\' "$1" | sed \'s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g\'; }',
84
+ // sh/dash/ash re-evaluate $(...) inside PS1 every time the prompt is
85
+ // shown, but only if the $() stays literal — so PS1 must be assigned
86
+ // single-quoted. printf interprets \033/\a, unlike PS1 backslash
87
+ // escapes which dash does not expand.
88
+ '__e_ps1() { printf \'\\033]633;P;Cwd=%s\\a\\033]633;A\\a\' "$(__e_esc "$PWD")"; }',
89
+ 'PS1=\'$(__e_ps1)\'"${PS1:-# }"',
86
90
  'fi'
87
91
  ].join('; ')
88
92
  }
@@ -139,6 +143,13 @@ export function wrapSilent (cmd, shellType) {
139
143
  * @returns {string} Complete command to send to terminal
140
144
  */
141
145
  export function getShellIntegrationCommand (shellType = 'bash') {
146
+ if (shellType === 'fish') {
147
+ // fish is not a Bourne shell: the eval '\''...'\'' wrapper of wrapSilent
148
+ // is a parse error in fish (nested single quotes stay unbalanced), so
149
+ // the whole injection silently fails. Send the script raw instead —
150
+ // it is already valid fish syntax as-is.
151
+ return ' ' + getInlineShellIntegration(shellType) + '\r'
152
+ }
142
153
  const cmd = getInlineShellIntegration(shellType)
143
154
  return wrapSilent(cmd, shellType)
144
155
  }
@@ -22,6 +22,18 @@
22
22
  * visualViewport.height against a baseline captured at the moment the
23
23
  * terminal input is focused (i.e. before the keyboard opens), which holds
24
24
  * the full pre-keyboard height on both platforms.
25
+ *
26
+ * Telling a keyboard from a desktop window resize (pure size heuristics
27
+ * fail — dragging a window's top/bottom edge is also a height-only shrink)
28
+ * needs one of two extra signals, checked in onResize:
29
+ * - an overlay-style keyboard (iOS / HarmonyOS) shrinks the visual
30
+ * viewport but keeps the layout viewport (window.innerHeight) full —
31
+ * a window resize shrinks both.
32
+ * - an Android-style keyboard resizes both viewports, but a soft keyboard
33
+ * only ever opens in response to a *touch* into the terminal — a window
34
+ * resized with a mouse never shows one.
35
+ * Tablets running the desktop program still get the bar; mouse-driven
36
+ * desktops never do.
25
37
  */
26
38
 
27
39
  import { lazy, Suspense, useEffect, useState } from 'react'
@@ -35,6 +47,11 @@ const ShortcutBar = lazy(() => import('./shortcut-bar'))
35
47
  // bar above a keyboard that overlays the page (iOS / HarmonyOS).
36
48
  export const KEYBOARD_MIN = 120
37
49
 
50
+ // how long (ms) after an in-page touch an Android-style (layout-resizing)
51
+ // keyboard is expected to be up. Bounds the touch signal used to tell that
52
+ // keyboard from a mouse-driven window resize on touch-capable desktops.
53
+ const TOUCH_GRACE_MS = 3000
54
+
38
55
  // kept as a coarse capability fallback for engines without visualViewport.
39
56
  // Modern Chromium (Electron) always exposes window.visualViewport, so this is
40
57
  // effectively only reached on very old runtimes — where remote-control
@@ -77,6 +94,12 @@ export default function ShortcutBarEntry (props) {
77
94
  // the pre-keyboard (full) size, and the subsequent resize lets us measure
78
95
  // exactly how much the keyboard took — on both iOS and Android.
79
96
  let baseline = { height: vv.height, width: vv.width }
97
+ // when the last in-page touch happened (0 = never). An Android-style
98
+ // keyboard resizes the layout viewport exactly like a window resize does,
99
+ // so size alone can't separate them there — but a keyboard only ever
100
+ // opens right after a *touch*, while a window drag doesn't fire in-page
101
+ // pointer events at all, so an old touch must not count forever.
102
+ let lastTouchAt = 0
80
103
 
81
104
  function onFocusIn (ev) {
82
105
  const el = ev.target
@@ -85,11 +108,25 @@ export default function ShortcutBarEntry (props) {
85
108
  }
86
109
  }
87
110
 
111
+ function onPointerDown (ev) {
112
+ // touch or stylus — both tap like a finger and can raise the keyboard
113
+ if (ev.pointerType === 'touch' || ev.pointerType === 'pen') {
114
+ lastTouchAt = Date.now()
115
+ }
116
+ }
117
+
88
118
  function onResize () {
89
119
  // ignore viewport changes that also changed width (rotation / split) —
90
120
  // a keyboard changes height only.
91
121
  const sameWidth = Math.abs(vv.width - baseline.width) < 20
92
- if (sameWidth && baseline.height - vv.height > KEYBOARD_MIN) {
122
+ if (!sameWidth || baseline.height - vv.height <= KEYBOARD_MIN) {
123
+ return
124
+ }
125
+ // overlay keyboard (iOS / HarmonyOS): the layout viewport stays full
126
+ // while the visual one shrinks. A window resize shrinks both — this is
127
+ // what rules out mouse window-dragging on those platforms.
128
+ const layoutShrunk = window.innerHeight < baseline.height - KEYBOARD_MIN
129
+ if (!layoutShrunk) {
93
130
  setKeyboardSeen(true)
94
131
  // The user is focused in a terminal with the keyboard up — they need
95
132
  // the bar right now. Set visibility here rather than waiting for the
@@ -100,13 +137,27 @@ export default function ShortcutBarEntry (props) {
100
137
  if (store && store.inActiveTerminal) {
101
138
  store.shortcutBarVisible = true
102
139
  }
140
+ return
141
+ }
142
+ // Android-style resize keyboard: both viewports shrink, so require a
143
+ // recent touch — a keyboard opens within moments of the tap, while a
144
+ // window drag fires no in-page pointerdown at all, so any touch old
145
+ // enough to have expired can't have been the cause.
146
+ if (Date.now() - lastTouchAt < TOUCH_GRACE_MS) {
147
+ setKeyboardSeen(true)
148
+ const { store } = window
149
+ if (store && store.inActiveTerminal) {
150
+ store.shortcutBarVisible = true
151
+ }
103
152
  }
104
153
  }
105
154
 
106
155
  document.addEventListener('focusin', onFocusIn)
156
+ document.addEventListener('pointerdown', onPointerDown)
107
157
  vv.addEventListener('resize', onResize)
108
158
  return () => {
109
159
  document.removeEventListener('focusin', onFocusIn)
160
+ document.removeEventListener('pointerdown', onPointerDown)
110
161
  vv.removeEventListener('resize', onResize)
111
162
  }
112
163
  }, [])
@@ -5,6 +5,8 @@
5
5
  import { useEffect, useState, useRef, useCallback } from 'react'
6
6
  import wait from '../../common/wait'
7
7
  import TermInteractiveUI from './terminal-interactive-ui'
8
+ import ExternalLink from '../common/external-link.jsx'
9
+ import { notification } from '../common/notification'
8
10
 
9
11
  export default function TermInteractive () {
10
12
  const [current, setCurrent] = useState(null)
@@ -15,6 +17,32 @@ export default function TermInteractive () {
15
17
  window.store.updateTab(data.tabId, data.update)
16
18
  }
17
19
 
20
+ function notifyProxyCommandMessage (data) {
21
+ const lines = (data.message || '').split('\n')
22
+ const title = lines[0] || 'ssh proxy command'
23
+ const body = lines.slice(1).join('\n').trim()
24
+ // make any http(s) link in the message clickable (SSO login url)
25
+ const renderBody = (text) => {
26
+ const parts = text.split(/(https?:\/\/\S+)/)
27
+ return (
28
+ <pre className='pd0 mg0 overflow-auto'>
29
+ {
30
+ parts.map((part, i) => /^https?:\/\//.test(part)
31
+ ? <ExternalLink key={i} to={part}>{part}</ExternalLink>
32
+ : part)
33
+ }
34
+ </pre>
35
+ )
36
+ }
37
+ notification.info({
38
+ message: title,
39
+ description: body
40
+ ? renderBody(body)
41
+ : undefined,
42
+ duration: 30
43
+ })
44
+ }
45
+
18
46
  function processNext () {
19
47
  const next = queueRef.current.shift()
20
48
  if (next) {
@@ -47,6 +75,13 @@ export default function TermInteractive () {
47
75
  e.data.includes('ssh-tunnel-result')
48
76
  ) {
49
77
  updateTab(JSON.parse(e.data))
78
+ } else if (
79
+ e &&
80
+ e.data &&
81
+ typeof e.data === 'string' &&
82
+ e.data.includes('ssh-proxy-command-message')
83
+ ) {
84
+ notifyProxyCommandMessage(JSON.parse(e.data))
50
85
  }
51
86
  }
52
87
 
@@ -1723,12 +1723,6 @@ class Term extends Component {
1723
1723
  }
1724
1724
 
1725
1725
  this.shellType = shellType
1726
- if (shellType === 'fish') {
1727
- if (this.props.sftpPathFollowSsh) {
1728
- this.warnSftpFollowUnsupported()
1729
- }
1730
- return Promise.resolve()
1731
- }
1732
1726
 
1733
1727
  // Don't inject for sh type shells unless sftpPathFollowSsh is true
1734
1728
  if (shellType === 'sh' && !this.props.sftpPathFollowSsh) {
@@ -1946,6 +1940,17 @@ class Term extends Component {
1946
1940
  if (this.isElementVisible()) {
1947
1941
  this.fitAddon.fit()
1948
1942
  }
1943
+ // Force-send the current size now that `this.pid` is finally valid.
1944
+ // A resize may already have fired earlier (e.g. from the tab-becomes-
1945
+ // visible fit in componentDidUpdate, which can run before createTerm()
1946
+ // resolves) while this.pid was still undefined - the backend silently
1947
+ // drops resize calls for an unknown pid. If that earlier call already
1948
+ // updated term.cols/rows locally, xterm's resize() no-ops on the next
1949
+ // fit() (dims unchanged) and never fires onResize again, so the
1950
+ // backend pty would otherwise be stuck at its initial (possibly wrong)
1951
+ // size forever. Sending explicitly here, keyed off the real pid,
1952
+ // guarantees the remote pty/ssh channel is told the true current size.
1953
+ resizeTerm(this.pid, term.cols, term.rows)
1949
1954
  term.displayRaw = displayRaw
1950
1955
  term.loadAddon(
1951
1956
  new KeywordHighlighterAddon(keywords)
@@ -42,7 +42,11 @@ export default function TerminalInfoActivities (props) {
42
42
  bordered: true,
43
43
  columns: col,
44
44
  size: 'small',
45
- pagination: false
45
+ pagination: false,
46
+ scroll: {
47
+ y: 180,
48
+ x: 'max-content'
49
+ }
46
50
  }
47
51
  return (
48
52
  <div className='terminal-info-section terminal-info-act'>
@@ -3,8 +3,8 @@
3
3
  */
4
4
  import { Component } from 'react'
5
5
  import {
6
- Space,
7
- Button
6
+ Button,
7
+ Dropdown
8
8
  } from 'antd'
9
9
  import SwitchLabel from '../common/switch'
10
10
  import defaults from '../../common/default-setting'
@@ -15,7 +15,9 @@ import {
15
15
  DatabaseOutlined,
16
16
  BarsOutlined,
17
17
  ApiOutlined,
18
- PartitionOutlined
18
+ PartitionOutlined,
19
+ FilterOutlined,
20
+ CheckOutlined
19
21
  } from '@ant-design/icons'
20
22
  import { refs } from '../common/ref'
21
23
  import ShowItem from '../common/show-item'
@@ -136,26 +138,39 @@ export default class TerminalInfoBase extends Component {
136
138
  const {
137
139
  terminalInfos
138
140
  } = this.props
141
+ const items = defaults.terminalInfos.map(f => {
142
+ const checked = terminalInfos.includes(f)
143
+ return {
144
+ key: f,
145
+ label: (
146
+ <span
147
+ className='term-info-filter-label'
148
+ style={{
149
+ display: 'flex',
150
+ alignItems: 'center',
151
+ gap: '6px'
152
+ }}
153
+ >
154
+ <span style={{ width: '14px' }}>{checked ? <CheckOutlined /> : null}</span>
155
+ {mapper[f]} {f}
156
+ </span>
157
+ ),
158
+ onClick: () => this.toggleTerminalLogInfo(f)
159
+ }
160
+ })
139
161
  return (
140
- <Space.Compact className='width-100'>
141
- {
142
- defaults.terminalInfos.map(f => {
143
- const type = terminalInfos.includes(f) ? 'primary' : 'default'
144
- return (
145
- <Button
146
- key={f + 'term-info-sel'}
147
- type={type}
148
- size='small'
149
- onClick={() => this.toggleTerminalLogInfo(f)}
150
- className='cap'
151
- icon={mapper[f]}
152
- >
153
- {f}
154
- </Button>
155
- )
156
- })
157
- }
158
- </Space.Compact>
162
+ <Dropdown
163
+ menu={{ items }}
164
+ trigger={['click']}
165
+ placement='bottomRight'
166
+ >
167
+ <Button
168
+ size='small'
169
+ icon={<FilterOutlined />}
170
+ >
171
+ {e('filter')}({terminalInfos.length}/{defaults.terminalInfos.length})
172
+ </Button>
173
+ </Dropdown>
159
174
  )
160
175
  }
161
176
 
@@ -30,7 +30,11 @@ export default function TerminalInfoDisk (props) {
30
30
  bordered: true,
31
31
  columns: col,
32
32
  size: 'small',
33
- pagination: false
33
+ pagination: false,
34
+ scroll: {
35
+ y: 180,
36
+ x: 'max-content'
37
+ }
34
38
  }
35
39
  return (
36
40
  <div className='terminal-info-section terminal-info-disk'>
@@ -0,0 +1,89 @@
1
+ /**
2
+ * resolve local terminal info: os / hostname / kernel / arch / shell + version
3
+ * uses the electron os module via window.pre.osInfo() (sync) and probes the
4
+ * configured shell binary for its version through the local fs run bridge
5
+ */
6
+
7
+ import { isMac, isWin } from '../../common/constants'
8
+ import fs from '../../common/fs'
9
+
10
+ function parseOsInfo (list) {
11
+ const map = {}
12
+ for (const { k, v } of list || []) {
13
+ let val
14
+ try {
15
+ val = JSON.parse(v)
16
+ } catch (e) {
17
+ val = v
18
+ }
19
+ map[k] = Array.isArray(val) ? val.join(' ') : String(val)
20
+ }
21
+ return map
22
+ }
23
+
24
+ const shellNameReg = /(\w+)$/
25
+
26
+ function formatOsName (map) {
27
+ const ver = map.release || ''
28
+ if (isWin) {
29
+ const main = parseInt(ver.split('.')[0], 10)
30
+ if (main >= 10) {
31
+ const build = parseInt(ver.split('.')[2], 10)
32
+ return build >= 22000 ? `Windows 11 (${ver})` : `Windows 10 (${ver})`
33
+ }
34
+ return `Windows (${ver})`
35
+ }
36
+ if (isMac) {
37
+ return `macOS ${map.version || ''}`.trim()
38
+ }
39
+ return `Linux ${ver}`.trim()
40
+ }
41
+
42
+ async function probeShellVersion (shellPath) {
43
+ if (!shellPath) {
44
+ return ''
45
+ }
46
+ try {
47
+ let out = ''
48
+ if (isWin) {
49
+ // runWinCmd wraps the given script with powershell.exe -Command
50
+ const res = await fs.runWinCmd(
51
+ '$PSVersionTable.PSVersion.ToString()'
52
+ ).catch(() => null)
53
+ out = res?.stdout || ''
54
+ } else {
55
+ out = await fs.run(`'${shellPath.replace(/'/g, "'\\''")}' --version`).catch(() => '')
56
+ }
57
+ return (out || '').split('\n')[0].trim()
58
+ } catch (e) {
59
+ return ''
60
+ }
61
+ }
62
+
63
+ export default async function resolveLocalInfo (pid) {
64
+ const store = window.store
65
+ if (!window.pre?.osInfo) {
66
+ // web app build: no local os bridge available
67
+ return null
68
+ }
69
+ const tab = store?.getTabs?.().find(t => t.id === pid)
70
+ const config = store?.config || {}
71
+ const execProp = isWin ? 'execWindows' : isMac ? 'execMac' : 'execLinux'
72
+ // tab settings (bookmark) win over global config, same as terminal spawn logic
73
+ const shellPath = tab?.[execProp] || config[execProp] || ''
74
+ const shellBase = isWin
75
+ ? (shellPath.split(/[\\/]/).pop() || '')
76
+ : shellPath.split('/').pop() || ''
77
+ const nameMatch = shellBase.match(shellNameReg)
78
+ const shellName = nameMatch ? nameMatch[1] : shellBase
79
+ const map = parseOsInfo(window.pre.osInfo())
80
+ const shellVersion = await probeShellVersion(shellPath)
81
+ return {
82
+ os: formatOsName(map),
83
+ hostname: map.hostname || '',
84
+ kernel: isWin ? `Windows NT ${map.release || ''}`.trim() : (map.release || ''),
85
+ arch: map.arch || '',
86
+ shell: shellName,
87
+ shellVersion
88
+ }
89
+ }
@@ -106,7 +106,11 @@ export default function TerminalInfoDisk (props) {
106
106
  bordered: true,
107
107
  size: 'small',
108
108
  columns: col,
109
- pagination: false
109
+ pagination: false,
110
+ scroll: {
111
+ y: 180,
112
+ x: 'max-content'
113
+ }
110
114
  }
111
115
  return (
112
116
  <div className='terminal-info-section terminal-info-network'>
@@ -32,6 +32,33 @@ function formatActivities (str) {
32
32
  }
33
33
  }
34
34
 
35
+ function formatSysInfo (unameStr, prettyName) {
36
+ if (!unameStr) {
37
+ return {}
38
+ }
39
+ const parts = unameStr.trim().split(/\s+/)
40
+ if (parts.length < 4) {
41
+ return {}
42
+ }
43
+ const [sysname, hostname, kernel, arch] = parts
44
+ let os = sysname
45
+ const m = (prettyName || '').match(/PRETTY_NAME="?([^"\r\n]+)"?/)
46
+ if (m) {
47
+ os = m[1].trim()
48
+ } else if (sysname === 'Darwin') {
49
+ os = 'macOS'
50
+ }
51
+ return {
52
+ sysInfo: {
53
+ os,
54
+ sysname,
55
+ hostname,
56
+ kernel,
57
+ arch
58
+ }
59
+ }
60
+ }
61
+
35
62
  function formatCpu (str) {
36
63
  if (!str) {
37
64
  return {
@@ -153,9 +180,11 @@ function InfoGetter (props) {
153
180
  props.setState(update)
154
181
  }
155
182
  run()
156
- const ref = setInterval(run, interval)
157
- return () => {
158
- clearInterval(ref)
183
+ if (interval > 0) {
184
+ const ref = setInterval(run, interval)
185
+ return () => {
186
+ clearInterval(ref)
187
+ }
159
188
  }
160
189
  }, [pid])
161
190
  return null
@@ -166,6 +195,16 @@ export default (props) => {
166
195
  return null
167
196
  }
168
197
  const cmds = [
198
+ {
199
+ name: 'sysinfo',
200
+ cmds: [
201
+ 'uname -s -n -r -m',
202
+ 'grep PRETTY_NAME= /etc/os-release 2>/dev/null || echo'
203
+ ],
204
+ interval: 0,
205
+ delay: 0,
206
+ formatter: formatSysInfo
207
+ },
169
208
  {
170
209
  name: 'uptime',
171
210
  cmd: 'uptime -p',
@@ -0,0 +1,56 @@
1
+ /**
2
+ * system info: os / hostname / kernel / arch (+ shell for local terminals)
3
+ * remote info comes from uname via the session runCmd bridge,
4
+ * local info from the os module + a shell version probe
5
+ */
6
+
7
+ import { useEffect, useState } from 'react'
8
+ import { DesktopOutlined } from '@ant-design/icons'
9
+ import resolveLocalInfo from './local-info-resolver'
10
+
11
+ export default function TerminalInfoSys (props) {
12
+ const { sysInfo, isRemote, pid } = props
13
+ const [localInfo, setLocalInfo] = useState(null)
14
+
15
+ useEffect(() => {
16
+ if (isRemote) {
17
+ return
18
+ }
19
+ resolveLocalInfo(pid).then(setLocalInfo)
20
+ }, [isRemote, pid])
21
+
22
+ const info = isRemote ? sysInfo : localInfo
23
+ if (!info || !info.os) {
24
+ return null
25
+ }
26
+ const rows = [
27
+ ['os', info.os],
28
+ ['hostname', info.hostname],
29
+ ['kernel', info.kernel],
30
+ ['arch', info.arch]
31
+ ]
32
+ if (info.shell) {
33
+ rows.push(['shell', info.shellVersion ? `${info.shell} ${info.shellVersion}` : info.shell])
34
+ }
35
+ const final = rows.filter(d => d[1])
36
+ if (!final.length) {
37
+ return null
38
+ }
39
+ return (
40
+ <div className='terminal-info-section terminal-info-sys'>
41
+ <div className='pd1y bold'><DesktopOutlined /> System</div>
42
+ <div className='terminal-info-sys-rows'>
43
+ {
44
+ final.map(([k, v]) => {
45
+ return (
46
+ <div className='terminal-info-sys-row' key={k}>
47
+ <span className='terminal-info-sys-key'>{k}</span>
48
+ <span className='terminal-info-sys-val' title={v}>{v}</span>
49
+ </div>
50
+ )
51
+ })
52
+ }
53
+ </div>
54
+ </div>
55
+ )
56
+ }
@@ -9,6 +9,7 @@ import TerminalInfoNetwork from './network'
9
9
  import TerminalInfoResource from './resource'
10
10
  import TerminalInfoActivities from './activity'
11
11
  import TerminalInfoDisk from './disk'
12
+ import TerminalInfoSys from './sys-info'
12
13
  import RunCmd from './run-cmd'
13
14
  import { runCmd } from '../terminal/terminal-apis'
14
15
  import './terminal-info.styl'
@@ -21,7 +22,8 @@ export default class TerminalInfoContent extends PureComponent {
21
22
  swap: {},
22
23
  activities: [],
23
24
  disks: [],
24
- network: {}
25
+ network: {},
26
+ sysInfo: null
25
27
  }
26
28
 
27
29
  setStateRef = (...args) => {
@@ -44,6 +46,7 @@ export default class TerminalInfoContent extends PureComponent {
44
46
  return (
45
47
  <>
46
48
  <TerminalInfoBase {...props} {...state} />
49
+ <TerminalInfoSys {...props} {...state} />
47
50
  <TerminalInfoUp {...props} {...state} />
48
51
  <TerminalInfoResource
49
52
  {...props} {...state}
@@ -1,4 +1,3 @@
1
-
2
1
  .terminal-info-icon
3
2
  color var(--text-dark)
4
3
  &:hover
@@ -23,11 +22,27 @@
23
22
  display block
24
23
  .terminal-info-section
25
24
  padding 10px 0
26
- .terminal-info-act
27
- .terminal-info-disk
28
- .terminal-info-network
29
- max-height 260px
30
- overflow-y scroll
25
+ .terminal-info-sys
26
+ border-bottom 1px solid var(--main-darker)
27
+ .terminal-info-sys-rows
28
+ display flex
29
+ flex-direction column
30
+ gap 4px
31
+ .terminal-info-sys-row
32
+ display flex
33
+ gap 8px
34
+ align-items baseline
35
+ font-size 12px
36
+ .terminal-info-sys-key
37
+ flex none
38
+ width 80px
39
+ color var(--text-dark)
40
+ text-transform capitalize
41
+ .terminal-info-sys-val
42
+ flex 1
43
+ overflow hidden
44
+ text-overflow ellipsis
45
+ white-space nowrap
31
46
  .activity-item
32
47
  max-height 1.6em
33
48
  line-height 1.6
@@ -36,4 +51,4 @@
36
51
  max-height none
37
52
  .info-panel-wrap-pin
38
53
  .toggle-info-panel-wrap
39
- color var(--success)
54
+ color var(--success)
@@ -11,14 +11,16 @@ import {
11
11
  leftSideBarOpenKey,
12
12
  rightSidebarWidthKey,
13
13
  addPanelWidthLsKey,
14
- dismissDelKeyTipLsKey,
15
14
  connectionMap,
16
15
  lastAiChatSessionIdKey,
17
16
  mobileBreakpoint,
18
- splitMap
17
+ splitMap,
18
+ settingAiId,
19
+ settingSyncId
19
20
  } from '../common/constants'
20
21
  import * as ls from '../common/safe-local-storage'
21
22
  import { refs, refsStatic } from '../components/common/ref'
23
+ import { requireTermOfUse } from '../common/term-of-use'
22
24
  import { action } from 'manate'
23
25
  import uid from '../common/uid'
24
26
  import deepCopy from 'json-deep-copy'
@@ -56,7 +58,9 @@ export default Store => {
56
58
  }
57
59
 
58
60
  Store.prototype.toggleAIConfig = function () {
59
- window.store.showAIConfigModal = true
61
+ requireTermOfUse('ai', () => {
62
+ window.store.showAIConfigModal = true
63
+ })
60
64
  }
61
65
 
62
66
  Store.prototype.onResize = debounce(async function () {
@@ -99,6 +103,18 @@ export default Store => {
99
103
  }
100
104
 
101
105
  Store.prototype.setSettingItem = function (v) {
106
+ // entering the AI / sync setting page requires the term of use
107
+ // confirmation first (when the term is defined)
108
+ if (
109
+ v && (
110
+ v.id === settingAiId || v.id === settingSyncId
111
+ )
112
+ ) {
113
+ const type = v.id === settingAiId ? 'ai' : 'sync'
114
+ return requireTermOfUse(type, () => {
115
+ window.store.settingItem = v
116
+ })
117
+ }
102
118
  window.store.settingItem = v
103
119
  }
104
120
 
@@ -131,10 +147,6 @@ export default Store => {
131
147
  ls.setItem(rightSidebarWidthKey, v)
132
148
  window.store._rightPanelWidth = v
133
149
  }
134
- Store.prototype.dismissDelKeyTip = function (v) {
135
- ls.setItem(dismissDelKeyTipLsKey, 'y')
136
- window.store.hideDelKeyTip = true
137
- }
138
150
  Store.prototype.beforeExit = function (evt) {
139
151
  const { confirmBeforeExit } = window.store.config
140
152
  if (
@@ -19,7 +19,6 @@ import {
19
19
  leftSidePanelWidthKey,
20
20
  rightSidebarWidthKey,
21
21
  addPanelWidthLsKey,
22
- dismissDelKeyTipLsKey,
23
22
  qmSortByFrequencyKey,
24
23
  resolutionsLsKey,
25
24
  syncServerDataKey,
@@ -216,7 +215,6 @@ export default () => {
216
215
  hasNodePty: window.pre.runSync('nodePtyCheck'),
217
216
  isMobile: window.innerWidth <= mobileBreakpoint,
218
217
  fullscreen: false,
219
- hideDelKeyTip: ls.getItem(dismissDelKeyTipLsKey) === 'y',
220
218
  tabsHeight: 36,
221
219
 
222
220
  // widgets
@@ -810,7 +810,9 @@ export default Store => {
810
810
  const { command, tabId, timeoutMs } = args
811
811
  const marker = `__ET_EXEC_${uid()}__`
812
812
  const startLine = `${marker}S`
813
- const wrapped = `echo "${startLine}"; ${command}; echo "${marker}E$?"`
813
+ // Run in a subshell so commands like `exit`/`exec` cannot kill the outer
814
+ // shell before the end sentinel is emitted; $? holds the subshell's code.
815
+ const wrapped = `echo "${startLine}"; ( ${command} ); echo "${marker}E$?"`
814
816
 
815
817
  store.runQuickCommand(wrapped, false, tabId)
816
818
 
@@ -14,6 +14,7 @@ import { buildNewTheme } from '../common/terminal-theme'
14
14
  import getInitItem from '../common/init-setting-item'
15
15
  import newTerm from '../common/new-terminal'
16
16
  import settingList from '../common/setting-list'
17
+ import { requireTermOfUse } from '../common/term-of-use'
17
18
 
18
19
  const e = window.translate
19
20
 
@@ -103,11 +104,13 @@ export default Store => {
103
104
  ) {
104
105
  return store.hideSettingModal()
105
106
  }
106
- store.storeAssign({
107
- settingTab: settingMap.setting
107
+ requireTermOfUse('sync', () => {
108
+ store.storeAssign({
109
+ settingTab: settingMap.setting
110
+ })
111
+ store.setSettingItem(settingList().find(d => d.id === settingSyncId))
112
+ store.openSettingModal()
108
113
  })
109
- store.setSettingItem(settingList().find(d => d.id === settingSyncId))
110
- store.openSettingModal()
111
114
  }
112
115
 
113
116
  Store.prototype.openTerminalThemes = function () {
@@ -111,17 +111,22 @@ export default Store => {
111
111
  // Remove old tab
112
112
  tabs.splice(index, 1)
113
113
 
114
- setTimeout(() => {
115
- if (store.activeTabId === tabId) {
116
- store.activeTabId = newTab.id
117
- }
118
-
119
- // Update batch current tab ID if needed
120
- const batchProp = `activeTabId${oldBatch}`
121
- if (store[batchProp] === tabId) {
122
- store[batchProp] = newTab.id
123
- }
124
- }, 0)
114
+ // Update active tab id synchronously (same as addTab) so the new tab's
115
+ // container is already visible (display:block) by the time its Terminal
116
+ // mounts. Deferring this via setTimeout(0) left the container hidden at
117
+ // mount time, causing the initial fit()/createTerm() to use xterm's
118
+ // default 80x24 size instead of the real fitted size - which then got
119
+ // baked into the remote pty and only partially corrected later, leaving
120
+ // full-screen apps like tmux/vim rendering at the wrong size.
121
+ if (store.activeTabId === tabId) {
122
+ store.activeTabId = newTab.id
123
+ }
124
+
125
+ // Update batch current tab ID if needed
126
+ const batchProp = `activeTabId${oldBatch}`
127
+ if (store[batchProp] === tabId) {
128
+ store[batchProp] = newTab.id
129
+ }
125
130
  }
126
131
 
127
132
  Store.prototype.reloadAllTabs = function () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.2.0",
3
+ "version": "5.3.5",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",