@electerm/electerm-react 3.15.50 → 3.15.66

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 (30) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/components/ai/agent.js +100 -95
  3. package/client/components/ai/ai-chat-entry.jsx +1 -2
  4. package/client/components/ai/ai-chat-history-item.jsx +1 -1
  5. package/client/components/ai/ai-chat.jsx +12 -1
  6. package/client/components/ai/ai-output.jsx +14 -3
  7. package/client/components/ai/ai.styl +14 -0
  8. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  9. package/client/components/bookmark-form/common/x11.jsx +21 -0
  10. package/client/components/bookmark-form/config/ssh.js +1 -0
  11. package/client/components/common/logo.styl +2 -1
  12. package/client/components/main/main.jsx +2 -1
  13. package/client/components/rdp/rdp-session.jsx +1 -2
  14. package/client/components/setting-panel/setting-modal.jsx +6 -8
  15. package/client/components/setting-sync/server-data-status.jsx +31 -13
  16. package/client/components/setting-sync/sync-data-compare.jsx +92 -0
  17. package/client/components/spice/spice-session.jsx +1 -2
  18. package/client/components/sys-menu/menu-btn.jsx +5 -3
  19. package/client/components/sys-menu/sys-menu.styl +13 -7
  20. package/client/components/terminal/terminal-interactive-ui.jsx +1 -1
  21. package/client/components/terminal/xterm-loader.js +11 -13
  22. package/client/components/terminal-info/terminal-info-entry.jsx +1 -2
  23. package/client/components/text-editor/text-editor-entry.jsx +1 -2
  24. package/client/components/vnc/vnc-session.jsx +1 -2
  25. package/client/css/basic.styl +6 -1
  26. package/client/store/init-state.js +1 -0
  27. package/client/store/sidebar.js +3 -0
  28. package/client/store/sync.js +98 -1
  29. package/package.json +1 -1
  30. package/client/common/import-retry.js +0 -23
@@ -64,6 +64,7 @@ export const sshBookmarkSchema = {
64
64
  sshAgent: z.string().optional().describe('SSH agent path'),
65
65
  serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
66
66
  cipher: z.array(z.string()).optional().describe('Cipher list'),
67
+ compress: z.array(z.string()).optional().describe('Compression algorithms'),
67
68
  quickCommands: z.array(quickCommandSchema).optional().describe('Quick commands'),
68
69
  x11: z.boolean().optional().describe('Enable x11 forwarding, default is false'),
69
70
  term: z.string().optional().describe('Terminal type, default is xterm-256color'),
@@ -46,116 +46,121 @@ async function callBackendAIchatWithTools (messages, config) {
46
46
  }
47
47
 
48
48
  export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming) {
49
- const messages = [
50
- { role: 'system', content: buildAgentSystemPrompt(config) },
51
- { role: 'user', content: chatEntry.prompt }
52
- ]
53
- const toolCallsLog = []
54
- let accumulatedContent = ''
55
-
56
- setIsStreaming(true)
57
- updateChatEntry(chatEntry, {
58
- toolCalls: [],
59
- response: ''
60
- })
61
-
62
- for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
63
- if (abortRef && abortRef.current) {
64
- setIsStreaming(false)
65
- updateChatEntry(chatEntry, {
66
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
67
- })
68
- return
69
- }
70
-
71
- const result = await callBackendAIchatWithTools(messages, config)
49
+ window.store.agentRunning = true
50
+ try {
51
+ const messages = [
52
+ { role: 'system', content: buildAgentSystemPrompt(config) },
53
+ { role: 'user', content: chatEntry.prompt }
54
+ ]
55
+ const toolCallsLog = []
56
+ let accumulatedContent = ''
57
+
58
+ setIsStreaming(true)
59
+ updateChatEntry(chatEntry, {
60
+ toolCalls: [],
61
+ response: ''
62
+ })
63
+
64
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
65
+ if (abortRef && abortRef.current) {
66
+ setIsStreaming(false)
67
+ updateChatEntry(chatEntry, {
68
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
69
+ })
70
+ return
71
+ }
72
72
 
73
- if (result.error) {
74
- setIsStreaming(false)
75
- updateChatEntry(chatEntry, {
76
- response: accumulatedContent + `\n\n**Error:** ${result.error}`
77
- })
78
- return
79
- }
73
+ const result = await callBackendAIchatWithTools(messages, config)
80
74
 
81
- const assistantMessage = result.message
82
- if (!assistantMessage) {
83
- setIsStreaming(false)
84
- updateChatEntry(chatEntry, {
85
- response: accumulatedContent || 'No response from AI.'
86
- })
87
- return
88
- }
75
+ if (result.error) {
76
+ setIsStreaming(false)
77
+ updateChatEntry(chatEntry, {
78
+ response: accumulatedContent + `\n\n**Error:** ${result.error}`
79
+ })
80
+ return
81
+ }
89
82
 
90
- messages.push(assistantMessage)
83
+ const assistantMessage = result.message
84
+ if (!assistantMessage) {
85
+ setIsStreaming(false)
86
+ updateChatEntry(chatEntry, {
87
+ response: accumulatedContent || 'No response from AI.'
88
+ })
89
+ return
90
+ }
91
91
 
92
- if (assistantMessage.content) {
93
- accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
94
- updateChatEntry(chatEntry, {
95
- response: accumulatedContent
96
- })
97
- }
92
+ messages.push(assistantMessage)
98
93
 
99
- if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
100
- setIsStreaming(false)
101
- updateChatEntry(chatEntry, {
102
- response: accumulatedContent
103
- })
104
- return
105
- }
94
+ if (assistantMessage.content) {
95
+ accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
96
+ updateChatEntry(chatEntry, {
97
+ response: accumulatedContent
98
+ })
99
+ }
106
100
 
107
- for (const toolCall of assistantMessage.tool_calls) {
108
- if (abortRef && abortRef.current) {
101
+ if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
109
102
  setIsStreaming(false)
110
103
  updateChatEntry(chatEntry, {
111
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
104
+ response: accumulatedContent
112
105
  })
113
106
  return
114
107
  }
115
108
 
116
- let args
117
- try {
118
- args = JSON.parse(toolCall.function.arguments)
119
- } catch {
120
- args = {}
121
- }
109
+ for (const toolCall of assistantMessage.tool_calls) {
110
+ if (abortRef && abortRef.current) {
111
+ setIsStreaming(false)
112
+ updateChatEntry(chatEntry, {
113
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
114
+ })
115
+ return
116
+ }
117
+
118
+ let args
119
+ try {
120
+ args = JSON.parse(toolCall.function.arguments)
121
+ } catch {
122
+ args = {}
123
+ }
124
+
125
+ const toolEntry = {
126
+ id: toolCall.id,
127
+ name: toolCall.function.name,
128
+ args,
129
+ status: 'running',
130
+ result: null
131
+ }
132
+ toolCallsLog.push(toolEntry)
133
+ updateChatEntry(chatEntry, {
134
+ toolCalls: [...toolCallsLog]
135
+ })
122
136
 
123
- const toolEntry = {
124
- id: toolCall.id,
125
- name: toolCall.function.name,
126
- args,
127
- status: 'running',
128
- result: null
129
- }
130
- toolCallsLog.push(toolEntry)
131
- updateChatEntry(chatEntry, {
132
- toolCalls: [...toolCallsLog]
133
- })
134
-
135
- let toolResult
136
- try {
137
- toolResult = await executeToolCall(toolCall.function.name, args)
138
- toolEntry.status = 'completed'
139
- toolEntry.result = toolResult
140
- } catch (err) {
141
- toolEntry.status = 'error'
142
- toolEntry.result = err.message
143
- }
137
+ let toolResult
138
+ try {
139
+ toolResult = await executeToolCall(toolCall.function.name, args)
140
+ toolEntry.status = 'completed'
141
+ toolEntry.result = toolResult
142
+ } catch (err) {
143
+ toolEntry.status = 'error'
144
+ toolEntry.result = err.message
145
+ }
144
146
 
145
- updateChatEntry(chatEntry, {
146
- toolCalls: [...toolCallsLog]
147
- })
147
+ updateChatEntry(chatEntry, {
148
+ toolCalls: [...toolCallsLog]
149
+ })
148
150
 
149
- messages.push({
150
- role: 'tool',
151
- tool_call_id: toolCall.id,
152
- content: toolEntry.result
153
- })
151
+ messages.push({
152
+ role: 'tool',
153
+ tool_call_id: toolCall.id,
154
+ content: toolEntry.result
155
+ })
156
+ }
154
157
  }
155
- }
156
158
 
157
- setIsStreaming(false)
158
- updateChatEntry(chatEntry, {
159
- response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
160
- })
159
+ setIsStreaming(false)
160
+ updateChatEntry(chatEntry, {
161
+ response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
162
+ })
163
+ } finally {
164
+ window.store.agentRunning = false
165
+ }
161
166
  }
@@ -1,7 +1,6 @@
1
1
  import { lazy, Suspense } from 'react'
2
- import importRetry from '../../common/import-retry'
3
2
 
4
- const AIChat = lazy(() => importRetry(() => import('./ai-chat')))
3
+ const AIChat = lazy(() => import('./ai-chat'))
5
4
 
6
5
  export default function AIChatEntry (props) {
7
6
  return (
@@ -173,7 +173,6 @@ export default function AIChatHistoryItem ({ item }) {
173
173
  {showOutput ? <CaretDownOutlined /> : <CaretRightOutlined />}
174
174
  </span>
175
175
  <span>{prompt}</span>
176
- {renderStopButton()}
177
176
  </div>
178
177
  ),
179
178
  type: 'info'
@@ -244,6 +243,7 @@ export default function AIChatHistoryItem ({ item }) {
244
243
  </div>
245
244
  {renderToolCalls()}
246
245
  {showOutput && <AIOutput item={item} />}
246
+ {renderStopButton()}
247
247
  </div>
248
248
  )
249
249
  }
@@ -25,6 +25,7 @@ export default function AIChat (props) {
25
25
  const [prompt, setPrompt] = useState('')
26
26
  const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask')
27
27
  const isAgent = mode === 'agent'
28
+ const submitDisabled = isAgent && props.agentRunning
28
29
 
29
30
  function handlePromptChange (e) {
30
31
  setPrompt(e.target.value)
@@ -103,6 +104,14 @@ export default function AIChat (props) {
103
104
  }
104
105
 
105
106
  function renderSendIcon () {
107
+ if (submitDisabled) {
108
+ return (
109
+ <SendOutlined
110
+ className='mg1l send-to-ai-icon disabled'
111
+ title='Agent is running, please wait'
112
+ />
113
+ )
114
+ }
106
115
  return (
107
116
  <SendOutlined
108
117
  onClick={handleSubmit}
@@ -132,7 +141,9 @@ export default function AIChat (props) {
132
141
  const handleKeyPress = (e) => {
133
142
  if (!e.shiftKey) {
134
143
  e.preventDefault()
135
- handleSubmit()
144
+ if (!submitDisabled) {
145
+ handleSubmit()
146
+ }
136
147
  }
137
148
  }
138
149
 
@@ -1,3 +1,4 @@
1
+ import { useRef, useEffect } from 'react'
1
2
  import ReactMarkdown from 'react-markdown'
2
3
  import { copy } from '../../common/clipboard'
3
4
  import Link from '../common/external-link'
@@ -8,12 +9,20 @@ import getBrand from './get-brand'
8
9
  const e = window.translate
9
10
 
10
11
  export default function AIOutput ({ item }) {
12
+ const outputRef = useRef(null)
11
13
  const {
12
14
  response,
13
15
  baseURLAI,
14
16
  nameAI,
15
17
  modelAI
16
18
  } = item
19
+
20
+ useEffect(() => {
21
+ if (outputRef.current) {
22
+ outputRef.current.scrollTop = outputRef.current.scrollHeight
23
+ }
24
+ }, [response])
25
+
17
26
  if (!response) {
18
27
  return null
19
28
  }
@@ -103,9 +112,11 @@ export default function AIOutput ({ item }) {
103
112
  }
104
113
 
105
114
  return (
106
- <div className='pd1'>
107
- {renderBrand()}
108
- <ReactMarkdown {...mdProps} />
115
+ <div className='ai-stream-output' ref={outputRef}>
116
+ <div className='pd1'>
117
+ {renderBrand()}
118
+ <ReactMarkdown {...mdProps} />
119
+ </div>
109
120
  </div>
110
121
  )
111
122
  }
@@ -14,6 +14,12 @@
14
14
  overflow-x hidden
15
15
 
16
16
  .chat-history-item
17
+ position relative
18
+ > .ai-stop-icon-square
19
+ position absolute
20
+ bottom 8px
21
+ right 8px
22
+ z-index 10
17
23
  .code-block
18
24
  border 1px dashed var(--main-darker)
19
25
  padding 5px
@@ -26,6 +32,10 @@
26
32
  // .code-block-actions
27
33
  // display block
28
34
 
35
+ .ai-stream-output
36
+ max-height 400px
37
+ overflow-y auto
38
+
29
39
  .ai-chat-input
30
40
  position relative
31
41
  margin-top 10px
@@ -53,6 +63,10 @@
53
63
  white-space pre-wrap
54
64
  word-break break-all
55
65
 
66
+ .send-to-ai-icon.disabled
67
+ opacity 0.4
68
+ cursor not-allowed
69
+
56
70
  .clear-ai-icon
57
71
  position relative
58
72
  &::after
@@ -22,6 +22,7 @@ const bookmarkSchema = {
22
22
  sshAgent: 'string - ssh agent path',
23
23
  serverHostKey: 'array - server host key algorithms',
24
24
  cipher: 'array - cipher list',
25
+ compress: 'array - compression algorithms (zlib@openssh.com, zlib, none)',
25
26
  runScripts: 'array - run scripts after connected ({delay,script})',
26
27
  quickCommands: 'array - quick commands ({name,command})',
27
28
  proxy: 'string - proxy address (socks5://...)',
@@ -35,6 +35,13 @@ const cipherOptions = [
35
35
  'curve25519-sha256@libssh.org'
36
36
  ]
37
37
 
38
+ // Available compress options
39
+ const compressOptions = [
40
+ 'zlib@openssh.com',
41
+ 'zlib',
42
+ 'none'
43
+ ]
44
+
38
45
  // Available serverHostKey options from ssh2-alg.js
39
46
  const serverHostKeyOptions = [
40
47
  'ssh-rsa',
@@ -98,6 +105,20 @@ export default function renderX11 ({ form }) {
98
105
  ))}
99
106
  </Select>
100
107
  </FormItem>
108
+ <FormItem
109
+ {...formItemLayout}
110
+ label='compress'
111
+ name='compress'
112
+ >
113
+ <Select
114
+ mode='tags'
115
+ style={{ width: '100%' }}
116
+ >
117
+ {compressOptions.map(c => (
118
+ <Option key={c} value={c}>{c}</Option>
119
+ ))}
120
+ </Select>
121
+ </FormItem>
101
122
  <FormItem
102
123
  {...formItemLayout}
103
124
  label='serverHostKey'
@@ -24,6 +24,7 @@ const sshConfig = {
24
24
  sshAgent: '',
25
25
  serverHostKey: [],
26
26
  cipher: [],
27
+ compress: [],
27
28
  ...getTerminalDefaults(store),
28
29
  ...getSshDefaults(),
29
30
  ...getTerminalBackgroundDefaults(defaultSetting),
@@ -2,8 +2,9 @@
2
2
  height 80px
3
3
  .logo-img-small
4
4
  height 28px
5
+ border-radius 28px
5
6
  @media (max-width: 500px)
6
7
  .logo-img
7
8
  height 40px
8
9
  .logo-img-small
9
- height 20px
10
+ height 20px
@@ -232,7 +232,8 @@ export default auto(function Index (props) {
232
232
  tabs: store.getTabs(),
233
233
  activeTabId: store.activeTabId,
234
234
  showAIConfig: store.showAIConfig,
235
- rightPanelTab
235
+ rightPanelTab,
236
+ agentRunning: store.agentRunning
236
237
  }
237
238
  const cmdSuggestionsProps = {
238
239
  suggestions: store.terminalCommandSuggestions
@@ -28,7 +28,6 @@ import { FileTransferManager, createFileLogger } from './file-transfer'
28
28
  import { notification } from '../common/notification'
29
29
  import message from '../common/message'
30
30
  import ShowItem from '../common/show-item'
31
- import importRetry from '../../common/import-retry'
32
31
  import './rdp.styl'
33
32
 
34
33
  const { Option } = Select
@@ -36,7 +35,7 @@ const { Option } = Select
36
35
  async function loadWasmModule () {
37
36
  if (window.ironRdp) return
38
37
  console.debug('[RDP-CLIENT] Loading IronRDP WASM module...')
39
- const mod = await importRetry(() => import('ironrdp-wasm'))
38
+ const mod = await import('ironrdp-wasm')
40
39
  window.ironRdp = {
41
40
  wasmInit: mod.default,
42
41
  wasmSetup: mod.setup,
@@ -11,14 +11,12 @@ import {
11
11
  settingMap,
12
12
  modals
13
13
  } from '../../common/constants'
14
- import importRetry from '../../common/import-retry'
15
-
16
- const TabBookmarks = lazy(() => importRetry(() => import('./tab-bookmarks')))
17
- const TabQuickCommands = lazy(() => importRetry(() => import('./tab-quick-commands')))
18
- const TabSettings = lazy(() => importRetry(() => import('./tab-settings')))
19
- const TabThemes = lazy(() => importRetry(() => import('./tab-themes')))
20
- const TabProfiles = lazy(() => importRetry(() => import('./tab-profiles')))
21
- const TabWidgets = lazy(() => importRetry(() => import('./tab-widgets')))
14
+ const TabBookmarks = lazy(() => import('./tab-bookmarks'))
15
+ const TabQuickCommands = lazy(() => import('./tab-quick-commands'))
16
+ const TabSettings = lazy(() => import('./tab-settings'))
17
+ const TabThemes = lazy(() => import('./tab-themes'))
18
+ const TabProfiles = lazy(() => import('./tab-profiles'))
19
+ const TabWidgets = lazy(() => import('./tab-widgets'))
22
20
 
23
21
  const Loading = () => <div style={{ padding: 20, textAlign: 'center' }}><Spin /></div>
24
22
 
@@ -1,7 +1,8 @@
1
1
  import { syncTypes } from '../../common/constants'
2
2
  import { useState } from 'react'
3
- import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
3
+ import { LoadingOutlined, ReloadOutlined, DiffOutlined } from '@ant-design/icons'
4
4
  import dayjs from 'dayjs'
5
+ import SyncDataCompare from './sync-data-compare'
5
6
 
6
7
  const e = window.translate
7
8
 
@@ -9,6 +10,7 @@ export default function ServerDataStatus (props) {
9
10
  const { store } = window
10
11
  const { type, status } = props
11
12
  const [loading, setLoading] = useState(false)
13
+ const [showCompare, setShowCompare] = useState(false)
12
14
  const token = store.getSyncToken(type)
13
15
  const gistId = store.getSyncGistId(type)
14
16
  const canSync = token && (gistId || type === 'custom' || type === 'cloud' || type === syncTypes.webdav)
@@ -20,6 +22,10 @@ export default function ServerDataStatus (props) {
20
22
  setLoading(false)
21
23
  }
22
24
 
25
+ function handleCompare () {
26
+ setShowCompare(!showCompare)
27
+ }
28
+
23
29
  function renderReloadButton () {
24
30
  if (loading) {
25
31
  return (
@@ -27,10 +33,19 @@ export default function ServerDataStatus (props) {
27
33
  )
28
34
  }
29
35
  return (
30
- <ReloadOutlined
31
- className='pointer mg1l hover-black'
32
- onClick={handleReload}
33
- />
36
+ <span>
37
+ <ReloadOutlined
38
+ className='pointer mg1r hover-black'
39
+ onClick={handleReload}
40
+ />
41
+ <span
42
+ className='pointer mg2l hover-black'
43
+ onClick={handleCompare}
44
+ >
45
+ <DiffOutlined className='mg1r' />
46
+ {e('compare') || 'compare'}
47
+ </span>
48
+ </span>
34
49
  )
35
50
  }
36
51
 
@@ -59,14 +74,17 @@ export default function ServerDataStatus (props) {
59
74
  } = status
60
75
 
61
76
  return (
62
- <p>
63
- <span className='mg1r'>{e('syncServerDataStatus')}:</span>
64
- <b className='mg1r'>{dayjs(lastSyncTime).format('YYYY-MM-DD HH:mm:ss')}</b>
65
- <span className='mg1r'>{e('from')}:</span>
66
- <b className='mg1r'>{deviceName}</b>
67
- <b className='mg1r'>(v{electermVersion})</b>
68
- {renderReloadButton()}
69
- </p>
77
+ <div>
78
+ <p>
79
+ <span className='mg1r'>{e('syncServerDataStatus')}:</span>
80
+ <b className='mg1r'>{dayjs(lastSyncTime).format('YYYY-MM-DD HH:mm:ss')}</b>
81
+ <span className='mg1r'>{e('from')}:</span>
82
+ <b className='mg1r'>{deviceName}</b>
83
+ <b className='mg1r'>(v{electermVersion})</b>
84
+ {renderReloadButton()}
85
+ </p>
86
+ {showCompare && <SyncDataCompare syncType={type} />}
87
+ </div>
70
88
  )
71
89
  }
72
90
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Sync data comparison component
3
+ * Shows simple diff suggestions
4
+ */
5
+
6
+ import { useState, useEffect } from 'react'
7
+ import { Spin } from 'antd'
8
+
9
+ const e = window.translate
10
+
11
+ export default function SyncDataCompare (props) {
12
+ const { store } = window
13
+ const { syncType } = props
14
+ const [loading, setLoading] = useState(false)
15
+ const [comparison, setComparison] = useState(null)
16
+
17
+ useEffect(() => {
18
+ loadComparison()
19
+ }, [syncType])
20
+
21
+ async function loadComparison () {
22
+ setLoading(true)
23
+ try {
24
+ const result = await store.previewServerDataWithCompare(syncType)
25
+ setComparison(result)
26
+ } catch (err) {
27
+ console.error('Failed to load comparison:', err)
28
+ }
29
+ setLoading(false)
30
+ }
31
+
32
+ if (!comparison) {
33
+ return null
34
+ }
35
+
36
+ const { comparison: comp } = comparison
37
+
38
+ // Filter only items with differences
39
+ const diffs = comp.filter(item => item.onlyLocal > 0 || item.onlyServer > 0)
40
+
41
+ if (diffs.length === 0) {
42
+ return (
43
+ <p className='mg1t sync-diff-text'>
44
+ {e('dataInSync') || 'Data in sync'}
45
+ </p>
46
+ )
47
+ }
48
+
49
+ const nameMap = {
50
+ bookmarks: e('bookmarks') || 'Bookmarks',
51
+ bookmarkGroups: 'Bookmark Groups',
52
+ terminalThemes: e('terminalThemes') || 'Terminal Themes',
53
+ quickCommands: e('quickCommands') || 'Quick Commands',
54
+ profiles: e('profiles') || 'Profiles',
55
+ addressBookmarks: e('addressBookmarks') || 'Address Bookmarks',
56
+ workspaces: e('workspaces') || 'Workspaces'
57
+ }
58
+
59
+ const lines = diffs.map(item => {
60
+ const displayName = nameMap[item.name] || item.name
61
+ const localCount = item.localCount
62
+ const serverCount = item.serverCount
63
+ const diff = serverCount - localCount
64
+ let action = ''
65
+ if (diff > 0) {
66
+ action = e('download') || 'download'
67
+ } else if (diff < 0) {
68
+ action = e('upload') || 'upload'
69
+ }
70
+ return {
71
+ text: `${e('remote') || 'remote'}: ${serverCount} ${displayName}, ${e('local') || 'local'}: ${localCount} ${displayName}`,
72
+ action
73
+ }
74
+ })
75
+
76
+ return (
77
+ <div className='sync-data-compare mg1t mg2b'>
78
+ <Spin spinning={loading}>
79
+ <div className='sync-diff-text'>
80
+ {lines.map((line, i) => (
81
+ <p key={i} className='mg0'>
82
+ {line.text}
83
+ {line.action && (
84
+ <span className='sync-suggest-action'> {'->'} {line.action} ?</span>
85
+ )}
86
+ </p>
87
+ ))}
88
+ </div>
89
+ </Spin>
90
+ </div>
91
+ )
92
+ }
@@ -15,12 +15,11 @@ import {
15
15
  } from 'antd'
16
16
  import * as ls from '../../common/safe-local-storage'
17
17
  import RemoteFloatControl from '../common/remote-float-control'
18
- import importRetry from '../../common/import-retry'
19
18
  import './spice.styl'
20
19
 
21
20
  async function loadSpiceModule () {
22
21
  if (window.spiceHtml5) return
23
- const mod = await importRetry(() => import('spice-client'))
22
+ const mod = await import('spice-client')
24
23
  window.spiceHtml5 = {
25
24
  SpiceMainConn: mod.SpiceMainConn,
26
25
  sendCtrlAltDel: mod.sendCtrlAltDel
@@ -6,13 +6,12 @@ import { PureComponent } from 'react'
6
6
  import {
7
7
  Popover
8
8
  } from 'antd'
9
- import logoRef from '@electerm/electerm-resource/res/imgs/electerm.svg'
9
+ import logoSvg from '@electerm/electerm-resource/res/imgs/electerm.svg?raw'
10
10
  import { shortcutDescExtend } from '../shortcuts/shortcut-handler.js'
11
11
  import MenuRender from './sys-menu.jsx'
12
12
  import { refsStatic } from '../common/ref.js'
13
13
 
14
14
  const e = window.translate
15
- const logo = logoRef.replace(/^\//, '')
16
15
 
17
16
  class MenuBtn extends PureComponent {
18
17
  componentDidMount () {
@@ -202,7 +201,10 @@ class MenuBtn extends PureComponent {
202
201
  <div
203
202
  {...pops}
204
203
  >
205
- <img src={logo} width={28} height={28} />
204
+ <span
205
+ className='menu-logo'
206
+ dangerouslySetInnerHTML={{ __html: logoSvg }}
207
+ />
206
208
  </div>
207
209
  </Popover>
208
210
  )
@@ -68,17 +68,23 @@
68
68
  width 28px
69
69
  height 28px
70
70
  border-radius 30px
71
- color var(--text-light)
71
+ color var(--text)
72
72
  font-size 16px
73
73
  text-align center
74
74
  display inline-block
75
75
  line-height 28px
76
76
  cursor pointer
77
+ color var(--text)
78
+
79
+ .menu-logo
80
+ display inline-block
81
+ width 28px
82
+ height 28px
83
+ vertical-align middle
84
+ color var(--text-disabled)
77
85
  &:hover
78
86
  color var(--text)
79
- .is-main
80
- .menu-control
81
- img
82
- border 1px solid var(--text-dark)
83
- border-radius 28px
84
- background #000
87
+ svg
88
+ fill currentColor
89
+ width 100%
90
+ height 100%
@@ -90,7 +90,7 @@ export default function TermInteractiveUI ({
90
90
  type='primary'
91
91
  onClick={onConfirm}
92
92
  >
93
- {opts.options.submitText || e('submit')}
93
+ {opts.options.submitText || e('save')}
94
94
  </Button>
95
95
  <Button
96
96
  className='mg1l'
@@ -1,5 +1,3 @@
1
- import importRetry from '../../common/import-retry'
2
-
3
1
  window.xtermAddons = window.xtermAddons || {}
4
2
 
5
3
  let xtermCssLoaded = false
@@ -7,76 +5,76 @@ let xtermCssLoaded = false
7
5
  function loadXtermCss () {
8
6
  if (xtermCssLoaded) return
9
7
  xtermCssLoaded = true
10
- importRetry(() => import('@xterm/xterm/css/xterm.css'))
8
+ import('@xterm/xterm/css/xterm.css')
11
9
  }
12
10
 
13
11
  export async function loadTerminal () {
14
12
  if (window.xtermAddons.Terminal) return window.xtermAddons.Terminal
15
13
  loadXtermCss()
16
- const mod = await importRetry(() => import('@xterm/xterm'))
14
+ const mod = await import('@xterm/xterm')
17
15
  window.xtermAddons.Terminal = mod.Terminal
18
16
  return window.xtermAddons.Terminal
19
17
  }
20
18
 
21
19
  export async function loadFitAddon () {
22
20
  if (window.xtermAddons.FitAddon) return window.xtermAddons.FitAddon
23
- const mod = await importRetry(() => import('@xterm/addon-fit'))
21
+ const mod = await import('@xterm/addon-fit')
24
22
  window.xtermAddons.FitAddon = mod.FitAddon
25
23
  return window.xtermAddons.FitAddon
26
24
  }
27
25
 
28
26
  export async function loadAttachAddon () {
29
27
  if (window.xtermAddons.AttachAddon) return window.xtermAddons.AttachAddon
30
- const mod = await importRetry(() => import('@xterm/addon-attach'))
28
+ const mod = await import('@xterm/addon-attach')
31
29
  window.xtermAddons.AttachAddon = mod.AttachAddon
32
30
  return window.xtermAddons.AttachAddon
33
31
  }
34
32
 
35
33
  export async function loadWebLinksAddon () {
36
34
  if (window.xtermAddons.WebLinksAddon) return window.xtermAddons.WebLinksAddon
37
- const mod = await importRetry(() => import('@xterm/addon-web-links'))
35
+ const mod = await import('@xterm/addon-web-links')
38
36
  window.xtermAddons.WebLinksAddon = mod.WebLinksAddon
39
37
  return window.xtermAddons.WebLinksAddon
40
38
  }
41
39
 
42
40
  export async function loadCanvasAddon () {
43
41
  if (window.xtermAddons.CanvasAddon) return window.xtermAddons.CanvasAddon
44
- const mod = await importRetry(() => import('@xterm/addon-canvas'))
42
+ const mod = await import('@xterm/addon-canvas')
45
43
  window.xtermAddons.CanvasAddon = mod.CanvasAddon
46
44
  return window.xtermAddons.CanvasAddon
47
45
  }
48
46
 
49
47
  export async function loadWebglAddon () {
50
48
  if (window.xtermAddons.WebglAddon) return window.xtermAddons.WebglAddon
51
- const mod = await importRetry(() => import('@xterm/addon-webgl'))
49
+ const mod = await import('@xterm/addon-webgl')
52
50
  window.xtermAddons.WebglAddon = mod.WebglAddon
53
51
  return window.xtermAddons.WebglAddon
54
52
  }
55
53
 
56
54
  export async function loadSearchAddon () {
57
55
  if (window.xtermAddons.SearchAddon) return window.xtermAddons.SearchAddon
58
- const mod = await importRetry(() => import('@xterm/addon-search'))
56
+ const mod = await import('@xterm/addon-search')
59
57
  window.xtermAddons.SearchAddon = mod.SearchAddon
60
58
  return window.xtermAddons.SearchAddon
61
59
  }
62
60
 
63
61
  export async function loadLigaturesAddon () {
64
62
  if (window.xtermAddons.LigaturesAddon) return window.xtermAddons.LigaturesAddon
65
- const mod = await importRetry(() => import('@xterm/addon-ligatures'))
63
+ const mod = await import('@xterm/addon-ligatures')
66
64
  window.xtermAddons.LigaturesAddon = mod.LigaturesAddon
67
65
  return window.xtermAddons.LigaturesAddon
68
66
  }
69
67
 
70
68
  export async function loadUnicode11Addon () {
71
69
  if (window.xtermAddons.Unicode11Addon) return window.xtermAddons.Unicode11Addon
72
- const mod = await importRetry(() => import('@xterm/addon-unicode11'))
70
+ const mod = await import('@xterm/addon-unicode11')
73
71
  window.xtermAddons.Unicode11Addon = mod.Unicode11Addon
74
72
  return window.xtermAddons.Unicode11Addon
75
73
  }
76
74
 
77
75
  export async function loadImageAddon () {
78
76
  if (window.xtermAddons.ImageAddon) return window.xtermAddons.ImageAddon
79
- const mod = await importRetry(() => import('@xterm/addon-image'))
77
+ const mod = await import('@xterm/addon-image')
80
78
  window.xtermAddons.ImageAddon = mod.ImageAddon
81
79
  return window.xtermAddons.ImageAddon
82
80
  }
@@ -1,7 +1,6 @@
1
1
  import { lazy, Suspense } from 'react'
2
- import importRetry from '../../common/import-retry'
3
2
 
4
- const TerminalInfo = lazy(() => importRetry(() => import('./terminal-info')))
3
+ const TerminalInfo = lazy(() => import('./terminal-info'))
5
4
 
6
5
  export default function TerminalInfoEntry (props) {
7
6
  return (
@@ -1,7 +1,6 @@
1
1
  import { lazy, Suspense } from 'react'
2
- import importRetry from '../../common/import-retry'
3
2
 
4
- const TextEditor = lazy(() => importRetry(() => import('./text-editor')))
3
+ const TextEditor = lazy(() => import('./text-editor'))
5
4
 
6
5
  export default function TextEditorEntry (props) {
7
6
  return (
@@ -18,14 +18,13 @@ import Modal from '../common/modal'
18
18
  import { copy } from '../../common/clipboard'
19
19
  import VncForm from './vnc-form'
20
20
  import RemoteFloatControl from '../common/remote-float-control'
21
- import importRetry from '../../common/import-retry'
22
21
  import './vnc.styl'
23
22
 
24
23
  // noVNC module imports — loaded dynamically
25
24
  async function loadVncModule () {
26
25
  if (window.novnc) return
27
26
  console.debug('[VNC-CLIENT] Loading noVNC module...')
28
- const mod = await importRetry(() => import('@novnc/novnc/core/rfb'))
27
+ const mod = await import('@novnc/novnc/core/rfb')
29
28
  window.novnc = {
30
29
  RFB: mod.default
31
30
  }
@@ -40,4 +40,9 @@ a
40
40
  color var(--text-dark)
41
41
 
42
42
  .cap
43
- text-transform capitalize
43
+ text-transform capitalize
44
+
45
+ .sync-data-compare
46
+ .sync-suggest-action
47
+ color var(--success)
48
+ font-weight 500
@@ -87,6 +87,7 @@ export default () => {
87
87
  // batch input selected tab ids
88
88
  _batchInputSelectedTabIds: new Set(),
89
89
  aiChatHistory: [],
90
+ agentRunning: false,
90
91
 
91
92
  // sftp
92
93
  fileOperation: fileOperationsMap.cp, // cp or mv
@@ -24,6 +24,9 @@ export default Store => {
24
24
  const current = !store.pinned
25
25
  ls.setItem(sidebarPinnedKey, current + '')
26
26
  store.pinned = current
27
+ if (!current) {
28
+ store.setOpenedSideBar('')
29
+ }
27
30
  }
28
31
 
29
32
  Store.prototype.handleSidebarPanelTab = function (tab) {
@@ -234,7 +234,7 @@ export default (Store) => {
234
234
  const status = statusContent ? parseJsonSafe(statusContent) : undefined
235
235
  store.syncServerStatus[type] = status
236
236
  }
237
- return
237
+ return gist
238
238
  }
239
239
 
240
240
  const gist = await fetchData(
@@ -245,6 +245,103 @@ export default (Store) => {
245
245
  store.getSyncProxy(type)
246
246
  )
247
247
  updateSyncServerStatusFromGist(store, gist, type)
248
+ return gist
249
+ }
250
+
251
+ Store.prototype.previewServerDataWithCompare = async function (type) {
252
+ const { store } = window
253
+ const token = store.getSyncToken(type)
254
+ const gistId = store.getSyncGistId(type)
255
+ const pass = store.getSyncPassword(type)
256
+ const { names } = store.getDataSyncNames()
257
+
258
+ // Get server data
259
+ let serverGist
260
+ if (type === syncTypes.webdav) {
261
+ serverGist = await fetchData(
262
+ type,
263
+ 'download',
264
+ [],
265
+ token,
266
+ store.getSyncProxy(type)
267
+ )
268
+ } else {
269
+ serverGist = await fetchData(
270
+ type,
271
+ 'getOne',
272
+ [gistId],
273
+ token,
274
+ store.getSyncProxy(type)
275
+ )
276
+ }
277
+
278
+ // Update status
279
+ if (type === syncTypes.webdav) {
280
+ if (serverGist && serverGist.files) {
281
+ const statusContent = get(serverGist, 'files["electerm-status.json"].content')
282
+ const status = statusContent ? parseJsonSafe(statusContent) : undefined
283
+ store.syncServerStatus[type] = status
284
+ }
285
+ } else {
286
+ updateSyncServerStatusFromGist(store, serverGist, type)
287
+ }
288
+
289
+ // Compare data
290
+ const comparison = []
291
+ const localData = {}
292
+ const serverData = {}
293
+
294
+ for (const n of names) {
295
+ // Get local data
296
+ const localItems = store.getItems(n)
297
+ localData[n] = localItems
298
+
299
+ // Get server data
300
+ let serverStr
301
+ if (type === syncTypes.webdav) {
302
+ serverStr = get(serverGist, `files["${n}.json"].content`)
303
+ } else {
304
+ serverStr = get(serverGist, `files["${n}.json"].content`)
305
+ }
306
+
307
+ let serverItems = []
308
+ if (serverStr) {
309
+ try {
310
+ if (!isJSON(serverStr)) {
311
+ serverStr = await window.pre.runGlobalAsync('decryptAsync', serverStr, pass)
312
+ }
313
+ serverItems = JSON.parse(serverStr)
314
+ } catch (e) {
315
+ console.error(`Failed to parse server data for ${n}:`, e)
316
+ }
317
+ }
318
+ serverData[n] = serverItems
319
+
320
+ // Find unique items
321
+ const localIds = new Set(localItems.map(item => item.id))
322
+ const serverIds = new Set(serverItems.map(item => item.id))
323
+
324
+ const onlyLocal = localItems.filter(item => !serverIds.has(item.id))
325
+ const onlyServer = serverItems.filter(item => !localIds.has(item.id))
326
+ const common = localItems.filter(item => serverIds.has(item.id))
327
+
328
+ comparison.push({
329
+ name: n,
330
+ localCount: localItems.length,
331
+ serverCount: serverItems.length,
332
+ onlyLocal: onlyLocal.length,
333
+ onlyServer: onlyServer.length,
334
+ common: common.length,
335
+ localItems: onlyLocal,
336
+ serverItems: onlyServer
337
+ })
338
+ }
339
+
340
+ return {
341
+ localData,
342
+ serverData,
343
+ comparison
344
+ }
248
345
  }
249
346
 
250
347
  Store.prototype.uploadSettingAction = async function (type) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.50",
3
+ "version": "3.15.66",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",
@@ -1,23 +0,0 @@
1
- /**
2
- * Retry wrapper for dynamic import() calls
3
- * Handles transient "Failed to fetch" errors that can occur
4
- * when the app starts and chunks are fetched before the
5
- * network/server is fully ready
6
- */
7
-
8
- const MAX_RETRIES = 3
9
- const RETRY_DELAY = 500
10
-
11
- function sleep (ms) {
12
- return new Promise(resolve => setTimeout(resolve, ms))
13
- }
14
-
15
- export default function importRetry (factory, retries = MAX_RETRIES) {
16
- return factory().catch(async (err) => {
17
- if (retries <= 0) {
18
- throw err
19
- }
20
- await sleep(RETRY_DELAY)
21
- return importRetry(factory, retries - 1)
22
- })
23
- }