@electerm/electerm-react 2.13.6 → 2.15.8

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.
@@ -2,16 +2,14 @@
2
2
  * ui theme
3
3
  */
4
4
 
5
- import { useEffect } from 'react'
6
- import { useDelta, useConditionalEffect } from 'react-delta-hooks'
5
+ import { useEffect, useRef } from 'react'
7
6
  import eq from 'fast-deep-equal'
8
7
 
9
8
  const themeDomId = 'custom-css'
10
9
 
11
10
  export default function CustomCss (props) {
12
11
  const { customCss } = props
13
-
14
- const delta = useDelta(customCss)
12
+ const prevRef = useRef(null)
15
13
 
16
14
  async function applyTheme () {
17
15
  const style = document.getElementById(themeDomId)
@@ -21,8 +19,13 @@ export default function CustomCss (props) {
21
19
  useEffect(() => {
22
20
  applyTheme()
23
21
  }, [])
24
- useConditionalEffect(() => {
25
- applyTheme()
26
- }, delta && !eq(delta.prev, delta.curr))
22
+
23
+ useEffect(() => {
24
+ if (prevRef.current && !eq(prevRef.current, customCss)) {
25
+ applyTheme()
26
+ }
27
+ prevRef.current = customCss
28
+ }, [customCss])
29
+
27
30
  return null
28
31
  }
@@ -59,7 +59,7 @@ export function NotificationContainer () {
59
59
  {nots.map(notif => (
60
60
  <NotificationItem
61
61
  key={notif.key}
62
- message={notif.message}
62
+ message={notif.message || notif.type}
63
63
  description={notif.description}
64
64
  type={notif.type}
65
65
  duration={notif.duration}
@@ -1,5 +1,4 @@
1
- import { useEffect } from 'react'
2
- import { useDelta, useConditionalEffect } from 'react-delta-hooks'
1
+ import { useEffect, useRef } from 'react'
3
2
  import eq from 'fast-deep-equal'
4
3
 
5
4
  const opacityDomId = 'opacity-style'
@@ -14,7 +13,7 @@ const opacityDomId = 'opacity-style'
14
13
  export default function Opacity ({ opacity }) {
15
14
  // Default to 1 if opacity is not provided
16
15
  const currentOpacity = opacity !== undefined ? opacity : 1
17
- const delta = useDelta(currentOpacity)
16
+ const prevRef = useRef(null)
18
17
 
19
18
  function applyOpacity () {
20
19
  let styleElement = document.getElementById(opacityDomId)
@@ -58,9 +57,12 @@ export default function Opacity ({ opacity }) {
58
57
  }
59
58
  }, [])
60
59
 
61
- useConditionalEffect(() => {
62
- applyOpacity()
63
- }, delta && !eq(delta.prev, delta.curr))
60
+ useEffect(() => {
61
+ if (prevRef.current && !eq(prevRef.current, currentOpacity)) {
62
+ applyOpacity()
63
+ }
64
+ prevRef.current = currentOpacity
65
+ }, [currentOpacity])
64
66
 
65
67
  return null
66
68
  }
@@ -2,8 +2,7 @@
2
2
  * ui theme
3
3
  */
4
4
 
5
- import { useEffect } from 'react'
6
- import { useDelta, useConditionalEffect } from 'react-delta-hooks'
5
+ import { useEffect, useRef } from 'react'
7
6
  import eq from 'fast-deep-equal'
8
7
  import isColorDark from '../../common/is-color-dark'
9
8
 
@@ -53,7 +52,7 @@ function buildTheme (themeConfig) {
53
52
  export default function UiTheme (props) {
54
53
  const { themeConfig } = props
55
54
 
56
- const delta = useDelta(themeConfig)
55
+ const prevRef = useRef(null)
57
56
 
58
57
  async function applyTheme () {
59
58
  const style = document.getElementById(themeDomId)
@@ -64,8 +63,13 @@ export default function UiTheme (props) {
64
63
  useEffect(() => {
65
64
  applyTheme()
66
65
  }, [])
67
- useConditionalEffect(() => {
68
- applyTheme()
69
- }, delta && delta.prev && !eq(delta.prev, delta.curr))
66
+
67
+ useEffect(() => {
68
+ if (prevRef.current && !eq(prevRef.current, themeConfig)) {
69
+ applyTheme()
70
+ }
71
+ prevRef.current = themeConfig
72
+ }, [themeConfig])
73
+
70
74
  return null
71
75
  }
@@ -28,7 +28,7 @@ export default class ProfileList extends List {
28
28
  const { activeItemId } = this.props
29
29
  const { name, id } = item
30
30
  const cls = classnames(
31
- 'item-list-unit theme-item',
31
+ 'item-list-unit',
32
32
  {
33
33
  active: activeItemId === id
34
34
  }
@@ -63,7 +63,7 @@ export default class QuickCommandsList extends List {
63
63
  const { activeItemId } = this.props
64
64
  const { name, id } = item
65
65
  const cls = classnames(
66
- 'item-list-unit theme-item',
66
+ 'item-list-unit',
67
67
  {
68
68
  active: activeItemId === id
69
69
  }
@@ -36,6 +36,13 @@
36
36
  .list-item-remove
37
37
  .list-item-bookmark
38
38
  display block
39
+ .theme-item:hover
40
+ .list-item-remove
41
+ right 24px
42
+ .setting-tabs-setting
43
+ .item-list-unit
44
+ .list-item-remove
45
+ display none
39
46
  // .item-list
40
47
  // .list-item-edit
41
48
  // .list-item-apply
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * bookmark form
7
7
  */
8
- import { useDelta, useConditionalEffect } from 'react-delta-hooks'
8
+ import { useEffect, useRef } from 'react'
9
9
  import { ArrowDownOutlined, ArrowUpOutlined, SaveOutlined, ClearOutlined } from '@ant-design/icons'
10
10
  import { Button, Input, Form, Alert } from 'antd'
11
11
  import { notification } from '../common/notification'
@@ -27,10 +27,15 @@ function trim (str) {
27
27
 
28
28
  export default function SyncForm (props) {
29
29
  const [form] = Form.useForm()
30
- const delta = useDelta(props.formData)
31
- useConditionalEffect(() => {
32
- form.resetFields()
33
- }, delta && delta.prev && !eq(delta.prev, delta.curr))
30
+ const prevRef = useRef(null)
31
+
32
+ useEffect(() => {
33
+ if (prevRef.current && !eq(prevRef.current, props.formData)) {
34
+ form.resetFields()
35
+ }
36
+ prevRef.current = props.formData
37
+ }, [props.formData])
38
+
34
39
  const { syncType } = props
35
40
  function disabled () {
36
41
  if (syncType === syncTypes.cloud) {
@@ -0,0 +1,99 @@
1
+ import { useState } from 'react'
2
+ import {
3
+ Input
4
+ } from 'antd'
5
+ import {
6
+ EditOutlined,
7
+ DeleteOutlined,
8
+ CheckOutlined,
9
+ CloseOutlined
10
+ } from '@ant-design/icons'
11
+
12
+ const { TextArea } = Input
13
+
14
+ export default function LoadSshConfigsItem (props) {
15
+ const { item, index, onDelete, onUpdate } = props
16
+ const [isEditing, setIsEditing] = useState(false)
17
+ const [editValue, setEditValue] = useState(JSON.stringify(item, null, 2))
18
+
19
+ const handleToggleEdit = function () {
20
+ if (isEditing) {
21
+ try {
22
+ const parsed = JSON.parse(editValue)
23
+ onUpdate(index, parsed)
24
+ } catch (err) {
25
+ console.error('Invalid JSON:', err)
26
+ setEditValue(JSON.stringify(item, null, 2))
27
+ }
28
+ } else {
29
+ setEditValue(JSON.stringify(item, null, 2))
30
+ }
31
+ setIsEditing(!isEditing)
32
+ }
33
+
34
+ const handleDelete = function () {
35
+ onDelete(index)
36
+ }
37
+
38
+ const handleCancelEdit = function () {
39
+ setEditValue(JSON.stringify(item, null, 2))
40
+ setIsEditing(false)
41
+ }
42
+
43
+ function renderActions () {
44
+ if (isEditing) {
45
+ return [
46
+ <CheckOutlined
47
+ className='mg1r pointer icon-success'
48
+ onClick={handleToggleEdit}
49
+ key='confirm-ssh-config-item'
50
+ />,
51
+ <CloseOutlined
52
+ className='mg1r pointer icon-warning'
53
+ onClick={handleCancelEdit}
54
+ key='cancel-ssh-config-item'
55
+ />
56
+ ]
57
+ }
58
+ return [
59
+ <EditOutlined
60
+ className='mg1r pointer ssh-config-item-edit-icon'
61
+ onClick={handleToggleEdit}
62
+ key='edit-ssh-config-item'
63
+ />,
64
+ <DeleteOutlined
65
+ className='pointer icon-danger ssh-config-item-delete-icon'
66
+ onClick={handleDelete}
67
+ key='del-ssh-config-item'
68
+ />
69
+ ]
70
+ }
71
+
72
+ function renderContent () {
73
+ if (isEditing) {
74
+ return (
75
+ <TextArea
76
+ value={editValue}
77
+ onChange={(e) => setEditValue(e.target.value)}
78
+ rows={10}
79
+ className='mg1t'
80
+ />
81
+ )
82
+ }
83
+ return (
84
+ <pre className='ssh-config-item-content'>
85
+ {JSON.stringify(item, null, 2)}
86
+ </pre>
87
+ )
88
+ }
89
+
90
+ return (
91
+ <div className='ssh-config-item pd1'>
92
+ <div className='pd1b ssh-config-item-header'>
93
+ <b className='mg1r'>[{index + 1}]</b>
94
+ {renderActions()}
95
+ </div>
96
+ {renderContent()}
97
+ </div>
98
+ )
99
+ }
@@ -5,18 +5,20 @@ import {
5
5
  Empty
6
6
  } from 'antd'
7
7
  import { useState, useEffect } from 'react'
8
- import SshConfigItem from './ssh-config-item'
9
8
  import * as ls from '../../common/safe-local-storage'
10
9
  import {
11
10
  sshConfigLoadKey
12
11
  } from '../../common/constants'
13
12
  import { ReloadOutlined } from '@ant-design/icons'
13
+ import LoadSshConfigsItem from './load-ssh-configs-item'
14
+ import './ssh-config.styl'
14
15
 
15
16
  const e = window.translate
16
17
 
17
18
  export default function LoadSshConfigs (props) {
18
19
  const [loading, setLoading] = useState(false)
19
20
  const { sshConfigs } = props
21
+ const [localConfigs, setLocalConfigs] = useState([])
20
22
 
21
23
  const {
22
24
  store
@@ -24,6 +26,11 @@ export default function LoadSshConfigs (props) {
24
26
  const {
25
27
  showSshConfigModal
26
28
  } = props
29
+
30
+ useEffect(() => {
31
+ setLocalConfigs(sshConfigs)
32
+ }, [sshConfigs])
33
+
27
34
  const handleCancel = function () {
28
35
  store.showSshConfigModal = false
29
36
  }
@@ -35,21 +42,43 @@ export default function LoadSshConfigs (props) {
35
42
 
36
43
  const handleLoadSshConfig = function () {
37
44
  store.showSshConfigModal = false
38
- store.addSshConfigs(sshConfigs)
45
+ store.addSshConfigs(localConfigs)
39
46
  ls.setItem(sshConfigLoadKey, 'yes')
40
47
  }
41
48
 
49
+ const handleDeleteItem = function (index) {
50
+ const newConfigs = [...localConfigs]
51
+ newConfigs.splice(index, 1)
52
+ setLocalConfigs(newConfigs)
53
+ }
54
+
55
+ const handleUpdateItem = function (index, newItem) {
56
+ const newConfigs = [...localConfigs]
57
+ newConfigs[index] = newItem
58
+ setLocalConfigs(newConfigs)
59
+ }
60
+
42
61
  const renderList = function () {
43
- if (!sshConfigs.length) {
62
+ if (!localConfigs.length) {
44
63
  return (
45
64
  <Empty />
46
65
  )
47
66
  }
48
- return sshConfigs.map((d, i) => {
49
- return (
50
- <SshConfigItem item={d} key={d.title} />
51
- )
52
- })
67
+ return (
68
+ <div className='pd1b ssh-config-list'>
69
+ {
70
+ localConfigs.map((item, index) => (
71
+ <LoadSshConfigsItem
72
+ key={index}
73
+ item={item}
74
+ index={index}
75
+ onDelete={handleDeleteItem}
76
+ onUpdate={handleUpdateItem}
77
+ />
78
+ ))
79
+ }
80
+ </div>
81
+ )
53
82
  }
54
83
 
55
84
  useEffect(() => {
@@ -89,7 +118,7 @@ export default function LoadSshConfigs (props) {
89
118
  type='primary'
90
119
  className='mg1r mg1b'
91
120
  onClick={handleLoadSshConfig}
92
- disabled={!sshConfigs.length || loading}
121
+ disabled={!localConfigs.length || loading}
93
122
  >
94
123
  {e('import')}
95
124
  </Button>
@@ -0,0 +1,3 @@
1
+ .ssh-config-list
2
+ max-height 60vh
3
+ overflow auto
@@ -107,6 +107,34 @@ export class TransferClientBase {
107
107
  this.writeToTerminal(`\x1b[32m\x1b[1m${this.getProtocolDisplayName()}::${type}::START\x1b[0m\r\n`)
108
108
  }
109
109
 
110
+ /**
111
+ * Write progress bar to terminal
112
+ * @param {Object} options - Progress options
113
+ * @param {string} options.name - File name
114
+ * @param {number} options.size - Total size in bytes
115
+ * @param {number} options.transferred - Transferred bytes
116
+ * @param {number} options.speed - Transfer speed in bytes/s
117
+ * @param {boolean} options.isComplete - Whether transfer is complete
118
+ * @param {Function} options.formatSize - Function to format size
119
+ * @returns {string} The progress string written to terminal
120
+ */
121
+ writeProgressBar ({ name, size, transferred, speed, isComplete = false, formatSize = (b) => b }) {
122
+ const percent = size > 0 ? Math.floor(transferred * 100 / size) : 100
123
+ const barWidth = 30
124
+ const filledWidth = Math.floor(percent / 100 * barWidth)
125
+ const emptyWidth = barWidth - filledWidth
126
+
127
+ const bar = '\x1b[32m' + '\u2588'.repeat(filledWidth) + '\x1b[90m' + '\u2591'.repeat(emptyWidth) + '\x1b[0m'
128
+
129
+ const sizeStr = `${formatSize(transferred)}/${formatSize(size)}`
130
+ const speedStr = speed > 0 ? `, ${formatSize(speed)}/s` : ''
131
+ const doneStr = isComplete ? ' \x1b[32m\x1b[1m[DONE]\x1b[0m' : ''
132
+
133
+ const str = `\r\x1b[2K\x1b[32m${name}\x1b[0m: ${percent}% ${bar} ${sizeStr}${speedStr}${doneStr}`
134
+ this.writeToTerminal(str + '\r')
135
+ return str
136
+ }
137
+
110
138
  /**
111
139
  * Get protocol display name
112
140
  * Should be overridden by subclass
@@ -230,20 +230,18 @@ export class TrzszClient extends TransferClientBase {
230
230
  if (!this.currentTransfer || !this.terminal?.term) return
231
231
 
232
232
  const { name, size, transferred, path, serverSpeed } = this.currentTransfer
233
- const percent = size > 0 ? Math.floor(transferred * 100 / size) : 100
234
-
235
- // Use server's speed if available, otherwise calculate locally
236
233
  const speed = serverSpeed || 0
237
-
238
- // Use full path if available, otherwise just name
239
234
  const displayName = path || name
240
-
241
- // filesize expects bytes and formats to human readable
242
235
  const formatSize = (bytes) => filesize(bytes)
243
236
 
244
- // Clear line and write progress
245
- const str = `\r\x1b[2K\x1b[32m${displayName}\x1b[0m: ${percent}%, ${formatSize(transferred)}/${formatSize(size)}, ${formatSize(speed)}/s${isComplete ? ' \x1b[32m\x1b[1m[DONE]\x1b[0m' : ''}`
246
- this.writeToTerminal(str + '\r')
237
+ this.writeProgressBar({
238
+ name: displayName,
239
+ size,
240
+ transferred,
241
+ speed,
242
+ isComplete,
243
+ formatSize
244
+ })
247
245
  }
248
246
 
249
247
  /**
@@ -212,20 +212,18 @@ export class ZmodemClient extends TransferClientBase {
212
212
  if (!this.currentTransfer || !this.terminal?.term) return
213
213
 
214
214
  const { name, size, transferred, path, serverSpeed } = this.currentTransfer
215
- const percent = size > 0 ? Math.floor(transferred * 100 / size) : 100
216
-
217
- // Use server's speed if available, otherwise calculate locally
218
215
  const speed = serverSpeed || 0
219
-
220
- // Use full path if available, otherwise just name
221
216
  const displayName = path || name
222
-
223
- // filesize expects bytes and formats to human readable
224
217
  const formatSize = (bytes) => filesize(bytes)
225
218
 
226
- // Clear line and write progress
227
- const str = `\r\x1b[2K\x1b[32m${displayName}\x1b[0m: ${percent}%, ${formatSize(transferred)}/${formatSize(size)}, ${formatSize(speed)}/s${isComplete ? ' \x1b[32m\x1b[1m[DONE]\x1b[0m' : ''}`
228
- this.writeToTerminal(str + '\r')
219
+ this.writeProgressBar({
220
+ name: displayName,
221
+ size,
222
+ transferred,
223
+ speed,
224
+ isComplete,
225
+ formatSize
226
+ })
229
227
  }
230
228
  }
231
229
 
@@ -18,6 +18,11 @@ export default function SimpleEditor (props) {
18
18
 
19
19
  // When currentMatch changes, highlight the match in textarea
20
20
  useEffect(() => {
21
+ // Only process navigation when explicitly triggered (not when text changes)
22
+ if (!isNavigating) {
23
+ return
24
+ }
25
+
21
26
  if (currentMatch >= 0 && occurrences.length > 0) {
22
27
  const match = occurrences[currentMatch]
23
28
  if (editorRef.current) {
@@ -26,10 +31,8 @@ export default function SimpleEditor (props) {
26
31
  // Set selection range to select the matched text
27
32
  textarea.setSelectionRange(match.start, match.end)
28
33
 
29
- // Only focus the textarea when explicitly navigating between matches
30
- if (isNavigating) {
31
- textarea.focus()
32
- }
34
+ // Focus the textarea when explicitly navigating between matches
35
+ textarea.focus()
33
36
 
34
37
  // Scroll to the selection position
35
38
  // Use setTimeout to ensure the selection is rendered before scrolling
@@ -53,10 +56,17 @@ export default function SimpleEditor (props) {
53
56
  setIsNavigating(false)
54
57
  }, [currentMatch, occurrences])
55
58
 
56
- // Auto-search when keyword changes
59
+ // Auto-search when keyword changes (but not when text is being edited)
57
60
  useEffect(() => {
61
+ // Set navigating to true so first match is highlighted when searching
62
+ setIsNavigating(true)
58
63
  findMatches()
59
- }, [searchKeyword, props.value])
64
+ }, [searchKeyword])
65
+
66
+ // Update matches when text changes, but don't change currentMatch position
67
+ useEffect(() => {
68
+ updateMatchesOnly()
69
+ }, [props.value])
60
70
 
61
71
  // Copy the editor content to clipboard
62
72
  const copyEditorContent = () => {
@@ -87,6 +97,28 @@ export default function SimpleEditor (props) {
87
97
  setCurrentMatch(matches.length ? 0 : -1)
88
98
  }
89
99
 
100
+ // Update matches only (without changing currentMatch position)
101
+ const updateMatchesOnly = () => {
102
+ if (!searchKeyword) {
103
+ setOccurrences([])
104
+ return
105
+ }
106
+
107
+ const matches = []
108
+ const text = props.value || ''
109
+ const escapedKeyword = escapeRegExp(searchKeyword)
110
+ const regex = new RegExp(escapedKeyword, 'gi')
111
+ let match
112
+
113
+ while ((match = regex.exec(text)) !== null) {
114
+ matches.push({
115
+ start: match.index,
116
+ end: match.index + searchKeyword.length
117
+ })
118
+ }
119
+ setOccurrences(matches)
120
+ }
121
+
90
122
  // Handle search action when user presses enter or clicks the search button
91
123
  const handleSearch = (e) => {
92
124
  if (e && e.stopPropagation) {
@@ -33,18 +33,10 @@ export default Store => {
33
33
  return {
34
34
  term: 'xterm-256color',
35
35
  id: uid(),
36
- type: 'local',
37
- title: 'ssh config: ' + t.title,
36
+ type: 'ssh',
38
37
  color: '#0088cc',
39
- runScripts: [
40
- {
41
- script: `ssh ${t.title}`,
42
- delay: 500
43
- }
44
- ]
38
+ ...t
45
39
  }
46
- }).filter(d => {
47
- return !store.bookmarks.find(t => t.title === d.title)
48
40
  })
49
41
  const ids = bookmarksToAdd.map(d => d.id)
50
42
  let sshConfigGroup = store.bookmarkGroups.find(d => d.id === 'sshConfig')
@@ -61,7 +53,7 @@ export default Store => {
61
53
  ...ids,
62
54
  ...(sshConfigGroup.bookmarkIds || [])
63
55
  ]
64
- })
56
+ }, 'bookmarkGroups')
65
57
  }
66
58
  return store.addItems(bookmarksToAdd, 'bookmarks')
67
59
  }
@@ -35,9 +35,8 @@ async function fetchData (type, func, args, token, proxy) {
35
35
  }
36
36
 
37
37
  function updateSyncServerStatusFromGist (store, gist, type) {
38
- const status = parseJsonSafe(
39
- get(gist, 'files["electerm-status.json"].content')
40
- )
38
+ const statusContent = get(gist, 'files["electerm-status.json"].content')
39
+ const status = statusContent ? parseJsonSafe(statusContent) : undefined
41
40
  store.syncServerStatus[type] = status
42
41
  }
43
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "2.13.6",
3
+ "version": "2.15.8",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",
@@ -1,24 +0,0 @@
1
- import { Tooltip } from 'antd'
2
-
3
- export default function SshConfigItem (props) {
4
- const { item } = props
5
-
6
- const generateTooltipContent = (item) => {
7
- return Object.entries(item)
8
- .filter(([key]) => key !== 'id')
9
- .map(([key, value]) => (
10
- <div key={key}>
11
- <b className='mg1r'>{key}:</b>
12
- <span>{value}</span>
13
- </div>
14
- ))
15
- }
16
-
17
- return (
18
- <Tooltip title={generateTooltipContent(item)}>
19
- <div className='elli pd1y pd2x'>
20
- ssh {item.title}
21
- </div>
22
- </Tooltip>
23
- )
24
- }