@electerm/electerm-react 5.1.25 → 5.3.0

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 (55) hide show
  1. package/client/common/build-map.js +12 -0
  2. package/client/common/constants.js +12 -0
  3. package/client/common/db.js +7 -5
  4. package/client/common/import-task.js +174 -0
  5. package/client/common/term-of-use.jsx +69 -0
  6. package/client/components/ai/ai-config-props.js +8 -0
  7. package/client/components/bookmark-form/ai-bookmark-form.jsx +18 -15
  8. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  9. package/client/components/bookmark-form/common/bookmark-select.jsx +6 -5
  10. package/client/components/bookmark-form/common/fields.jsx +1 -1
  11. package/client/components/bookmark-form/common/profile-item.jsx +12 -2
  12. package/client/components/bookmark-form/config/common-fields.js +11 -0
  13. package/client/components/bookmark-form/config/rdp.js +1 -1
  14. package/client/components/bookmark-form/tree-select.jsx +5 -16
  15. package/client/components/common/import-progress.jsx +90 -0
  16. package/client/components/footer/footer-entry.jsx +1 -1
  17. package/client/components/main/main.jsx +2 -0
  18. package/client/components/profile/profile-form-rdp.jsx +1 -1
  19. package/client/components/quick-commands/quick-command-transport-mod.jsx +24 -18
  20. package/client/components/rdp/rdp-cred-form.jsx +55 -0
  21. package/client/components/rdp/rdp-session.jsx +97 -6
  22. package/client/components/setting-panel/keywords-transport.jsx +1 -4
  23. package/client/components/setting-panel/setting-modal.jsx +8 -2
  24. package/client/components/setting-panel/start-session-select.jsx +5 -16
  25. package/client/components/setting-sync/setting-sync.jsx +15 -7
  26. package/client/components/sidebar/info-modal.jsx +7 -0
  27. package/client/components/spice/spice-session.jsx +40 -0
  28. package/client/components/terminal/attach-addon-custom.js +12 -10
  29. package/client/components/terminal/shell.js +22 -11
  30. package/client/components/terminal/shortcut-bar-entry.jsx +52 -1
  31. package/client/components/terminal/terminal-color-query.mjs +32 -27
  32. package/client/components/terminal/terminal-interactive.jsx +35 -0
  33. package/client/components/terminal/terminal.jsx +24 -15
  34. package/client/components/terminal-info/activity.jsx +5 -1
  35. package/client/components/terminal-info/base.jsx +37 -22
  36. package/client/components/terminal-info/disk.jsx +5 -1
  37. package/client/components/terminal-info/local-info-resolver.js +89 -0
  38. package/client/components/terminal-info/network.jsx +5 -1
  39. package/client/components/terminal-info/run-cmd.jsx +42 -3
  40. package/client/components/terminal-info/sys-info.jsx +56 -0
  41. package/client/components/terminal-info/terminal-info.jsx +4 -1
  42. package/client/components/terminal-info/terminal-info.styl +22 -7
  43. package/client/components/tree-list/bookmark-transport.jsx +2 -1
  44. package/client/components/tree-list/bookmark-upload.js +56 -41
  45. package/client/components/tree-list/tree-list-row.jsx +11 -4
  46. package/client/components/tree-list/tree-list-rows.js +6 -3
  47. package/client/components/tree-list/tree-list.jsx +4 -2
  48. package/client/store/common.js +38 -3
  49. package/client/store/load-data.js +6 -1
  50. package/client/store/setting.js +7 -4
  51. package/client/store/store.js +3 -12
  52. package/client/store/sync.js +31 -2
  53. package/client/store/tab.js +16 -11
  54. package/client/store/watch.js +10 -1
  55. package/package.json +1 -1
@@ -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
  }, [])
@@ -57,7 +57,8 @@ function parseHexColor (color) {
57
57
  return [
58
58
  parseInt(expanded.slice(0, 2), 16),
59
59
  parseInt(expanded.slice(2, 4), 16),
60
- parseInt(expanded.slice(4, 6), 16)
60
+ parseInt(expanded.slice(4, 6), 16),
61
+ alpha
61
62
  ]
62
63
  }
63
64
 
@@ -82,10 +83,15 @@ function parseRgbColor (color) {
82
83
  if (alpha === null || alpha <= 0) {
83
84
  return null
84
85
  }
85
- return channels
86
+ return [...channels, alpha]
86
87
  }
87
88
 
88
89
  export function parseColorToRgb (color) {
90
+ const rgba = parseColorToRgba(color)
91
+ return rgba ? rgba.slice(0, 3) : null
92
+ }
93
+
94
+ export function parseColorToRgba (color) {
89
95
  if (typeof color !== 'string') {
90
96
  return null
91
97
  }
@@ -105,46 +111,45 @@ export function colorToOscRgb (color) {
105
111
  }
106
112
 
107
113
  /**
108
- * Blend a (possibly translucent) selection color over an opaque background,
114
+ * Blend a (possibly translucent) selection colour over an opaque background,
109
115
  * producing the colour in xterm's internal format ({ css, rgba } where
110
116
  * rgba = r<<24 | g<<16 | b<<8 | a).
111
117
  *
112
118
  * xterm derives its DOM selection colour as blend(theme.background,
113
- * selectionBackground). When the terminal background is forced transparent
114
- * (DOM renderer keeps the real background behind via CSS), that blend runs
115
- * over transparent-black and a light selection turns into dark gray. This
116
- * helper recomputes it over the real visible background instead.
119
+ * selectionBackground) into `selectionBackgroundOpaque`, and separately forces
120
+ * any opaque selectionBackground down to 0.3 alpha in
121
+ * `selectionBackgroundTransparent` (xterm#2737). electerm renders the terminal
122
+ * background via CSS and hands xterm a transparent background, so the blend
123
+ * must be redone over the real visible background — starting from the
124
+ * *configured* colour so an opaque selection stays opaque (xterm's forced
125
+ * 0.3-alpha variant would make every selection translucent).
117
126
  *
118
- * `selectionColor` is an xterm color object ({ css, rgba }) e.g.
119
- * colors.selectionBackgroundTransparent; `visibleBackground` is an opaque
120
- * colour string.
127
+ * `selectionColor` is a CSS colour string from the terminal theme, e.g.
128
+ * themeConfig.selectionBackground; `visibleBackground` is an opaque colour
129
+ * string.
121
130
  */
122
131
  export function blendSelectionOverBackground (visibleBackground, selectionColor) {
123
- const bgRgb = parseColorToRgb(visibleBackground)
124
- const sel = selectionColor?.rgba
125
- if (!bgRgb || typeof sel !== 'number') {
126
- return null
127
- }
128
- const [br, bg, bb] = bgRgb
129
- const a = (sel & 255) / 255
130
- if (a <= 0) {
132
+ const bg = parseColorToRgb(visibleBackground)
133
+ const sel = parseColorToRgba(selectionColor)
134
+ if (!bg || !sel) {
131
135
  return null
132
136
  }
137
+ const [br, bgg, bb] = bg
138
+ const [sr, sg, sb, sa] = sel
133
139
  const toCss = (r, g, b, alpha) =>
134
140
  `#${[r, g, b, alpha].map(toHexByte).join('')}`
135
- const sr = sel >> 24 & 255
136
- const sg = sel >> 16 & 255
137
- const sb = sel >> 8 & 255
138
- if (a >= 1) {
141
+ if (sa >= 1) {
142
+ // Fully opaque: use the configured colour as-is, just normalised to
143
+ // 8-digit hex so both the overlay and per-cell inline styles agree.
139
144
  return {
140
145
  css: toCss(sr, sg, sb, 255),
141
- rgba: ((sel & 0xffffff) | 0xff000000) >>> 0
146
+ rgba: (sr << 24 | sg << 16 | sb << 8 | 255) >>> 0
142
147
  }
143
148
  }
144
- const r = br + Math.round((sr - br) * a)
145
- const g = bg + Math.round((sg - bg) * a)
146
- const b = bb + Math.round((sb - bb) * a)
147
- const alpha = Math.round(a * 255)
149
+ const r = br + Math.round((sr - br) * sa)
150
+ const g = bgg + Math.round((sg - bgg) * sa)
151
+ const b = bb + Math.round((sb - bb) * sa)
152
+ const alpha = Math.round(sa * 255)
148
153
  return {
149
154
  css: toCss(r, g, b, alpha),
150
155
  rgba: (r << 24 | g << 16 | b << 8 | alpha) >>> 0
@@ -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
 
@@ -1474,31 +1474,35 @@ class Term extends Component {
1474
1474
 
1475
1475
  /**
1476
1476
  * xterm computes its DOM selection colour as blend(theme.background,
1477
- * selectionBackground). The DOM renderer keeps the terminal background
1478
- * transparent (rgba(0,0,0,0)) so electerm's own CSS background / image
1479
- * shows through, which makes a light selection blend over transparent-black
1480
- * and render as dark gray. Recompute the selection over the real visible
1481
- * background so terminal:selectionBackground=rgba(...) is applied as the
1482
- * configured semi-transparent colour.
1477
+ * selectionBackground). The terminal background is forced transparent
1478
+ * (rgba(0,0,0,0)) so electerm's own CSS background / image shows through,
1479
+ * which makes the blend run over transparent-black. Recompute the selection
1480
+ * colours over the real visible background, from the *configured* theme
1481
+ * colours — xterm's internal selectionBackgroundTransparent forces opaque
1482
+ * colours down to 0.3 alpha (xterm#2737), which would make every selection
1483
+ * translucent even when the user configured an opaque one.
1483
1484
  */
1484
1485
  fixSelectionColors = (term) => {
1486
+ const themeConfig = this.props.themeConfig || {}
1485
1487
  const themeService = term?._core?._themeService
1486
1488
  if (!themeService?.modifyColors) {
1487
1489
  return
1488
1490
  }
1489
1491
  const visibleBackground = this.getVisibleTerminalBackground()
1490
- const colors = themeService.colors
1492
+ // xterm's DEFAULT_SELECTION, used when the theme omits a selection color
1493
+ const selectionFallback = 'rgba(255, 255, 255, 0.3)'
1494
+ const inactiveFallback = themeConfig.selectionBackground || selectionFallback
1491
1495
  themeService.modifyColors((c) => {
1492
1496
  const active = blendSelectionOverBackground(
1493
1497
  visibleBackground,
1494
- colors.selectionBackgroundTransparent
1498
+ themeConfig.selectionBackground || selectionFallback
1495
1499
  )
1496
1500
  if (active) {
1497
1501
  c.selectionBackgroundOpaque = active
1498
1502
  }
1499
1503
  const inactive = blendSelectionOverBackground(
1500
1504
  visibleBackground,
1501
- colors.selectionInactiveBackgroundTransparent
1505
+ themeConfig.selectionInactiveBackground || inactiveFallback
1502
1506
  )
1503
1507
  if (inactive) {
1504
1508
  c.selectionInactiveBackgroundOpaque = inactive
@@ -1719,12 +1723,6 @@ class Term extends Component {
1719
1723
  }
1720
1724
 
1721
1725
  this.shellType = shellType
1722
- if (shellType === 'fish') {
1723
- if (this.props.sftpPathFollowSsh) {
1724
- this.warnSftpFollowUnsupported()
1725
- }
1726
- return Promise.resolve()
1727
- }
1728
1726
 
1729
1727
  // Don't inject for sh type shells unless sftpPathFollowSsh is true
1730
1728
  if (shellType === 'sh' && !this.props.sftpPathFollowSsh) {
@@ -1942,6 +1940,17 @@ class Term extends Component {
1942
1940
  if (this.isElementVisible()) {
1943
1941
  this.fitAddon.fit()
1944
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)
1945
1954
  term.displayRaw = displayRaw
1946
1955
  term.loadAddon(
1947
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
+ }