@electerm/electerm-react 3.15.90 → 3.15.100

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.
@@ -241,7 +241,6 @@ export const termLSPrefix = 'term:sess:'
241
241
  export const batchInputLsKey = 'batch-inputs'
242
242
  export const rendererTypes = {
243
243
  dom: 'dom',
244
- canvas: 'canvas',
245
244
  webGL: 'webGL'
246
245
  }
247
246
  export const downloadUpgradeTimeout = 20000
@@ -28,7 +28,7 @@ export default {
28
28
  terminalBackgroundTextSize: 48,
29
29
  terminalBackgroundTextColor: '#ffffff',
30
30
  terminalBackgroundTextFontFamily: 'Maple Mono',
31
- rendererType: 'canvas',
31
+ rendererType: 'dom',
32
32
  terminalType: 'xterm-256color',
33
33
  keepaliveCountMax: 10,
34
34
  keyword2FA: 'verification code,otp,one-time,two-factor,2fa,totp,authenticator,duo,yubikey,security code,mfa,passcode',
@@ -48,6 +48,7 @@ export default {
48
48
  sftpPathFollowSsh: false,
49
49
  keepaliveInterval: 10000,
50
50
  backspaceMode: '^?',
51
+ shiftEnterMode: '\\n',
51
52
  showHiddenFilesOnSftpStart: true,
52
53
  terminalInfos: [
53
54
  'uptime',
@@ -9,7 +9,8 @@
9
9
  * Rules applied:
10
10
  * - Remove control characters (0x00-0x1F)
11
11
  * - Replace reserved characters: < > : " / \ | ? * with _
12
- * - Remove leading/trailing dots and spaces (Windows restriction)
12
+ * - Strip trailing dots and spaces (Windows restriction: "file.", "file ")
13
+ * - Strip leading spaces only (NOT leading dots — they mean hidden file)
13
14
  * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
14
15
  * - Limit filename length to 255 bytes (common filesystem limit)
15
16
  * - Fallback to 'unnamed' if result is empty
@@ -19,8 +20,12 @@
19
20
  // eslint-disable-next-line no-control-regex
20
21
  const ILLEGAL_CHARS = /[<>:"/\\|?\x00-\x1f]/g
21
22
 
22
- // Leading/trailing dots and spaces are problematic on Windows
23
- const LEADING_TRAILING = /^[.\s]+|[.\s]+$/g
23
+ // Trailing dots and spaces = problematic on Windows (e.g. "file." → "file")
24
+ // Leading dots are PRESERVED — they mean "hidden file" on Unix and work on modern Windows
25
+ const TRAILING_DOTS_SPACES = /[.\s]+$/g
26
+
27
+ // Leading spaces only — Windows can't handle filenames starting with space
28
+ const LEADING_SPACES = /^\s+/
24
29
 
25
30
  // Reserved Windows device names (case-insensitive)
26
31
  const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i
@@ -37,8 +42,10 @@ export default function sanitizeFilename (name) {
37
42
  let safe = name
38
43
  // Replace illegal characters
39
44
  .replace(ILLEGAL_CHARS, REPLACEMENT_CHAR)
40
- // Strip leading/trailing dots and spaces
41
- .replace(LEADING_TRAILING, '')
45
+ // Strip trailing dots and spaces (Windows restriction)
46
+ .replace(TRAILING_DOTS_SPACES, '')
47
+ // Strip leading spaces only (not dots — they mean hidden file)
48
+ .replace(LEADING_SPACES, '')
42
49
 
43
50
  // Handle reserved Windows device names by appending underscore
44
51
  if (RESERVED_NAMES.test(safe)) {
@@ -552,6 +552,10 @@ export default class SettingTerminal extends Component {
552
552
  {
553
553
  this.renderText('terminalWordSeparator', e('terminalWordSeparator'))
554
554
  }
555
+ <div className='pd1b'>Shift+Enter</div>
556
+ {
557
+ this.renderText('shiftEnterMode', 'Text to send on Shift+Enter. Use \\n for newline')
558
+ }
555
559
  {
556
560
  this.renderCursorStyleSelect()
557
561
  }
@@ -5,6 +5,30 @@ import {
5
5
  } from '../../common/constants'
6
6
  import keyControlPressed from '../../common/key-control-pressed.js'
7
7
 
8
+ function processEscapeSequences (text) {
9
+ let result = ''
10
+ for (let i = 0; i < text.length; i++) {
11
+ if (text[i] === '\\' && i + 1 < text.length) {
12
+ const next = text[i + 1]
13
+ if (next === 'n') {
14
+ result += '\n'
15
+ } else if (next === 't') {
16
+ result += '\t'
17
+ } else if (next === 'r') {
18
+ result += '\r'
19
+ } else if (next === '\\') {
20
+ result += '\\'
21
+ } else {
22
+ result += '\\' + next
23
+ }
24
+ i++
25
+ } else {
26
+ result += text[i]
27
+ }
28
+ }
29
+ return result
30
+ }
31
+
8
32
  function sendInputData (ctx, data) {
9
33
  if (!data) return
10
34
  if (ctx.attachAddon && ctx.attachAddon._sendData) {
@@ -170,6 +194,21 @@ export function shortcutExtend (Cls) {
170
194
  this.socket.send(char)
171
195
  this.term.scrollToBottom()
172
196
  return false
197
+ } else if (
198
+ this.term &&
199
+ key === 'Enter' &&
200
+ type === 'keydown' &&
201
+ shiftKey &&
202
+ !ctrlKey &&
203
+ !altKey &&
204
+ !metaKey
205
+ ) {
206
+ event.preventDefault()
207
+ event.stopPropagation()
208
+ const shiftEnterText = processEscapeSequences(this.props.config.shiftEnterMode || '\\n')
209
+ this.socket.send(shiftEnterText)
210
+ this.term.scrollToBottom()
211
+ return false
173
212
  } else if (
174
213
  this.term &&
175
214
  this.term.buffer.active.type === 'alternate'
@@ -4,11 +4,11 @@ import {
4
4
  HighlightOutlined,
5
5
  HomeOutlined,
6
6
  UserOutlined,
7
- WarningOutlined,
8
7
  InfoCircleOutlined,
9
8
  AlignLeftOutlined,
10
9
  BugOutlined,
11
- HeartOutlined
10
+ HeartOutlined,
11
+ CloudOutlined
12
12
  } from '@ant-design/icons'
13
13
  import { Tabs, Button } from 'antd'
14
14
  import Modal from '../common/modal'
@@ -136,10 +136,10 @@ export default auto(function InfoModal (props) {
136
136
  url: bugReportLink
137
137
  },
138
138
  releases: releaseLink,
139
- privacyNoticeLink,
140
139
  sponsorLink,
141
140
  knownIssuesLink
142
141
  } = packInfo
142
+ const electermOnline = 'https://cloud.electerm.org'
143
143
  const link = releaseLink.replace('/releases', '')
144
144
  const { versions } = window.pre
145
145
  const deps = {
@@ -215,9 +215,9 @@ export default auto(function InfoModal (props) {
215
215
  </Link>
216
216
  </p>
217
217
  <p className='mg1b'>
218
- <WarningOutlined /> <b className='mg1r'>{e('privacyNotice')} ➾</b>
219
- <Link to={privacyNoticeLink} className='mg1l'>
220
- {privacyNoticeLink}
218
+ <CloudOutlined /> <b className='mg1r'>electerm Online ➾</b>
219
+ <Link to={electermOnline} className='mg1l'>
220
+ {electermOnline}
221
221
  </Link>
222
222
  </p>
223
223
  <p className='mg1b'>
@@ -63,14 +63,14 @@ export default class TermSearch extends PureComponent {
63
63
  }
64
64
  }
65
65
 
66
- prev = () => {
66
+ prev = (searchTerm) => {
67
67
  const {
68
68
  activeTabId,
69
69
  termSearchOptions
70
70
  } = this.props
71
71
  refs.get('term-' + activeTabId)
72
72
  ?.searchPrev(
73
- this.props.termSearch,
73
+ searchTerm ?? this.props.termSearch,
74
74
  copy(termSearchOptions)
75
75
  )
76
76
  }
@@ -0,0 +1,133 @@
1
+ const ESC = '\x1b'
2
+ const ST = '\x1b\\'
3
+
4
+ function toHexByte (value) {
5
+ return value.toString(16).padStart(2, '0')
6
+ }
7
+
8
+ function clampByte (value) {
9
+ return Math.max(0, Math.min(255, Math.round(value)))
10
+ }
11
+
12
+ function parseChannel (value) {
13
+ const trimmed = value.trim()
14
+ if (!trimmed) {
15
+ return null
16
+ }
17
+ if (trimmed.endsWith('%')) {
18
+ const percent = Number(trimmed.slice(0, -1))
19
+ return Number.isFinite(percent)
20
+ ? clampByte(percent * 255 / 100)
21
+ : null
22
+ }
23
+ const num = Number(trimmed)
24
+ return Number.isFinite(num)
25
+ ? clampByte(num)
26
+ : null
27
+ }
28
+
29
+ function parseAlpha (value) {
30
+ if (!value) {
31
+ return 1
32
+ }
33
+ const trimmed = value.trim()
34
+ if (trimmed.endsWith('%')) {
35
+ const percent = Number(trimmed.slice(0, -1))
36
+ return Number.isFinite(percent) ? percent / 100 : null
37
+ }
38
+ const num = Number(trimmed)
39
+ return Number.isFinite(num) ? num : null
40
+ }
41
+
42
+ function parseHexColor (color) {
43
+ const match = color.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
44
+ if (!match) {
45
+ return null
46
+ }
47
+ const raw = match[1]
48
+ const expanded = raw.length <= 4
49
+ ? raw.split('').map(char => char + char).join('')
50
+ : raw
51
+ const alpha = expanded.length === 8
52
+ ? parseInt(expanded.slice(6, 8), 16) / 255
53
+ : 1
54
+ if (alpha <= 0) {
55
+ return null
56
+ }
57
+ return [
58
+ parseInt(expanded.slice(0, 2), 16),
59
+ parseInt(expanded.slice(2, 4), 16),
60
+ parseInt(expanded.slice(4, 6), 16)
61
+ ]
62
+ }
63
+
64
+ function parseRgbColor (color) {
65
+ const match = color.match(/^rgba?\((.*)\)$/i)
66
+ if (!match) {
67
+ return null
68
+ }
69
+ const body = match[1].trim()
70
+ const [channelsPart, slashAlpha] = body.split('/').map(part => part.trim())
71
+ const parts = channelsPart.includes(',')
72
+ ? channelsPart.split(',').map(part => part.trim())
73
+ : channelsPart.split(/\s+/).filter(Boolean)
74
+ if (parts.length < 3) {
75
+ return null
76
+ }
77
+ const channels = parts.slice(0, 3).map(parseChannel)
78
+ if (channels.some(channel => channel === null)) {
79
+ return null
80
+ }
81
+ const alpha = parseAlpha(slashAlpha || parts[3])
82
+ if (alpha === null || alpha <= 0) {
83
+ return null
84
+ }
85
+ return channels
86
+ }
87
+
88
+ export function parseColorToRgb (color) {
89
+ if (typeof color !== 'string') {
90
+ return null
91
+ }
92
+ const trimmed = color.trim()
93
+ if (!trimmed || trimmed.toLowerCase() === 'transparent') {
94
+ return null
95
+ }
96
+ return parseHexColor(trimmed) || parseRgbColor(trimmed)
97
+ }
98
+
99
+ export function colorToOscRgb (color) {
100
+ const rgb = parseColorToRgb(color)
101
+ if (!rgb) {
102
+ return ''
103
+ }
104
+ return `rgb:${rgb.map(toHexByte).join('/')}`
105
+ }
106
+
107
+ export function buildOscColorResponse (identifier, color, fallbackColor) {
108
+ const oscColor = colorToOscRgb(color) || colorToOscRgb(fallbackColor)
109
+ return oscColor
110
+ ? `${ESC}]${identifier};${oscColor}${ST}`
111
+ : ''
112
+ }
113
+
114
+ export function handleTerminalColorQuery (terminal, identifier, color, fallbackColor, data) {
115
+ if (typeof data !== 'string' || data.trim() !== '?') {
116
+ return false
117
+ }
118
+ const response = buildOscColorResponse(identifier, color, fallbackColor)
119
+ if (!response || typeof terminal?.input !== 'function') {
120
+ return false
121
+ }
122
+ terminal.input(response, false)
123
+ return true
124
+ }
125
+
126
+ export function createRendererThemeConfig (themeConfig = {}, rendererType, visibleBackground) {
127
+ return {
128
+ ...themeConfig,
129
+ background: rendererType === 'webGL' && colorToOscRgb(visibleBackground)
130
+ ? visibleBackground
131
+ : 'rgba(0,0,0,0)'
132
+ }
133
+ }
@@ -57,13 +57,16 @@ import {
57
57
  loadTerminal,
58
58
  loadFitAddon,
59
59
  loadWebLinksAddon,
60
- loadCanvasAddon,
61
60
  loadWebglAddon,
62
61
  loadSearchAddon,
63
62
  loadLigaturesAddon,
64
63
  loadUnicode11Addon,
65
64
  loadImageAddon
66
65
  } from './xterm-loader.js'
66
+ import {
67
+ createRendererThemeConfig,
68
+ handleTerminalColorQuery
69
+ } from './terminal-color-query.mjs'
67
70
 
68
71
  const e = window.translate
69
72
 
@@ -141,10 +144,8 @@ class Term extends Component {
141
144
  prevProps.themeConfig
142
145
  )
143
146
  if (themeChanged && this.term) {
144
- this.term.options.theme = {
145
- ...deepCopy(this.props.themeConfig),
146
- background: 'rgba(0,0,0,0)'
147
- }
147
+ this.term.options.theme = this.getRendererThemeConfig(this.props.themeConfig)
148
+ this.registerTerminalColorQueryHandlers(this.term, this.props.themeConfig)
148
149
  }
149
150
  }
150
151
 
@@ -156,6 +157,7 @@ class Term extends Component {
156
157
  if (this.term) {
157
158
  this.term.parent = null
158
159
  }
160
+ this.disposeTerminalColorQueryHandlers()
159
161
  Object.keys(this.timers).forEach(k => {
160
162
  clearTimeout(this.timers[k])
161
163
  this.timers[k] = null
@@ -451,13 +453,19 @@ class Term extends Component {
451
453
  return
452
454
  }
453
455
 
456
+ const hasMultiFiles = filePaths.length > 1
457
+
454
458
  if (isSshTerminal) {
455
459
  const behavior = this.props.config.dragDropBehavior || 'ask'
456
- if (behavior === 'ask') {
460
+ // multi-file drops always paste paths, skip the action modal
461
+ if (behavior === 'ask' && !hasMultiFiles) {
457
462
  this.setState({
458
463
  dropFileModalVisible: true,
459
464
  droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
460
465
  })
466
+ } else if (hasMultiFiles) {
467
+ const filesAll = filePaths.map(path => `"${path}"`).join(' ')
468
+ this.attachAddon._sendData(filesAll)
461
469
  } else {
462
470
  this.handleDropFileAction(behavior, filePaths.map(path => ({ path, isRemote: false })))
463
471
  }
@@ -465,10 +473,16 @@ class Term extends Component {
465
473
  }
466
474
 
467
475
  if (isSerialTerminal) {
468
- this.setState({
469
- dropFileModalVisible: true,
470
- droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
471
- })
476
+ // multi-file drops always paste paths, skip the action modal
477
+ if (hasMultiFiles) {
478
+ const filesAll = filePaths.map(path => `"${path}"`).join(' ')
479
+ this.attachAddon._sendData(filesAll)
480
+ } else {
481
+ this.setState({
482
+ dropFileModalVisible: true,
483
+ droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
484
+ })
485
+ }
472
486
  return
473
487
  }
474
488
 
@@ -606,7 +620,7 @@ class Term extends Component {
606
620
  this.term.clear()
607
621
  this.term.focus()
608
622
  if (shouldClear) {
609
- this.searchAddon._linesCache = undefined
623
+ this.searchAddon._lineCache.clear()
610
624
  this.timers.clearSearchTimer = setTimeout(() => {
611
625
  refsStatic.get('term-search')?.next()
612
626
  }, 100)
@@ -658,8 +672,8 @@ class Term extends Component {
658
672
  }
659
673
 
660
674
  updateSearchResults = (resultIndex) => {
661
- const matches = this.searchAddon._highlightDecorations.map((highlight, i) => {
662
- return highlight.match.row
675
+ const matches = this.searchAddon._resultTracker.searchResults.map((result, i) => {
676
+ return result.row
663
677
  })
664
678
 
665
679
  this.setState({
@@ -1066,26 +1080,70 @@ class Term extends Component {
1066
1080
  }
1067
1081
 
1068
1082
  loadRenderer = async (term, config) => {
1069
- if (config.rendererType === rendererTypes.canvas) {
1070
- const CanvasAddon = await loadCanvasAddon()
1071
- term.loadAddon(new CanvasAddon())
1072
- } else if (config.rendererType === rendererTypes.webGL) {
1083
+ // xterm 6.x: only the built-in DOM renderer and the WebGL addon exist
1084
+ // (the canvas renderer addon was removed in 6.x). 'dom' = no addon loaded
1085
+ // (built-in DOM renderer). Legacy 'canvas' settings fall back to DOM.
1086
+ if (config.rendererType === rendererTypes.webGL) {
1073
1087
  try {
1074
1088
  const WebglAddon = await loadWebglAddon()
1075
1089
  term.loadAddon(new WebglAddon())
1076
1090
  } catch (e) {
1077
- console.error('render with webgl failed, fallback to canvas')
1091
+ console.error('render with webgl failed, fallback to dom renderer')
1078
1092
  console.error(e)
1079
- const CanvasAddon = await loadCanvasAddon()
1080
- term.loadAddon(new CanvasAddon())
1093
+ // built-in DOM renderer is used (no addon loaded)
1081
1094
  }
1082
1095
  }
1083
1096
  }
1084
1097
 
1098
+ terminalColorQueryDisposables = []
1099
+
1100
+ disposeTerminalColorQueryHandlers = () => {
1101
+ this.terminalColorQueryDisposables.forEach(disposable => disposable?.dispose?.())
1102
+ this.terminalColorQueryDisposables.length = 0
1103
+ }
1104
+
1105
+ getVisibleTerminalBackground = () => {
1106
+ const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1107
+ const dom = this.domRef.current
1108
+ const cssMain = dom && window.getComputedStyle
1109
+ ? window.getComputedStyle(dom).getPropertyValue('--main').trim()
1110
+ : ''
1111
+ return uiThemeConfig.main || cssMain || this.props.themeConfig.background
1112
+ }
1113
+
1114
+ getVisibleTerminalForeground = () => {
1115
+ const uiThemeConfig = window.store?.getUiThemeConfig?.() || {}
1116
+ return uiThemeConfig.text
1117
+ }
1118
+
1119
+ registerTerminalColorQueryHandlers = (term, themeConfig = {}) => {
1120
+ this.disposeTerminalColorQueryHandlers()
1121
+ if (!term?.parser?.registerOscHandler) {
1122
+ return
1123
+ }
1124
+ const background = this.getVisibleTerminalBackground()
1125
+ const foregroundFallback = this.getVisibleTerminalForeground()
1126
+ this.terminalColorQueryDisposables.push(
1127
+ term.parser.registerOscHandler(10, data => {
1128
+ return handleTerminalColorQuery(term, 10, themeConfig.foreground, foregroundFallback, data)
1129
+ }),
1130
+ term.parser.registerOscHandler(11, data => {
1131
+ return handleTerminalColorQuery(term, 11, background, themeConfig.background, data)
1132
+ })
1133
+ )
1134
+ }
1135
+
1136
+ getRendererThemeConfig = (themeConfig = this.props.themeConfig) => {
1137
+ return createRendererThemeConfig(
1138
+ deepCopy(themeConfig),
1139
+ this.props.config.rendererType,
1140
+ this.getVisibleTerminalBackground()
1141
+ )
1142
+ }
1143
+
1085
1144
  initTerminal = async () => {
1086
1145
  const { themeConfig, tab = {}, config = {} } = this.props
1087
- const tc = deepCopy(themeConfig)
1088
- tc.background = 'rgba(0,0,0,0)'
1146
+ const tc = this.getRendererThemeConfig(themeConfig)
1089
1147
  const Terminal = await loadTerminal()
1090
1148
  const term = new Terminal({
1091
1149
  allowProposedApi: true,
@@ -1104,6 +1162,7 @@ class Term extends Component {
1104
1162
  term.parent = this
1105
1163
  term.onSelectionChange(this.onSelection)
1106
1164
  term.open(this.domRef.current, true)
1165
+ this.registerTerminalColorQueryHandlers(term, themeConfig)
1107
1166
  await this.loadRenderer(term, config)
1108
1167
 
1109
1168
  const FitAddon = await loadFitAddon()
@@ -1324,7 +1383,7 @@ class Term extends Component {
1324
1383
  }
1325
1384
 
1326
1385
  openNormalBuffer = () => {
1327
- const normal = this.term.buffer._normal
1386
+ const normal = this.term.buffer.normal
1328
1387
  const len = normal.length
1329
1388
  const lines = new Array(len).fill('').map((x, i) => {
1330
1389
  return normal.getLine(i).translateToString(false)
@@ -1486,13 +1545,12 @@ class Term extends Component {
1486
1545
  socket.onopen = async () => {
1487
1546
  await this.initAttachAddon()
1488
1547
  this.runInitScript()
1489
- term._initialized = true
1490
1548
  }
1491
1549
  // term.onRrefresh(this.onRefresh)
1492
1550
  term.onResize(this.onResizeTerminal)
1493
- if (pick(term, 'buffer._onBufferChange._listeners')) {
1494
- term.buffer._onBufferChange._listeners.push(this.onBufferChange)
1495
- }
1551
+ // xterm 6.x exposes buffer change as a public event (IBufferNamespace.onBufferChange).
1552
+ // Previously this reached into the private _onBufferChange._listeners array.
1553
+ term.buffer.onBufferChange(this.onBufferChange)
1496
1554
  const WebLinksAddon = await loadWebLinksAddon()
1497
1555
  term.loadAddon(new WebLinksAddon(this.webLinkHandler))
1498
1556
  term.focus()
@@ -32,6 +32,10 @@
32
32
  margin-left 5px
33
33
 
34
34
  #container
35
+ .xterm
36
+ background transparent
37
+ .xterm-viewport
38
+ background-color transparent !important
35
39
  .xterm-screen::before
36
40
  content ''
37
41
  position absolute
@@ -37,13 +37,6 @@ export async function loadWebLinksAddon () {
37
37
  return window.xtermAddons.WebLinksAddon
38
38
  }
39
39
 
40
- export async function loadCanvasAddon () {
41
- if (window.xtermAddons.CanvasAddon) return window.xtermAddons.CanvasAddon
42
- const mod = await import('@xterm/addon-canvas')
43
- window.xtermAddons.CanvasAddon = mod.CanvasAddon
44
- return window.xtermAddons.CanvasAddon
45
- }
46
-
47
40
  export async function loadWebglAddon () {
48
41
  if (window.xtermAddons.WebglAddon) return window.xtermAddons.WebglAddon
49
42
  const mod = await import('@xterm/addon-webgl')
@@ -95,10 +88,6 @@ export function getWebLinksAddon () {
95
88
  return window.xtermAddons.WebLinksAddon
96
89
  }
97
90
 
98
- export function getCanvasAddon () {
99
- return window.xtermAddons.CanvasAddon
100
- }
101
-
102
91
  export function getWebglAddon () {
103
92
  return window.xtermAddons.WebglAddon
104
93
  }
@@ -4,12 +4,38 @@ function isTopLevelGroup (group) {
4
4
  return !group?.level || group.level < 2
5
5
  }
6
6
 
7
+ function bookmarkCompare (a, b, bookmarksMap, sortTitle, sortHost) {
8
+ const itemA = bookmarksMap.get(a)
9
+ const itemB = bookmarksMap.get(b)
10
+ if (!itemA || !itemB) {
11
+ return 0
12
+ }
13
+
14
+ if (sortTitle) {
15
+ const cmp = (itemA.title || '').localeCompare(itemB.title || '')
16
+ if (cmp !== 0) {
17
+ return sortTitle === 'asc' ? cmp : -cmp
18
+ }
19
+ }
20
+
21
+ if (sortHost) {
22
+ const cmp = (itemA.host || '').localeCompare(itemB.host || '')
23
+ if (cmp !== 0) {
24
+ return sortHost === 'asc' ? cmp : -cmp
25
+ }
26
+ }
27
+
28
+ return 0
29
+ }
30
+
7
31
  export function buildVisibleTreeRows ({
8
32
  bookmarkGroups,
9
33
  bookmarkGroupTree,
10
34
  bookmarksMap,
11
35
  expandedKeys,
12
- keyword
36
+ keyword,
37
+ sortTitle,
38
+ sortHost
13
39
  }) {
14
40
  const groupTree = bookmarkGroupTree || {}
15
41
  const rows = []
@@ -77,7 +103,12 @@ export function buildVisibleTreeRows ({
77
103
  visitGroup(groupTree[groupId], nextParentId, depth + 1)
78
104
  }
79
105
 
80
- for (const bookmarkId of group.bookmarkIds || []) {
106
+ const bookmarkIds = group.bookmarkIds || []
107
+ const sortedIds = (sortTitle || sortHost)
108
+ ? [...bookmarkIds].sort((a, b) => bookmarkCompare(a, b, bookmarksMap, sortTitle, sortHost))
109
+ : bookmarkIds
110
+
111
+ for (const bookmarkId of sortedIds) {
81
112
  const item = bookmarksMap.get(bookmarkId)
82
113
  if (!item || (lowerKeyword && !bookmarkMatches(bookmarkId))) {
83
114
  continue
@@ -44,7 +44,8 @@ export default class ItemListTree extends Component {
44
44
  categoryTitle: '',
45
45
  categoryColor: '',
46
46
  categoryId: '',
47
- searchSelectedRowKey: ''
47
+ searchSelectedRowKey: '',
48
+ sort: null
48
49
  }
49
50
  this.listRef = React.createRef()
50
51
  }
@@ -109,6 +110,10 @@ export default class ItemListTree extends Component {
109
110
  })
110
111
  }
111
112
 
113
+ handleSortChange = sort => {
114
+ this.setState({ sort })
115
+ }
116
+
112
117
  handleKeyDown = (e) => {
113
118
  const { keyword } = this.state
114
119
  if (!keyword) return
@@ -176,12 +181,15 @@ export default class ItemListTree extends Component {
176
181
  }
177
182
 
178
183
  getVisibleTreeData = () => {
184
+ const sort = this.state.sort
179
185
  return buildVisibleTreeRows({
180
186
  bookmarkGroups: this.props.bookmarkGroups,
181
187
  bookmarkGroupTree: this.props.bookmarkGroupTree,
182
188
  bookmarksMap: this.props.bookmarksMap,
183
189
  expandedKeys: this.props.expandedKeys,
184
- keyword: this.state.keyword
190
+ keyword: this.state.keyword,
191
+ sortTitle: sort?.field === 'title' ? sort.dir : null,
192
+ sortHost: sort?.field === 'host' ? sort.dir : null
185
193
  })
186
194
  }
187
195
 
@@ -414,6 +422,8 @@ export default class ItemListTree extends Component {
414
422
  keyword={this.state.keyword}
415
423
  autoFocus={this.props.autoFocus}
416
424
  onKeyDown={this.handleKeyDown}
425
+ sort={this.state.sort}
426
+ onSortChange={this.handleSortChange}
417
427
  />
418
428
  </div>
419
429
  )
@@ -101,6 +101,42 @@
101
101
  min-height 0
102
102
  position relative
103
103
 
104
+ .tree-sort-wrap
105
+ position relative
106
+ .tree-sort-dropdown
107
+ display none
108
+ position absolute
109
+ right 0
110
+ top 100%
111
+ margin-top 4px
112
+ background var(--main-dark)
113
+ border 1px solid var(--main)
114
+ border-radius 4px
115
+ padding 4px
116
+ z-index 100
117
+ box-shadow 0 2px 8px rgba(0,0,0,.1)
118
+ &.open
119
+ .tree-sort-dropdown
120
+ display block
121
+
122
+ .tree-sort-trigger
123
+ cursor pointer
124
+ &:hover
125
+ color var(--primary)
126
+ &.active
127
+ color var(--primary)
128
+
129
+ .tree-sort-popover-item
130
+ padding 4px 8px
131
+ border-radius 3px
132
+ cursor pointer
133
+ white-space nowrap
134
+ font-size 12px
135
+ &:hover
136
+ background var(--hover-bg, rgba(0,0,0,.04))
137
+ &.active
138
+ color var(--primary)
139
+
104
140
  .tree-list-virtual-spacer
105
141
  position relative
106
142
  width 100%
@@ -1,10 +1,33 @@
1
- import React, { useState, memo } from 'react'
1
+ import React, { useState, memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { debounce } from 'lodash-es'
3
+ import { Space, Button } from 'antd'
4
+ import { SortAscendingOutlined, CaretUpOutlined, CaretDownOutlined } from '@ant-design/icons'
3
5
  import Search from '../common/search'
4
6
  import runIdle from '../../common/run-idle'
5
7
 
6
- export default memo(function TreeSearchComponent ({ onSearch, keyword, autoFocus, onKeyDown }) {
8
+ function cycleSort (currentField, currentDir, clickedField) {
9
+ if (currentField === clickedField) {
10
+ if (currentDir === 'asc') {
11
+ return { field: clickedField, dir: 'desc' }
12
+ }
13
+ if (currentDir === 'desc') {
14
+ return null
15
+ }
16
+ }
17
+ return { field: clickedField, dir: 'asc' }
18
+ }
19
+
20
+ export default memo(function TreeSearchComponent ({
21
+ onSearch,
22
+ keyword,
23
+ autoFocus,
24
+ onKeyDown,
25
+ sort,
26
+ onSortChange
27
+ }) {
7
28
  const [searchTerm, setSearchTerm] = useState(keyword)
29
+ const [open, setOpen] = useState(false)
30
+ const wrapRef = useRef(null)
8
31
 
9
32
  const performSearch = debounce((term) => {
10
33
  runIdle(() => {
@@ -24,13 +47,68 @@ export default memo(function TreeSearchComponent ({ onSearch, keyword, autoFocus
24
47
  }
25
48
  }
26
49
 
50
+ const handleSortClick = useCallback((field) => {
51
+ const next = cycleSort(sort?.field, sort?.dir, field)
52
+ onSortChange(next)
53
+ }, [sort, onSortChange])
54
+
55
+ const handleTriggerClick = () => {
56
+ setOpen(v => !v)
57
+ }
58
+
59
+ const handleItemClick = (field) => {
60
+ handleSortClick(field)
61
+ }
62
+
63
+ const isSortActive = !!sort?.field
64
+
65
+ useEffect(() => {
66
+ if (!open) return
67
+ const onMouseDown = (e) => {
68
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
69
+ setOpen(false)
70
+ }
71
+ }
72
+ document.addEventListener('mousedown', onMouseDown)
73
+ return () => document.removeEventListener('mousedown', onMouseDown)
74
+ }, [open])
75
+
27
76
  return (
28
- <Search
29
- onChange={handleChange}
30
- onKeyDown={handleKeyDown}
31
- value={searchTerm}
32
- allowClear
33
- autoFocus={autoFocus}
34
- />
77
+ <div className={`tree-sort-wrap iblock${open ? ' open' : ''}`} ref={wrapRef}>
78
+ <Space.Compact className='width-100'>
79
+ <Search
80
+ onChange={handleChange}
81
+ onKeyDown={handleKeyDown}
82
+ value={searchTerm}
83
+ allowClear
84
+ autoFocus={autoFocus}
85
+ />
86
+ <Button
87
+ className={`tree-sort-trigger pointer${isSortActive ? ' active' : ''}`}
88
+ onClick={handleTriggerClick}
89
+ icon={<SortAscendingOutlined />}
90
+ />
91
+ </Space.Compact>
92
+ <div className='tree-sort-dropdown'>
93
+ {['title', 'host'].map(field => {
94
+ const isActive = sort?.field === field
95
+ const isAsc = isActive && sort?.dir === 'asc'
96
+ const isDesc = isActive && sort?.dir === 'desc'
97
+ const cap = window.translate(field)
98
+ return (
99
+ <div
100
+ key={field}
101
+ className={`tree-sort-popover-item${isActive ? ' active' : ''}`}
102
+ onClick={() => handleItemClick(field)}
103
+ >
104
+ {isDesc
105
+ ? <CaretDownOutlined />
106
+ : <CaretUpOutlined className={isAsc ? '' : 'hidden'} />}
107
+ {cap}
108
+ </div>
109
+ )
110
+ })}
111
+ </div>
112
+ </div>
35
113
  )
36
114
  })
@@ -367,7 +367,8 @@ export default class VncSession extends PureComponent {
367
367
  'desktopname',
368
368
  'capabilities',
369
369
  'screens',
370
- 'desktopsize'
370
+ 'desktopsize',
371
+ 'serververification'
371
372
  ]
372
373
  for (const event of events) {
373
374
  rfb.addEventListener(event, this[`on${window.capitalizeFirstLetter(event)}`])
@@ -400,6 +401,14 @@ export default class VncSession extends PureComponent {
400
401
  message.error('Security Failure: ' + event.detail?.reason)
401
402
  }
402
403
 
404
+ onSerververification = (event) => {
405
+ // RA2ne (RealVNC) authentication requires the client to approve
406
+ // the server's public key before the handshake can proceed.
407
+ // Auto-approve since the user explicitly initiated the connection.
408
+ console.debug('[VNC-CLIENT] Server verification event, approving server:', event.detail?.type)
409
+ this.rfb?.approveServer()
410
+ }
411
+
403
412
  onOk = (res) => {
404
413
  this.setState({
405
414
  showConfirm: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.90",
3
+ "version": "3.15.100",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",