@electerm/electerm-react 2.17.16 → 3.0.6

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,6 +2,8 @@
2
2
  // keep limit items in cache
3
3
  // we persist cache to local storage, so we can keep cache after restart
4
4
 
5
+ import { safeGetItem, safeSetItem } from './safe-local-storage.js'
6
+
5
7
  class MapCache {
6
8
  constructor (limit, key) {
7
9
  this.limit = limit
@@ -11,7 +13,7 @@ class MapCache {
11
13
  }
12
14
 
13
15
  load () {
14
- const data = window.localStorage.getItem(this.key)
16
+ const data = safeGetItem(this.key)
15
17
  if (data) {
16
18
  const arr = JSON.parse(data)
17
19
  for (const item of arr) {
@@ -28,7 +30,7 @@ class MapCache {
28
30
  value
29
31
  })
30
32
  }
31
- window.localStorage.setItem(this.key, JSON.stringify(arr))
33
+ safeSetItem(this.key, JSON.stringify(arr))
32
34
  }
33
35
 
34
36
  set (key, value) {
@@ -9,7 +9,7 @@ import { without, isArray } from 'lodash-es'
9
9
  import handleError from './error-handler'
10
10
  import generate from './uid'
11
11
  import safeParse from './to-simple-obj'
12
- import { encObj, decObj } from './pass-enc'
12
+ import { decObj } from './pass-enc'
13
13
 
14
14
  /**
15
15
  * db action, never direct use it
@@ -46,7 +46,7 @@ export function insert (dbName, inst) {
46
46
  const { id, _id, ...rest } = obj
47
47
  return {
48
48
  _id: _id || id || generate(),
49
- ...encObj(rest)
49
+ ...rest
50
50
  }
51
51
  })
52
52
  return dbAction(dbName, 'insert', safeParse(arr))
@@ -76,9 +76,7 @@ export async function remove (dbName, id) {
76
76
  export function update (_id, value, db = 'data', upsert = true) {
77
77
  const updates = dbNames.includes(db)
78
78
  ? {
79
- $set: {
80
- ...encObj(value)
81
- }
79
+ $set: value
82
80
  }
83
81
  : {
84
82
  $set: {
@@ -29,7 +29,6 @@ export default {
29
29
  terminalBackgroundTextColor: '#ffffff',
30
30
  terminalBackgroundTextFontFamily: 'Maple Mono',
31
31
  rendererType: 'canvas',
32
- enableSixel: true,
33
32
  terminalType: 'xterm-256color',
34
33
  keepaliveCountMax: 10,
35
34
  keyword2FA: 'verification code,otp,one-time,two-factor,2fa,totp,authenticator,duo,yubikey,security code,mfa,passcode',
@@ -1,12 +1,3 @@
1
- const enc = (str) => {
2
- if (typeof str !== 'string') {
3
- return str
4
- }
5
- return str.split('').map((s, i) => {
6
- return String.fromCharCode((s.charCodeAt(0) + i + 1) % 65536)
7
- }).join('')
8
- }
9
-
10
1
  const dec = (str) => {
11
2
  if (typeof str !== 'string') {
12
3
  return str
@@ -16,18 +7,6 @@ const dec = (str) => {
16
7
  }).join('')
17
8
  }
18
9
 
19
- /**
20
- * enc password
21
- * @param {object} obj
22
- */
23
- export function encObj (obj) {
24
- if (!obj.passwordEncrypted && obj.password) {
25
- obj.password = enc(obj.password)
26
- obj.passwordEncrypted = true
27
- }
28
- return obj
29
- }
30
-
31
10
  /**
32
11
  * dec password
33
12
  * @param {object} obj
@@ -1,4 +1,60 @@
1
1
  import { termLSPrefix } from './constants'
2
+ import parseJsonSafe from './parse-json-safe'
3
+
4
+ // ─── Encryption helpers ───────────────────────────────────────────────────────
5
+
6
+ // Prefix that marks a value encrypted by this module.
7
+ // Values without this prefix are treated as legacy plaintext.
8
+ const ENC_PREFIX = 'enc1:'
9
+
10
+ let _encKey = null
11
+
12
+ function getKey () {
13
+ if (_encKey !== null) return _encKey
14
+ try {
15
+ // window.pre is set by pre.js before the store is used; runSync is synchronous IPC
16
+ _encKey = (window.pre && window.pre.runSync && window.pre.runSync('getStorageKey')) || ''
17
+ } catch (e) {
18
+ _encKey = ''
19
+ }
20
+ return _encKey
21
+ }
22
+
23
+ function encrypt (str) {
24
+ if (!str) return str
25
+ const key = getKey()
26
+ if (!key) return str
27
+ const strBytes = new TextEncoder().encode(str)
28
+ const keyBytes = new TextEncoder().encode(key)
29
+ const out = new Uint8Array(strBytes.length)
30
+ for (let i = 0; i < strBytes.length; i++) {
31
+ out[i] = strBytes[i] ^ keyBytes[i % keyBytes.length]
32
+ }
33
+ let binary = ''
34
+ for (let i = 0; i < out.length; i++) {
35
+ binary += String.fromCharCode(out[i])
36
+ }
37
+ return ENC_PREFIX + btoa(binary)
38
+ }
39
+
40
+ function decrypt (str) {
41
+ if (!str || !str.startsWith(ENC_PREFIX)) return str
42
+ const key = getKey()
43
+ if (!key) return str
44
+ try {
45
+ const binary = atob(str.slice(ENC_PREFIX.length))
46
+ const keyBytes = new TextEncoder().encode(key)
47
+ const out = new Uint8Array(binary.length)
48
+ for (let i = 0; i < binary.length; i++) {
49
+ out[i] = binary.charCodeAt(i) ^ keyBytes[i % keyBytes.length]
50
+ }
51
+ return new TextDecoder().decode(out)
52
+ } catch (e) {
53
+ return str
54
+ }
55
+ }
56
+
57
+ // ─── Internal helper ─────────────────────────────────────────────────────────
2
58
 
3
59
  function clear () {
4
60
  const keys = Object.keys(window.localStorage)
@@ -9,6 +65,8 @@ function clear () {
9
65
  }
10
66
  }
11
67
 
68
+ // ─── Original (plain) functions ──────────────────────────────────────────────
69
+
12
70
  export function setItem (id, str) {
13
71
  try {
14
72
  window.localStorage.setItem(id, str)
@@ -26,10 +84,57 @@ export function getItem (id) {
26
84
 
27
85
  export function getItemJSON (id, defaultValue) {
28
86
  const str = window.localStorage.getItem(id) || ''
29
- return str ? JSON.parse(str) : defaultValue
87
+ const r = parseJsonSafe(str)
88
+ if (typeof r === 'string') {
89
+ return defaultValue
90
+ }
91
+ return r || defaultValue
30
92
  }
31
93
 
32
94
  export function setItemJSON (id, obj) {
33
95
  const str = JSON.stringify(obj)
34
96
  return setItem(id, str)
35
97
  }
98
+
99
+ // ─── Safe (encrypted) functions ──────────────────────────────────────────────
100
+
101
+ export function safeSetItem (id, str) {
102
+ if (window.et.isWebApp) {
103
+ return setItem(id, str)
104
+ }
105
+ try {
106
+ window.localStorage.setItem(id, encrypt(str))
107
+ } catch (e) {
108
+ console.log(e)
109
+ console.log('maybe local storage full, lets reset')
110
+ clear()
111
+ window.localStorage.setItem(id, encrypt(str))
112
+ }
113
+ }
114
+
115
+ export function safeGetItem (id) {
116
+ if (window.et.isWebApp) {
117
+ return getItem(id)
118
+ }
119
+ return decrypt(window.localStorage.getItem(id) || '')
120
+ }
121
+
122
+ export function safeGetItemJSON (id, defaultValue) {
123
+ if (window.et.isWebApp) {
124
+ return getItemJSON(id, defaultValue)
125
+ }
126
+ const str = decrypt(window.localStorage.getItem(id) || '')
127
+ const r = parseJsonSafe(str)
128
+ if (typeof r === 'string') {
129
+ return defaultValue
130
+ }
131
+ return r || defaultValue
132
+ }
133
+
134
+ export function safeSetItemJSON (id, obj) {
135
+ if (window.et.isWebApp) {
136
+ return setItemJSON(id, obj)
137
+ }
138
+ const str = JSON.stringify(obj)
139
+ return safeSetItem(id, str)
140
+ }
@@ -1,5 +1,6 @@
1
- // ai-chat-history-item.jsx
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
2
  import AIOutput from './ai-output'
3
+ import AIStopIcon from './ai-stop-icon'
3
4
  import {
4
5
  Alert,
5
6
  Tooltip
@@ -12,47 +13,169 @@ import {
12
13
  CaretRightOutlined
13
14
  } from '@ant-design/icons'
14
15
  import { copy } from '../../common/clipboard'
15
- import { useState } from 'react'
16
16
 
17
17
  export default function AIChatHistoryItem ({ item }) {
18
18
  const [showOutput, setShowOutput] = useState(true)
19
+ const startedRef = useRef(false)
19
20
  const {
20
- prompt
21
+ prompt,
22
+ isStreaming,
23
+ sessionId,
24
+ response,
25
+ modelAI,
26
+ roleAI,
27
+ baseURLAI,
28
+ apiPathAI,
29
+ apiKeyAI,
30
+ proxyAI,
31
+ languageAI
21
32
  } = item
22
33
 
23
34
  function toggleOutput () {
24
35
  setShowOutput(!showOutput)
25
36
  }
26
37
 
38
+ function buildRole () {
39
+ const lang = languageAI || window.store.getLangName()
40
+ return roleAI + `;用[${lang}]回复`
41
+ }
42
+
43
+ const pollStreamContent = useCallback(async (sid) => {
44
+ try {
45
+ const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sid)
46
+
47
+ if (streamResponse && streamResponse.error) {
48
+ if (streamResponse.error === 'Session not found') {
49
+ return
50
+ }
51
+ window.store.removeAiHistory(item.id)
52
+ return window.store.onError(new Error(streamResponse.error))
53
+ }
54
+
55
+ const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
56
+ if (index !== -1) {
57
+ window.store.aiChatHistory[index].response = streamResponse.content || ''
58
+ window.store.aiChatHistory[index].isStreaming = streamResponse.hasMore
59
+ window.store.aiChatHistory = [...window.store.aiChatHistory]
60
+
61
+ if (streamResponse.hasMore) {
62
+ setTimeout(() => pollStreamContent(sid), 200)
63
+ }
64
+ }
65
+ } catch (error) {
66
+ window.store.removeAiHistory(item.id)
67
+ window.store.onError(error)
68
+ }
69
+ }, [item.id])
70
+
71
+ const startRequest = useCallback(async () => {
72
+ try {
73
+ const aiResponse = await window.pre.runGlobalAsync(
74
+ 'AIchat',
75
+ prompt,
76
+ modelAI,
77
+ buildRole(),
78
+ baseURLAI,
79
+ apiPathAI,
80
+ apiKeyAI,
81
+ proxyAI,
82
+ true
83
+ )
84
+
85
+ if (aiResponse && aiResponse.error) {
86
+ window.store.removeAiHistory(item.id)
87
+ return window.store.onError(new Error(aiResponse.error))
88
+ }
89
+
90
+ if (aiResponse && aiResponse.isStream && aiResponse.sessionId) {
91
+ const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
92
+ if (index !== -1) {
93
+ window.store.aiChatHistory[index].isStreaming = true
94
+ window.store.aiChatHistory[index].sessionId = aiResponse.sessionId
95
+ window.store.aiChatHistory[index].response = aiResponse.content || ''
96
+ }
97
+ pollStreamContent(aiResponse.sessionId)
98
+ } else if (aiResponse && aiResponse.response) {
99
+ const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
100
+ if (index !== -1) {
101
+ window.store.aiChatHistory[index].response = aiResponse.response
102
+ window.store.aiChatHistory[index].isStreaming = false
103
+ }
104
+ }
105
+ } catch (error) {
106
+ window.store.removeAiHistory(item.id)
107
+ window.store.onError(error)
108
+ }
109
+ }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, item.id, pollStreamContent])
110
+
111
+ useEffect(() => {
112
+ if (!response && !startedRef.current) {
113
+ startedRef.current = true
114
+ startRequest()
115
+ }
116
+ }, [])
117
+
118
+ async function handleStop (e) {
119
+ e.stopPropagation()
120
+ if (!sessionId) return
121
+
122
+ try {
123
+ await window.pre.runGlobalAsync('stopStream', sessionId)
124
+ const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
125
+ if (index !== -1) {
126
+ window.store.aiChatHistory[index].isStreaming = false
127
+ window.store.aiChatHistory = [...window.store.aiChatHistory]
128
+ }
129
+ } catch (error) {
130
+ console.error('Error stopping stream:', error)
131
+ }
132
+ }
133
+
134
+ function renderStopButton () {
135
+ if (!isStreaming) {
136
+ return null
137
+ }
138
+ return (
139
+ <AIStopIcon
140
+ onClick={handleStop}
141
+ title='Stop this AI request'
142
+ />
143
+ )
144
+ }
145
+
27
146
  const alertProps = {
28
147
  title: (
29
- <>
148
+ <div className='ai-history-item-title'>
30
149
  <span className='pointer mg1r' onClick={toggleOutput}>
31
150
  {showOutput ? <CaretDownOutlined /> : <CaretRightOutlined />}
32
151
  </span>
33
152
  <UserOutlined />: {prompt}
34
- </>
153
+ {renderStopButton()}
154
+ </div>
35
155
  ),
36
156
  type: 'info'
37
157
  }
158
+
38
159
  function handleDel (e) {
39
160
  e.stopPropagation()
40
161
  window.store.removeAiHistory(item.id)
41
162
  }
163
+
42
164
  function handleCopy () {
43
165
  copy(prompt)
44
166
  }
167
+
45
168
  function renderTitle () {
46
169
  return (
47
170
  <div>
48
171
  <p>
49
- <b>Model:</b> {item.modelAI}
172
+ <b>Model:</b> {modelAI}
50
173
  </p>
51
174
  <p>
52
- <b>Role:</b> {item.roleAI}
175
+ <b>Role:</b> {roleAI}
53
176
  </p>
54
177
  <p>
55
- <b>Base URL:</b> {item.baseURLAI}
178
+ <b>Base URL:</b> {baseURLAI}
56
179
  </p>
57
180
  <p>
58
181
  <b>Time:</b> {new Date(item.timestamp).toLocaleString()}
@@ -70,6 +193,7 @@ export default function AIChatHistoryItem ({ item }) {
70
193
  </div>
71
194
  )
72
195
  }
196
+
73
197
  return (
74
198
  <div className='chat-history-item'>
75
199
  <div className='mg1y'>
@@ -14,7 +14,6 @@ import {
14
14
  } from '../../common/constants'
15
15
  import HelpIcon from '../common/help-icon'
16
16
  import { refsStatic } from '../common/ref'
17
- import AIStopIcon from './ai-stop-icon'
18
17
  import './ai.styl'
19
18
 
20
19
  const { TextArea } = Input
@@ -22,155 +21,43 @@ const MAX_HISTORY = 100
22
21
 
23
22
  export default function AIChat (props) {
24
23
  const [prompt, setPrompt] = useState('')
25
- const [isLoading, setIsLoading] = useState(false)
26
- const [currentSessionId, setCurrentSessionId] = useState(null)
27
24
 
28
25
  function handlePromptChange (e) {
29
26
  setPrompt(e.target.value)
30
27
  }
31
28
 
32
- function buildRole () {
33
- const lang = props.config.languageAI || window.store.getLangName()
34
- return props.config.roleAI + `;用[${lang}]回复`
35
- }
36
-
37
- const handleSubmit = useCallback(async function () {
29
+ const handleSubmit = useCallback(function () {
38
30
  if (window.store.aiConfigMissing()) {
39
31
  window.store.toggleAIConfig()
40
32
  }
41
- if (!prompt.trim() || isLoading) return
42
- setIsLoading(true)
33
+ if (!prompt.trim()) return
43
34
 
44
- // Create a placeholder entry for the streaming response
45
35
  const chatId = uid()
46
36
  const chatEntry = {
47
37
  prompt,
48
- response: '', // Will be updated as stream arrives
38
+ response: '',
49
39
  isStreaming: false,
50
40
  sessionId: null,
51
41
  ...pick(props.config, [
52
42
  'modelAI',
53
43
  'roleAI',
54
- 'baseURLAI'
44
+ 'baseURLAI',
45
+ 'apiPathAI',
46
+ 'apiKeyAI',
47
+ 'proxyAI',
48
+ 'languageAI'
55
49
  ]),
56
50
  timestamp: Date.now(),
57
51
  id: chatId
58
52
  }
59
53
 
60
54
  window.store.aiChatHistory.push(chatEntry)
61
-
62
- try {
63
- const aiResponse = await window.pre.runGlobalAsync(
64
- 'AIchat',
65
- prompt,
66
- props.config.modelAI,
67
- buildRole(),
68
- props.config.baseURLAI,
69
- props.config.apiPathAI,
70
- props.config.apiKeyAI,
71
- props.config.proxyAI,
72
- true // Enable streaming for chat
73
- )
74
-
75
- if (aiResponse && aiResponse.error) {
76
- // Remove the placeholder entry and show error
77
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
78
- if (index !== -1) {
79
- window.store.aiChatHistory.splice(index, 1)
80
- }
81
- setIsLoading(false)
82
- return window.store.onError(
83
- new Error(aiResponse.error)
84
- )
85
- }
86
-
87
- if (aiResponse && aiResponse.isStream && aiResponse.sessionId) {
88
- // Handle streaming response with polling
89
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
90
- if (index !== -1) {
91
- window.store.aiChatHistory[index].isStreaming = true
92
- window.store.aiChatHistory[index].sessionId = aiResponse.sessionId
93
- window.store.aiChatHistory[index].response = aiResponse.content || ''
94
- }
95
- // Store current session ID for stop functionality
96
- setCurrentSessionId(aiResponse.sessionId)
97
-
98
- // Start polling for updates
99
- pollStreamContent(aiResponse.sessionId, chatId)
100
- } else if (aiResponse && aiResponse.response) {
101
- // Handle non-streaming response (fallback)
102
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
103
- if (index !== -1) {
104
- window.store.aiChatHistory[index].response = aiResponse.response
105
- window.store.aiChatHistory[index].isStreaming = false
106
- }
107
- setIsLoading(false)
108
- }
109
- } catch (error) {
110
- // Remove the placeholder entry and show error
111
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
112
- if (index !== -1) {
113
- window.store.aiChatHistory.splice(index, 1)
114
- }
115
- setIsLoading(false)
116
- window.store.onError(error)
117
- }
55
+ setPrompt('')
118
56
 
119
57
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
120
58
  window.store.aiChatHistory.splice(MAX_HISTORY)
121
59
  }
122
- setPrompt('')
123
- }, [prompt, isLoading])
124
-
125
- // Function to poll for streaming content updates
126
- const pollStreamContent = async (sessionId, chatId) => {
127
- try {
128
- const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sessionId)
129
-
130
- if (streamResponse && streamResponse.error) {
131
- // Session not found or error - stop polling
132
- if (streamResponse.error === 'Session not found') {
133
- setCurrentSessionId(null)
134
- setIsLoading(false)
135
- return
136
- }
137
- // Remove the entry and show error
138
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
139
- if (index !== -1) {
140
- window.store.aiChatHistory.splice(index, 1)
141
- }
142
- setIsLoading(false)
143
- return window.store.onError(new Error(streamResponse.error))
144
- }
145
-
146
- // Update the chat entry with new content
147
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
148
- if (index !== -1) {
149
- window.store.aiChatHistory[index].response = streamResponse.content || ''
150
- window.store.aiChatHistory[index].isStreaming = streamResponse.hasMore
151
-
152
- // Force re-render by updating the array reference
153
- window.store.aiChatHistory = [...window.store.aiChatHistory]
154
-
155
- // Continue polling if there's more content
156
- if (streamResponse.hasMore) {
157
- setTimeout(() => pollStreamContent(sessionId, chatId), 200) // Poll every 200ms
158
- } else {
159
- setCurrentSessionId(null)
160
- setIsLoading(false)
161
- }
162
- }
163
- } catch (error) {
164
- // Remove the entry and show error
165
- const index = window.store.aiChatHistory.findIndex(item => item.id === chatId)
166
- if (index !== -1) {
167
- window.store.aiChatHistory.splice(index, 1)
168
- }
169
- setCurrentSessionId(null)
170
- setIsLoading(false)
171
- window.store.onError(error)
172
- }
173
- }
60
+ }, [prompt])
174
61
 
175
62
  function renderHistory () {
176
63
  return (
@@ -188,41 +75,7 @@ export default function AIChat (props) {
188
75
  window.store.aiChatHistory = []
189
76
  }
190
77
 
191
- const handleStop = useCallback(async function () {
192
- if (!currentSessionId || !isLoading) return
193
-
194
- try {
195
- // Call server to stop the stream
196
- await window.pre.runGlobalAsync('stopStream', currentSessionId)
197
-
198
- // Reset state
199
- setCurrentSessionId(null)
200
- setIsLoading(false)
201
-
202
- // Update the chat entry to mark as stopped
203
- const chatEntries = window.store.aiChatHistory
204
- for (let i = chatEntries.length - 1; i >= 0; i--) {
205
- if (chatEntries[i].isStreaming) {
206
- chatEntries[i].isStreaming = false
207
- break
208
- }
209
- }
210
- window.store.aiChatHistory = [...chatEntries]
211
- } catch (error) {
212
- console.error('Error stopping stream:', error)
213
- setCurrentSessionId(null)
214
- setIsLoading(false)
215
- }
216
- }, [currentSessionId, isLoading])
217
-
218
78
  function renderSendIcon () {
219
- if (isLoading) {
220
- return (
221
- <AIStopIcon
222
- onClick={handleStop}
223
- />
224
- )
225
- }
226
79
  return (
227
80
  <SendOutlined
228
81
  onClick={handleSubmit}
@@ -254,7 +107,6 @@ export default function AIChat (props) {
254
107
  e.preventDefault()
255
108
  handleSubmit()
256
109
  }
257
- // If Shift+Enter, allow default behavior (new line)
258
110
  }
259
111
 
260
112
  return (
@@ -270,7 +122,6 @@ export default function AIChat (props) {
270
122
  onPressEnter={handleKeyPress}
271
123
  placeholder='Enter your prompt here'
272
124
  autoSize={{ minRows: 3, maxRows: 10 }}
273
- disabled={isLoading}
274
125
  className='ai-chat-textarea'
275
126
  />
276
127
  <Flex className='ai-chat-terminals' justify='space-between' align='center'>
@@ -66,9 +66,14 @@
66
66
  .ai-stop-icon-square
67
67
  width 20px
68
68
  height 20px
69
- background var(--error)
70
69
  border-radius 2px
71
70
  display flex
72
71
  align-items center
73
72
  justify-content center
74
73
  font-size 10px
74
+
75
+ .ai-history-item-title
76
+ display flex
77
+ align-items center
78
+ .ai-stop-icon-square
79
+ margin-left auto
@@ -1,117 +1 @@
1
- /**
2
- * bookmark form auth renderer (copied from legacy)
3
- */
4
- import {
5
- Button,
6
- Input,
7
- AutoComplete,
8
- Form,
9
- Select
10
- } from 'antd'
11
- import { formItemLayout } from '../../../common/form-layout'
12
- import { uniqBy } from 'lodash-es'
13
- import Password from '../../common/password'
14
- import Upload from '../../common/upload'
15
-
16
- const { TextArea } = Input
17
- const FormItem = Form.Item
18
- const e = window.translate
19
-
20
- export default function renderAuth (props) {
21
- const {
22
- store,
23
- form,
24
- authType,
25
- formItemName = 'password',
26
- profileFilter = (d) => d
27
- } = props
28
- const beforeUpload = async (file) => {
29
- const filePath = file.filePath
30
- const privateKey = await window.fs.readFile(filePath)
31
- form.setFieldsValue({
32
- privateKey
33
- })
34
- }
35
- if (authType === 'password') {
36
- const opts = {
37
- options: uniqBy(
38
- store.bookmarks
39
- .filter(d => d.password),
40
- (d) => d.password
41
- )
42
- .map(d => ({
43
- label: `${d.title ? `(${d.title})` : ''}${d.username || ''}:${d.host}-******`,
44
- value: d.password
45
- })),
46
- placeholder: e('password'),
47
- allowClear: false
48
- }
49
- return (
50
- <FormItem
51
- {...formItemLayout}
52
- label={e('password')}
53
- name={formItemName}
54
- hasFeedback
55
- rules={[{
56
- max: 1024, message: '1024 chars max'
57
- }]}
58
- >
59
- <AutoComplete {...opts}>
60
- <Password />
61
- </AutoComplete>
62
- </FormItem>
63
- )
64
- }
65
- if (authType === 'profiles') {
66
- const opts = {
67
- options: store.profiles
68
- .filter(profileFilter)
69
- .map(d => ({ label: d.name, value: d.id })),
70
- placeholder: e('profiles'),
71
- allowClear: true
72
- }
73
- return (
74
- <FormItem
75
- {...formItemLayout}
76
- label={e('profiles')}
77
- name='profile'
78
- hasFeedback
79
- >
80
- <Select {...opts} />
81
- </FormItem>
82
- )
83
- }
84
- return [
85
- <FormItem
86
- {...formItemLayout}
87
- label={e('privateKey')}
88
- hasFeedback
89
- key='privateKey'
90
- className='mg1b'
91
- rules={[{
92
- max: 13000, message: '13000 chars max'
93
- }]}
94
- >
95
- <FormItem noStyle name='privateKey'>
96
- <TextArea placeholder={e('privateKeyDesc')} autoSize={{ minRows: 1 }} />
97
- </FormItem>
98
- <Upload beforeUpload={beforeUpload} fileList={[]}>
99
- <Button type='dashed' className='mg2b mg1t'>
100
- {e('importFromFile')}
101
- </Button>
102
- </Upload>
103
- </FormItem>,
104
- <FormItem
105
- key='passphrase'
106
- {...formItemLayout}
107
- label={e('passphrase')}
108
- name='passphrase'
109
- hasFeedback
110
- rules={[{
111
- max: 1024, message: '1024 chars max'
112
- }]}
113
- >
114
- <Password placeholder={e('passphraseDesc')} />
115
- </FormItem>
116
- ]
117
- }
1
+ export { default } from './ssh-auth-selector'
@@ -239,6 +239,13 @@ export const commonFields = {
239
239
  type: 'runScripts',
240
240
  name: 'runScripts',
241
241
  label: ''
242
+ },
243
+
244
+ enableTerminalImage: {
245
+ type: 'switch',
246
+ name: 'enableTerminalImage',
247
+ label: () => e('enableTerminalImage'),
248
+ valuePropName: 'checked'
242
249
  }
243
250
  }
244
251
 
@@ -272,6 +279,7 @@ export const sshSettings = [
272
279
  label: () => e('ignoreKeyboardInteractive'),
273
280
  valuePropName: 'checked'
274
281
  },
282
+ commonFields.enableTerminalImage,
275
283
  ...terminalSettings.slice(0, -1), // All except terminalBackground
276
284
  commonFields.x11,
277
285
  commonFields.terminalBackground
@@ -1,5 +1,5 @@
1
1
  import { formItemLayout } from '../../../common/form-layout.js'
2
- import { terminalLocalType, terminalTypes } from '../../../common/constants.js'
2
+ import { terminalLocalType } from '../../../common/constants.js'
3
3
  import {
4
4
  createBaseInitValues,
5
5
  getTerminalDefaults,
@@ -31,7 +31,8 @@ const localConfig = {
31
31
  commonFields.category,
32
32
  commonFields.colorTitle,
33
33
  commonFields.description,
34
- { type: 'runScripts', name: 'runScripts', label: '' },
34
+ commonFields.enableTerminalImage,
35
+ commonFields.runScripts,
35
36
  { type: 'input', name: 'type', label: 'type', hidden: true }
36
37
  ]
37
38
  },
@@ -39,54 +40,12 @@ const localConfig = {
39
40
  key: 'settings',
40
41
  label: e('settings'),
41
42
  fields: [
42
- {
43
- type: 'input',
44
- name: 'env.LANG',
45
- label: 'ENV:LANG',
46
- props: { maxLength: 130 }
47
- },
48
- {
49
- type: 'autocomplete',
50
- name: 'term',
51
- label: () => e('terminalType'),
52
- rules: [{ required: true, message: 'terminal type required' }],
53
- options: terminalTypes.map(t => ({ label: t, value: t }))
54
- },
55
- {
56
- type: 'switch',
57
- name: 'displayRaw',
58
- label: () => e('displayRaw'),
59
- valuePropName: 'checked'
60
- },
61
- {
62
- type: 'input',
63
- name: 'fontFamily',
64
- label: () => e('fontFamily'),
65
- rules: [{ max: 130, message: '130 chars max' }],
66
- props: { placeholder: defaultSettings.fontFamily }
67
- },
68
- {
69
- type: 'number',
70
- name: 'fontSize',
71
- label: () => e('fontSize'),
72
- props: {
73
- min: 9,
74
- max: 65535,
75
- step: 1,
76
- placeholder: defaultSettings.fontSize
77
- }
78
- },
79
- {
80
- type: 'number',
81
- name: 'keepaliveInterval',
82
- label: () => e('keepaliveIntervalDesc'),
83
- props: {
84
- min: 0,
85
- max: 20000000,
86
- step: 1000
87
- }
88
- },
89
- { type: 'terminalBackground', name: 'terminalBackground', label: () => e('terminalBackgroundImage') },
43
+ commonFields.terminalType,
44
+ commonFields.displayRaw,
45
+ commonFields.fontFamily,
46
+ commonFields.fontSize,
47
+ commonFields.keepaliveInterval,
48
+ commonFields.terminalBackground,
90
49
  // Exec settings - stored as flat properties on bookmark
91
50
  { type: 'execSettings' }
92
51
  ]
@@ -95,7 +54,7 @@ const localConfig = {
95
54
  key: 'quickCommands',
96
55
  label: e('quickCommands'),
97
56
  fields: [
98
- { type: 'quickCommands', name: '__quick__', label: '' }
57
+ commonFields.quickCommands
99
58
  ]
100
59
  }
101
60
  ]
@@ -580,7 +580,6 @@ export default class SettingTerminal extends Component {
580
580
  this.renderToggle('saveTerminalLogToFile')
581
581
  }
582
582
  {this.renderToggle('addTimeStampToTermLog')}
583
- {this.renderToggle('enableSixel', 'pd2b', 'SIXEL')}
584
583
  {
585
584
  [
586
585
  'cursorBlink',
@@ -29,7 +29,7 @@ import fs from '../../common/fs'
29
29
  import ListTable from './list-table-ui'
30
30
  import deepCopy from 'json-deep-copy'
31
31
  import isValidPath from '../../common/is-valid-path'
32
- import { LoadingOutlined } from '@ant-design/icons'
32
+ import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
33
33
  import * as owner from './owner-list'
34
34
  import AddressBar from './address-bar'
35
35
  import getProxy from '../../common/get-proxy'
@@ -956,6 +956,20 @@ export default class Sftp extends Component {
956
956
  }, () => this[`${type}List`](undefined, undefined, oldPath))
957
957
  }
958
958
 
959
+ handleReloadRemoteSftp = async () => {
960
+ if (this.sftp) {
961
+ this.sftp.destroy()
962
+ this.sftp = null
963
+ }
964
+ this.setState({
965
+ remoteLoading: true,
966
+ remote: [],
967
+ remoteFileTree: new Map()
968
+ }, () => {
969
+ this.initRemoteAll()
970
+ })
971
+ }
972
+
959
973
  parsePath = (type, pth) => {
960
974
  const reg = /^%([^%]+)%/
961
975
  if (!reg.test(pth)) {
@@ -1152,6 +1166,25 @@ export default class Sftp extends Component {
1152
1166
  )
1153
1167
  }
1154
1168
 
1169
+ renderSftpPanelTitle (type, username, host) {
1170
+ if (type === typeMap.remote) {
1171
+ return (
1172
+ <div className='sftp-panel-title pd1t pd1b pd1x alignright'>
1173
+ <ReloadOutlined
1174
+ className='mg1r pointer'
1175
+ onClick={this.handleReloadRemoteSftp}
1176
+ />
1177
+ {e('remote')}: {username}@{host}
1178
+ </div>
1179
+ )
1180
+ }
1181
+ return (
1182
+ <div className='sftp-panel-title pd1t pd1b pd1x'>
1183
+ {e('local')}
1184
+ </div>
1185
+ )
1186
+ }
1187
+
1155
1188
  renderSection (type, style, width) {
1156
1189
  const {
1157
1190
  id
@@ -1222,17 +1255,7 @@ export default class Sftp extends Component {
1222
1255
  <Spin spinning={loading}>
1223
1256
  <div className='pd1 sftp-panel'>
1224
1257
  {
1225
- type === typeMap.remote
1226
- ? (
1227
- <div className='sftp-panel-title pd1t pd1b pd1x alignright'>
1228
- {e('remote')}: {username}@{host}
1229
- </div>
1230
- )
1231
- : (
1232
- <div className='sftp-panel-title pd1t pd1b pd1x'>
1233
- {e('local')}
1234
- </div>
1235
- )
1258
+ this.renderSftpPanelTitle(type, username, host)
1236
1259
  }
1237
1260
  <AddressBar
1238
1261
  {...addrProps}
@@ -26,7 +26,7 @@ export default auto(function HistoryPanel (props) {
26
26
  } = store
27
27
  let arr = store.config.disableConnectionHistory ? [] : history
28
28
  if (sortByFrequency) {
29
- arr = arr.sort((a, b) => { return b.count - a.count })
29
+ arr = [...arr].sort((a, b) => { return b.count - a.count })
30
30
  }
31
31
 
32
32
  const handleSortByFrequencyChange = (checked) => {
@@ -19,6 +19,19 @@ export default function AppDrag (props) {
19
19
  return true
20
20
  }
21
21
 
22
+ useEffect(() => {
23
+ if (window.store.shouldSendWindowMove) {
24
+ return
25
+ }
26
+ document.addEventListener('mouseup', onMouseUp)
27
+ window.addEventListener('contextmenu', onMouseUp)
28
+
29
+ return () => {
30
+ document.removeEventListener('mouseup', onMouseUp)
31
+ window.removeEventListener('contextmenu', onMouseUp)
32
+ }
33
+ }, [])
34
+
22
35
  function onMouseDown (e) {
23
36
  // e.stopPropagation()
24
37
  if (canOperate(e)) {
@@ -48,18 +61,6 @@ export default function AppDrag (props) {
48
61
  window.pre.runGlobalAsync('maximize')
49
62
  }
50
63
  }
51
- if (!window.store.shouldSendWindowMove) {
52
- useEffect(() => {
53
- // Listen for mouseup at document level to catch mouseup outside window
54
- document.addEventListener('mouseup', onMouseUp)
55
- window.addEventListener('contextmenu', onMouseUp)
56
-
57
- return () => {
58
- document.removeEventListener('mouseup', onMouseUp)
59
- window.removeEventListener('contextmenu', onMouseUp)
60
- }
61
- }, [])
62
- }
63
64
  const props0 = {
64
65
  className: 'app-drag',
65
66
  onDoubleClick
@@ -833,25 +833,19 @@ class Term extends Component {
833
833
  this.searchAddon.onDidChangeResults(this.onSearchResultsChange)
834
834
  const Unicode11Addon = await loadUnicode11Addon()
835
835
  const unicode11Addon = new Unicode11Addon()
836
- if (config.enableSixel !== false) {
837
- try {
838
- const ImageAddon = await loadImageAddon()
839
- this.imageAddon = new ImageAddon({
840
- enableSizeReports: false,
841
- sixelSupport: true,
842
- iipSupport: false
843
- })
844
- term.loadAddon(this.imageAddon)
845
- } catch (err) {
846
- console.error('load sixel addon failed', err)
847
- }
848
- }
849
836
  term.loadAddon(unicode11Addon)
850
837
  term.loadAddon(ligtureAddon)
851
838
  term.unicode.activeVersion = '11'
852
839
  term.loadAddon(this.fitAddon)
853
840
  term.loadAddon(this.searchAddon)
854
841
  term.loadAddon(this.cmdAddon)
842
+ if (tab.enableTerminalImage) {
843
+ const ImageAddon = await loadImageAddon()
844
+ this.imageAddon = new ImageAddon({
845
+ pixelLimit: 33554432
846
+ })
847
+ term.loadAddon(this.imageAddon)
848
+ }
855
849
  term.onData(this.onData)
856
850
  this.term = term
857
851
  term.onSelectionChange(this.onSelectionChange)
@@ -1401,6 +1395,7 @@ class Term extends Component {
1401
1395
  totalLines: this.state.totalLines,
1402
1396
  height
1403
1397
  }
1398
+ const spinCls = loading ? 'loading-wrapper' : 'hide'
1404
1399
  return (
1405
1400
  <Dropdown {...dropdownProps}>
1406
1401
  <div
@@ -1417,7 +1412,7 @@ class Term extends Component {
1417
1412
  <RemoteFloatControl
1418
1413
  isFullScreen={fullscreen}
1419
1414
  />
1420
- <Spin className='loading-wrapper' spinning={loading} />
1415
+ <Spin className={spinCls} spinning={loading} />
1421
1416
  </div>
1422
1417
  </Dropdown>
1423
1418
  )
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  // import { transferTypeMap } from '../../common/constants.js'
7
+ import { safeGetItem, safeSetItem } from '../../common/safe-local-storage.js'
7
8
  import { getLocalFileInfo } from '../sftp/file-read.js'
8
9
 
9
10
  /**
@@ -218,7 +219,7 @@ export class TransferClientBase {
218
219
  */
219
220
  openSaveFolderSelect = async () => {
220
221
  // Try to use last saved path
221
- const lastPath = this.storageKey ? window.localStorage.getItem(this.storageKey) : null
222
+ const lastPath = this.storageKey ? safeGetItem(this.storageKey) : null
222
223
 
223
224
  const savePaths = await window.api.openDialog({
224
225
  title: 'Choose a folder to save file(s)',
@@ -238,9 +239,8 @@ export class TransferClientBase {
238
239
  return null
239
240
  }
240
241
 
241
- // Save for next time
242
242
  if (this.storageKey) {
243
- window.localStorage.setItem(this.storageKey, savePaths[0])
243
+ safeSetItem(this.storageKey, savePaths[0])
244
244
  }
245
245
  return savePaths[0]
246
246
  }
@@ -4,19 +4,20 @@
4
4
 
5
5
  import { useState } from 'react'
6
6
  import { Button, Input, Space } from 'antd'
7
+ import { safeGetItem, safeSetItem } from '../../common/safe-local-storage.js'
7
8
 
8
9
  const LS_KEY = 'customEditorCommand'
9
10
  const e = window.translate
10
11
 
11
12
  export default function EditWithCustomEditor ({ loading, editWithCustom }) {
12
13
  const [editorCommand, setEditorCommand] = useState(
13
- () => window.localStorage.getItem(LS_KEY) || ''
14
+ () => safeGetItem(LS_KEY) || ''
14
15
  )
15
16
 
16
17
  function handleChange (ev) {
17
18
  const val = ev.target.value
18
19
  setEditorCommand(val)
19
- window.localStorage.setItem(LS_KEY, val)
20
+ safeSetItem(LS_KEY, val)
20
21
  }
21
22
 
22
23
  function handleClick () {
@@ -54,7 +54,7 @@ export default () => {
54
54
  lastDataUpdateTime: 0,
55
55
  tabs: [],
56
56
  activeTabId: '',
57
- history: ls.getItemJSON('history', []),
57
+ history: ls.safeGetItemJSON('history', []),
58
58
  sshConfigs: [],
59
59
  bookmarks: [],
60
60
  bookmarksMap: new Map(),
@@ -77,7 +77,7 @@ export default () => {
77
77
  // terminalCommandHistory: Map<cmdString, {count: Number, lastUseTime: DateString}>
78
78
  // Load from localStorage and migrate from old format (Set of strings) if needed
79
79
  terminalCommandHistory: (() => {
80
- const savedData = ls.getItemJSON(cmdHistoryKey, [])
80
+ const savedData = ls.safeGetItemJSON(cmdHistoryKey, [])
81
81
  const map = new Map()
82
82
  if (Array.isArray(savedData)) {
83
83
  // Check if old format (array of strings) or new format (array of objects)
@@ -111,7 +111,7 @@ export default () => {
111
111
 
112
112
  // batch input selected tab ids
113
113
  _batchInputSelectedTabIds: new Set(),
114
- aiChatHistory: ls.getItemJSON(aiChatHistoryKey, []),
114
+ aiChatHistory: ls.safeGetItemJSON(aiChatHistoryKey, []),
115
115
 
116
116
  // sftp
117
117
  fileOperation: fileOperationsMap.cp, // cp or mv
@@ -487,7 +487,6 @@ export default (Store) => {
487
487
  'hotkey',
488
488
  'sshReadyTimeout',
489
489
  'scrollback',
490
- 'enableSixel',
491
490
  'fontSize',
492
491
  'execWindows',
493
492
  'execMac',
@@ -135,12 +135,12 @@ export default store => {
135
135
  }).start()
136
136
 
137
137
  autoRun(() => {
138
- ls.setItemJSON('history', store.history)
138
+ ls.safeSetItemJSON('history', store.history)
139
139
  return store.history
140
140
  }).start()
141
141
 
142
142
  autoRun(() => {
143
- ls.setItemJSON(aiChatHistoryKey, store.aiChatHistory)
143
+ ls.safeSetItemJSON(aiChatHistoryKey, store.aiChatHistory)
144
144
  return store.aiChatHistory
145
145
  }).start()
146
146
 
@@ -152,7 +152,7 @@ export default store => {
152
152
  count: info.count,
153
153
  lastUseTime: info.lastUseTime
154
154
  }))
155
- ls.setItemJSON(cmdHistoryKey, data)
155
+ ls.safeSetItemJSON(cmdHistoryKey, data)
156
156
  return store.terminalCommandHistory
157
157
  }).start()
158
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "2.17.16",
3
+ "version": "3.0.6",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",