@electerm/electerm-react 5.5.15 → 5.5.35

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 (64) hide show
  1. package/README.md +14 -4
  2. package/client/common/bookmark-schemas.js +3 -0
  3. package/client/common/constants.js +16 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +4 -2
  6. package/client/common/parse-vv.js +407 -0
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +19 -4
  11. package/client/components/ai/agent.js +33 -16
  12. package/client/components/ai/ai-chat-history-item.jsx +12 -48
  13. package/client/components/ai/ai-chat.jsx +82 -25
  14. package/client/components/ai/ai-config-props.js +12 -1
  15. package/client/components/ai/ai-config.jsx +14 -0
  16. package/client/components/ai/ai-context-indicator.jsx +83 -0
  17. package/client/components/ai/ai-context.js +272 -0
  18. package/client/components/ai/ai-output.jsx +4 -0
  19. package/client/components/ai/ai-presets.js +13 -2
  20. package/client/components/ai/ai.styl +105 -6
  21. package/client/components/ai/copy-icon.jsx +31 -0
  22. package/client/components/bg/css-overwrite.jsx +16 -1
  23. package/client/components/bookmark-form/common/fields.jsx +3 -0
  24. package/client/components/bookmark-form/common/vv-file-field.jsx +138 -0
  25. package/client/components/bookmark-form/config/spice.js +22 -0
  26. package/client/components/footer/cmd-history.jsx +279 -65
  27. package/client/components/footer/cmd-history.styl +68 -1
  28. package/client/components/footer/footer-entry.jsx +46 -15
  29. package/client/components/icons/ai-icon.styl +4 -1
  30. package/client/components/layout/layout.jsx +12 -13
  31. package/client/components/main/main.jsx +23 -3
  32. package/client/components/main/term-fullscreen.styl +7 -0
  33. package/client/components/quick-commands/qm.styl +58 -0
  34. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  35. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  36. package/client/components/quick-commands/quick-commands-box.jsx +197 -82
  37. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  38. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  39. package/client/components/setting-panel/list.jsx +2 -2
  40. package/client/components/setting-panel/setting-common.jsx +2 -0
  41. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  42. package/client/components/side-panel-r/side-panel-r.jsx +56 -13
  43. package/client/components/sidebar/bookmark-select.jsx +17 -3
  44. package/client/components/sidebar/history-item.jsx +20 -4
  45. package/client/components/sidebar/history.jsx +7 -0
  46. package/client/components/sidebar/index.jsx +13 -4
  47. package/client/components/sidebar/side-panel.jsx +8 -3
  48. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  49. package/client/components/terminal/shortcut-bar.styl +14 -0
  50. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  51. package/client/components/terminal-info/terminal-info.styl +3 -0
  52. package/client/components/theme/terminal-theme-list.styl +3 -0
  53. package/client/components/theme/theme-ai-editor.jsx +2 -2
  54. package/client/components/theme/theme-form.jsx +2 -2
  55. package/client/components/theme/theme-list-item.jsx +59 -25
  56. package/client/components/tree-list/tree-list.jsx +31 -3
  57. package/client/css/mobile.styl +28 -0
  58. package/client/store/common.js +164 -32
  59. package/client/store/init-state.js +10 -1
  60. package/client/store/load-data.js +72 -0
  61. package/client/store/sidebar.js +5 -1
  62. package/client/store/store.js +15 -2
  63. package/client/store/watch.js +3 -1
  64. package/package.json +1 -1
@@ -0,0 +1,272 @@
1
+ // Context accounting for the AI panel: how big is the conversation we are
2
+ // about to send, and how much of the model's context window does it fill.
3
+ //
4
+ // There is deliberately no tokenizer here. A real one needs the model's
5
+ // vocabulary (tiktoken & friends) and would pull a dependency into the
6
+ // renderer for a number that only has to be good enough to answer "should I
7
+ // compress?". The heuristic below is CJK aware and deliberately pessimistic,
8
+ // so the warning arrives a little early rather than too late. Where the
9
+ // provider reports real usage we show that number alongside the estimate.
10
+ //
11
+ // Pure module -- no window / store access -- so it can be unit tested and so
12
+ // the sender and the indicator cannot drift apart.
13
+
14
+ // Latin text and code average ~4 chars per token; 3.5 is the safe side.
15
+ const CHARS_PER_TOKEN = 3.5
16
+ // CJK is roughly one token per character (a little under for Chinese).
17
+ const TOKENS_PER_CJK_CHAR = 0.8
18
+ // Per message role/delimiter overhead, as counted by the OpenAI tokenizer.
19
+ const PER_MESSAGE_OVERHEAD = 4
20
+
21
+ // BMP ranges that tokenize densely: CJK ideographs, kana, full width forms.
22
+ const CJK_RE = /[\u2e80-\u2eff\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/g
23
+
24
+ export const DEFAULT_CONTEXT_WINDOW = 128000
25
+ export const CONTEXT_WARN_PERCENT = 70
26
+ export const CONTEXT_DANGER_PERCENT = 90
27
+
28
+ // Rough token count of a string. Never exact, never wildly wrong. Called on
29
+ // the whole conversation every time it changes, so it stays a single pass
30
+ // with one regex sweep rather than a per character test.
31
+ export function estimateTokens (text) {
32
+ if (!text) {
33
+ return 0
34
+ }
35
+ const str = typeof text === 'string' ? text : String(text)
36
+ const cjkChars = str.match(CJK_RE)
37
+ const cjk = cjkChars ? cjkChars.length : 0
38
+ const other = str.length - cjk
39
+ return Math.ceil(cjk * TOKENS_PER_CJK_CHAR + other / CHARS_PER_TOKEN)
40
+ }
41
+
42
+ // Tokens for a message array in the OpenAI chat shape (which is also what the
43
+ // anthropic / responses builders are fed before they re-shape it).
44
+ export function estimateMessagesTokens (messages) {
45
+ if (!Array.isArray(messages)) {
46
+ return 0
47
+ }
48
+ let total = 0
49
+ for (const m of messages) {
50
+ if (!m) {
51
+ continue
52
+ }
53
+ total += PER_MESSAGE_OVERHEAD
54
+ total += estimateTokens(m.content)
55
+ if (m.role) {
56
+ total += estimateTokens(m.role)
57
+ }
58
+ if (Array.isArray(m.tool_calls)) {
59
+ for (const tc of m.tool_calls) {
60
+ total += estimateTokens(tc && tc.id)
61
+ total += estimateTokens(tc && tc.function && tc.function.name)
62
+ total += estimateTokens(tc && tc.function && tc.function.arguments)
63
+ }
64
+ }
65
+ }
66
+ return total
67
+ }
68
+
69
+ // Tool schemas are re-sent with every agent request and are far from free --
70
+ // ignoring them would understate agent mode by a few thousand tokens.
71
+ export function estimateToolsTokens (tools) {
72
+ if (!Array.isArray(tools) || !tools.length) {
73
+ return 0
74
+ }
75
+ try {
76
+ return estimateTokens(JSON.stringify(tools))
77
+ } catch (e) {
78
+ return 0
79
+ }
80
+ }
81
+
82
+ // Best effort hints, checked 2026-09. No provider publishes the window over
83
+ // the API, and relays rename models freely, so this is only a starting point:
84
+ // the "Context length" field in the AI config always wins over it.
85
+ // Longest / most specific pattern first -- the list is scanned in order.
86
+ const MODEL_CONTEXT_WINDOWS = [
87
+ [/qwen-?long/, 10000000],
88
+
89
+ // 1M class
90
+ [/gpt-6|gpt-5\.6|gpt-5-6/, 1050000],
91
+ [/gemini/, 1048576],
92
+ [/claude-(opus|sonnet|fable)-5/, 1000000],
93
+ [/deepseek-v4/, 1000000],
94
+ [/qwen3\.(7|8)|qwen3-max|qwen3\.5-max/, 1000000],
95
+ [/kimi-?k3/, 1048576],
96
+
97
+ // 500k / 400k / 256k class
98
+ [/grok-4\.6/, 500000],
99
+ [/gpt-5/, 400000],
100
+ [/kimi-?k2\.7/, 262144],
101
+ [/grok-4/, 256000],
102
+
103
+ // 200k class
104
+ [/claude/, 200000],
105
+ [/\bo[134]\b/, 200000],
106
+
107
+ // 128k class
108
+ [/gpt-4\.1|gpt-4o|gpt-4-turbo|gpt-4-1106|gpt-4-0125/, 128000],
109
+ [/llama-?[34]/, 131072],
110
+ [/qwen/, 131072],
111
+ [/kimi|moonshot/, 131072],
112
+ [/glm|zhipu|chatglm/, 131072],
113
+ [/ernie|wenxin/, 131072],
114
+ [/grok/, 131072],
115
+ [/mistral-large/, 131072],
116
+
117
+ // older / smaller
118
+ [/deepseek/, 65536],
119
+ [/gpt-4-32k/, 32768],
120
+ [/mixtral|mistral/, 32768],
121
+ [/gpt-3\.5/, 16385],
122
+ [/gpt-4/, 8192],
123
+ [/llama-?2/, 4096]
124
+ ]
125
+
126
+ // Returns { size, source } where source is 'config' | 'model' | 'default'.
127
+ export function getContextWindow (model, override) {
128
+ const declared = Number(override)
129
+ if (declared > 0) {
130
+ return { size: declared, source: 'config' }
131
+ }
132
+ const id = String(model || '').toLowerCase()
133
+ for (const [re, size] of MODEL_CONTEXT_WINDOWS) {
134
+ if (re.test(id)) {
135
+ return { size, source: 'model' }
136
+ }
137
+ }
138
+ return { size: DEFAULT_CONTEXT_WINDOW, source: 'default' }
139
+ }
140
+
141
+ // The single source of truth for "what gets sent": both the request in
142
+ // ai-chat-history-item.jsx and the indicator in ai-chat.jsx go through here,
143
+ // so the number on screen is the number that leaves the machine.
144
+ //
145
+ // Returns null when there is no session to build from -- the sender reads
146
+ // that as "no history", and the main process then falls back to a plain
147
+ // prompt + role request.
148
+ //
149
+ // `upto` limits the window to entries at or before a timestamp (the entry
150
+ // being sent); `excludeId` drops one entry's response, which is what the
151
+ // sender does for its own turn. The indicator omits both to describe the
152
+ // *next* request, which will include every response so far.
153
+ export function buildSessionMessages ({
154
+ history,
155
+ chatSessionId,
156
+ upto = Infinity,
157
+ role = '',
158
+ excludeId = null
159
+ }) {
160
+ if (!chatSessionId) {
161
+ return null
162
+ }
163
+ const entries = (history || [])
164
+ .filter(h => h.chatSessionId === chatSessionId && h.timestamp <= upto)
165
+ .sort((a, b) => a.timestamp - b.timestamp)
166
+
167
+ // A compressed entry replaces everything before it.
168
+ let lastCompressIndex = -1
169
+ for (let i = entries.length - 1; i >= 0; i--) {
170
+ if (entries[i].compressed) {
171
+ lastCompressIndex = i
172
+ break
173
+ }
174
+ }
175
+
176
+ const messages = [
177
+ { role: 'system', content: role }
178
+ ]
179
+
180
+ const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
181
+ for (let i = startIndex; i < entries.length; i++) {
182
+ const entry = entries[i]
183
+ if (entry.compressed) {
184
+ messages.push({
185
+ role: 'user',
186
+ content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
187
+ })
188
+ messages.push({
189
+ role: 'assistant',
190
+ content: 'Understood. I will use this context as we continue.'
191
+ })
192
+ } else {
193
+ messages.push({
194
+ role: 'user',
195
+ // promptWithAttachments carries the inlined file blocks;
196
+ // falls back to the plain prompt for legacy entries
197
+ content: entry.promptWithAttachments || entry.prompt
198
+ })
199
+ if (entry.response && entry.id !== excludeId) {
200
+ messages.push({ role: 'assistant', content: entry.response })
201
+ }
202
+ }
203
+ }
204
+ return messages
205
+ }
206
+
207
+ // Agent mode keeps the session history but swaps in its own system prompt.
208
+ export function buildAgentMessages ({ systemPrompt, conversationMessages, prompt }) {
209
+ if (conversationMessages && conversationMessages.length > 1) {
210
+ return [
211
+ { role: 'system', content: systemPrompt },
212
+ ...conversationMessages.filter(m => m.role !== 'system')
213
+ ]
214
+ }
215
+ return [
216
+ { role: 'system', content: systemPrompt },
217
+ { role: 'user', content: prompt }
218
+ ]
219
+ }
220
+
221
+ // Everything the indicator needs, from the messages that will be sent.
222
+ export function summarizeContext (messages, { model, contextLength, tools } = {}) {
223
+ const messagesTokens = estimateMessagesTokens(messages)
224
+ const toolsTokens = estimateToolsTokens(tools)
225
+ const tokens = messagesTokens + toolsTokens
226
+ const win = getContextWindow(model, contextLength)
227
+ return {
228
+ tokens,
229
+ messagesTokens,
230
+ toolsTokens,
231
+ windowSize: win.size,
232
+ windowSource: win.source,
233
+ percent: win.size ? (tokens / win.size) * 100 : null,
234
+ model: model || '',
235
+ measuredTokens: null
236
+ }
237
+ }
238
+
239
+ export function formatTokens (n) {
240
+ const v = Number(n) || 0
241
+ if (v < 1000) {
242
+ return String(Math.round(v))
243
+ }
244
+ if (v < 10000) {
245
+ return (v / 1000).toFixed(1) + 'k'
246
+ }
247
+ if (v < 1000000) {
248
+ return Math.round(v / 1000) + 'k'
249
+ }
250
+ return (v / 1000000).toFixed(1) + 'M'
251
+ }
252
+
253
+ export function formatPercent (p) {
254
+ if (p === null || p === undefined || !isFinite(p)) {
255
+ return ''
256
+ }
257
+ // below 10% a whole number would just read as 0%
258
+ return (p > 0 && p < 10 ? p.toFixed(1) : String(Math.round(p))) + '%'
259
+ }
260
+
261
+ export function getUsageLevel (percent) {
262
+ if (percent === null || percent === undefined || !isFinite(percent)) {
263
+ return 'unknown'
264
+ }
265
+ if (percent >= CONTEXT_DANGER_PERCENT) {
266
+ return 'danger'
267
+ }
268
+ if (percent >= CONTEXT_WARN_PERCENT) {
269
+ return 'warn'
270
+ }
271
+ return 'ok'
272
+ }
@@ -10,6 +10,7 @@ import {
10
10
  FlagFilled
11
11
  } from '@ant-design/icons'
12
12
  import getBrand from './get-brand'
13
+ import CopyIcon from './copy-icon'
13
14
 
14
15
  const e = window.translate
15
16
 
@@ -160,6 +161,9 @@ export default function AIOutput ({ item }) {
160
161
  <>
161
162
  {renderBrand()}
162
163
  <ReactMarkdown {...mdProps} />
164
+ <div className='ai-output-actions'>
165
+ <CopyIcon text={response} />
166
+ </div>
163
167
  </>
164
168
  )}
165
169
  </div>
@@ -1,4 +1,13 @@
1
1
  export const defaultAIPresets = [
2
+ {
3
+ id: 'fluxionai',
4
+ nameAI: 'FluxionAI',
5
+ baseURLAI: 'https://fluxionai.world/v1',
6
+ apiPathAI: '/chat/completions',
7
+ modelAI: 'deepseek-v4-flash-0731',
8
+ authHeaderNameAI: 'Authorization: Bearer',
9
+ siteUrl: 'https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF'
10
+ },
2
11
  {
3
12
  id: 'atlascloud',
4
13
  nameAI: 'AtlasCloud',
@@ -166,8 +175,10 @@ export const defaultAIPresets = [
166
175
 
167
176
  export function getAIPresets () {
168
177
  const presets = [...defaultAIPresets]
169
- if (window.et?.defaultAIPreset) {
170
- presets.unshift(window.et.defaultAIPreset)
178
+ const customPreset = window.et?.defaultAIPreset
179
+ if (customPreset) {
180
+ // keep the first preset (sponsor) on top, insert after it
181
+ presets.splice(1, 0, customPreset)
171
182
  }
172
183
  return presets
173
184
  }
@@ -26,11 +26,26 @@
26
26
  border-radius 3px
27
27
  pre
28
28
  margin-bottom 0
29
- // .code-block-actions
30
- // display block
31
- // &:hover
32
- // .code-block-actions
33
- // display block
29
+
30
+ // copy icons stay visible all the time, never hover only
31
+ .copy-icon,
32
+ .code-action-icon
33
+ color var(--text-dark)
34
+ font-size 14px
35
+ &:hover
36
+ color var(--primary)
37
+
38
+ .code-block-actions
39
+ display flex
40
+ justify-content flex-end
41
+
42
+ // left aligned on purpose: the streaming stop/loading icon is pinned to the
43
+ // bottom right of .chat-history-item, so a right aligned copy icon would sit
44
+ // on top of it while a response is streaming
45
+ .ai-output-actions
46
+ display flex
47
+ justify-content flex-start
48
+ margin-top 6px
34
49
 
35
50
  .ai-stream-output
36
51
  position relative
@@ -223,9 +238,18 @@
223
238
  font-size 12px
224
239
  color var(--text-color-2)
225
240
 
241
+ .agent-tool-pre-wrap
242
+ position relative
243
+
244
+ .agent-tool-copy
245
+ position absolute
246
+ top 5px
247
+ right 6px
248
+ z-index 1
249
+
226
250
  .agent-tool-pre
227
251
  background var(--main-dark)
228
- padding 6px 8px
252
+ padding 6px 26px 6px 8px
229
253
  border-radius 3px
230
254
  font-size 12px
231
255
  max-height 200px
@@ -313,3 +337,78 @@
313
337
  &:hover
314
338
  border-color var(--primary)
315
339
  color var(--text)
340
+
341
+ // context size indicator, right hand side of the ai chat toolbar
342
+ .ai-context-indicator
343
+ display inline-flex
344
+ align-items center
345
+ gap 4px
346
+ flex-shrink 0
347
+ font-size 11px
348
+ line-height 1
349
+ color var(--text-color-2)
350
+ cursor default
351
+ user-select none
352
+
353
+ .ai-context-bar
354
+ display inline-block
355
+ width 34px
356
+ height 5px
357
+ border-radius 3px
358
+ background var(--main-darker)
359
+ overflow hidden
360
+ flex-shrink 0
361
+
362
+ .ai-context-bar-fill
363
+ display block
364
+ height 100%
365
+ border-radius 3px
366
+ background var(--primary)
367
+ transition width 0.2s
368
+
369
+ .ai-context-count
370
+ color var(--text-dark)
371
+ font-variant-numeric tabular-nums
372
+
373
+ .ai-context-percent
374
+ font-variant-numeric tabular-nums
375
+
376
+ .ai-context-warn
377
+ .ai-context-bar-fill
378
+ background #faad14
379
+ .ai-context-percent
380
+ color #faad14
381
+
382
+ .ai-context-danger
383
+ .ai-context-bar-fill
384
+ background #ff4d4f
385
+ .ai-context-percent
386
+ color #ff4d4f
387
+ font-weight 500
388
+
389
+ .ai-context-tip
390
+ font-size 12px
391
+ min-width 210px
392
+
393
+ .ai-context-tip-title
394
+ font-weight 500
395
+ margin-bottom 4px
396
+
397
+ .ai-context-tip-row
398
+ display flex
399
+ justify-content space-between
400
+ gap 12px
401
+
402
+ .ai-context-tip-total
403
+ font-weight 500
404
+ border-top 1px solid rgba(128, 128, 128, 0.35)
405
+ margin-top 2px
406
+ padding-top 2px
407
+
408
+ .ai-context-tip-note
409
+ margin-top 4px
410
+ opacity 0.75
411
+
412
+ .ai-context-tip-warn
413
+ margin-top 4px
414
+ color #faad14
@@ -0,0 +1,31 @@
1
+ import { CopyOutlined } from '@ant-design/icons'
2
+ import { copy } from '../../common/clipboard'
3
+
4
+ const e = window.translate
5
+
6
+ /**
7
+ * Always visible copy icon, shared by AI output and agent tool call blocks
8
+ * @param {Object} props
9
+ * @param {string} props.text - text to copy
10
+ * @param {string} props.className - extra class for positioning
11
+ * @param {string} props.title - tooltip title
12
+ */
13
+ export default function CopyIcon ({ text, className = '', title }) {
14
+ function handleCopy (evt) {
15
+ // agent tool card header toggles expanded state on click
16
+ evt.stopPropagation()
17
+ copy(text)
18
+ }
19
+
20
+ if (!text) {
21
+ return null
22
+ }
23
+
24
+ return (
25
+ <CopyOutlined
26
+ className={`copy-icon pointer iblock${className ? ' ' + className : ''}`}
27
+ onClick={handleCopy}
28
+ title={title || e('copy')}
29
+ />
30
+ )
31
+ }
@@ -93,10 +93,25 @@ async function createStyleForTab (tab, props) {
93
93
  'background-image: none'
94
94
  )
95
95
  }
96
- } else if (st !== 'none') {
96
+ } else if (st === 'none') {
97
+ // The [🚫] opt-out must be spelled out: this rule has a higher specificity
98
+ // than the global one, but an empty declaration block would still let the
99
+ // built-in default bg painted by createGlobalStyle show through.
100
+ styles.push('background-image: none')
101
+ } else {
97
102
  styles.push(
98
103
  `background-image: ${st}`,
99
104
  'background-position: center',
105
+ // createGlobalStyle's built-in default bg rule carries an auto-fit
106
+ // `background-size` (so the 766px wide watermark is not cropped on
107
+ // narrow panes), and for the active tab it targets this very same
108
+ // ::before. `background-size` is not reset by a rule that only sets
109
+ // `background-image`, so it would leak onto the user's image and scale
110
+ // it down to at most 766px, centred — a big image that used to fill the
111
+ // pane would no longer cover it. Stating the initial value keeps user
112
+ // images at their natural size, which is what they rendered at before
113
+ // the auto-fit was introduced.
114
+ 'background-size: auto',
100
115
  `filter: ${createFilterStyle(props, tab)}`
101
116
  )
102
117
  }
@@ -20,6 +20,7 @@ import TriggersField from '../../triggers/triggers-field.jsx'
20
20
  import ProfileItem from './profile-item.jsx'
21
21
  import renderRunScripts from './run-scripts.jsx'
22
22
  import SerialPathSelector from './serial-path-selector.jsx'
23
+ import VvFileField from './vv-file-field.jsx'
23
24
  import SshHostSelector from './ssh-host-selector.jsx'
24
25
  import SshAuthTypeSelector from './ssh-auth-type-selector.jsx'
25
26
  import SshAuthSelector from './ssh-auth-selector.jsx'
@@ -150,6 +151,8 @@ export function renderFormItem (item, formItemLayout, form, ctxProps, index) {
150
151
  )
151
152
  case 'proxy':
152
153
  return <ProxyField key={name} bookmarks={ctxProps.bookmarks} />
154
+ case 'vvFile':
155
+ return <VvFileField key={name} form={form} />
153
156
  case 'x11':
154
157
  return <X11Field key={name} form={form} />
155
158
  case 'sshTunnels':
@@ -0,0 +1,138 @@
1
+ /**
2
+ * bookmark form - load a .vv (virt-viewer) connection file
3
+ *
4
+ * Registered as field type 'vvFile' in ./fields.jsx and used by
5
+ * ../config/spice.js. Fills host/port/password/title/proxy/tls/ca/hostSubject
6
+ * from the file and reports anything it could not honour instead of dropping
7
+ * it silently.
8
+ */
9
+ import { useState } from 'react'
10
+ import {
11
+ Form,
12
+ Button,
13
+ Alert
14
+ } from 'antd'
15
+ import { FileAddOutlined } from '@ant-design/icons'
16
+ import fs from '../../../common/fs'
17
+ import { tailFormItemLayout } from '../../../common/form-layout'
18
+ import { parseVv, describeVv } from '../../../common/parse-vv'
19
+
20
+ const FormItem = Form.Item
21
+
22
+ /**
23
+ * window.translate echoes an unknown key back capitalised, so an unmapped
24
+ * 'loadVvFile' would render as "LoadVvFile". The key IS in
25
+ * ~/dev/electerm-locales, but the app consumes the published
26
+ * @electerm/electerm-locales, so it stays unknown until that ships and the dep
27
+ * is bumped. Fall back to the literal, which is what en_us resolves to anyway
28
+ * (capitalizeFirstLetter('load .vv file')), so nothing changes visually when
29
+ * the translation does land.
30
+ */
31
+ function vvLabel () {
32
+ const t = window.translate('loadVvFile')
33
+ return t === 'LoadVvFile' ? 'Load .vv file' : t
34
+ }
35
+
36
+ /**
37
+ * Open the native file dialog, filtered to .vv files.
38
+ * window.api.openDialog is dialog.showOpenDialogSync in the main process
39
+ * (see src/app/lib/ipc.js), so the full Electron options object is accepted.
40
+ * @returns {Promise<string|null>} selected path, or null when cancelled
41
+ */
42
+ async function pickVvFile () {
43
+ const files = await window.api.openDialog({
44
+ title: 'Choose a .vv connection file',
45
+ message: 'Choose a .vv connection file',
46
+ properties: [
47
+ 'openFile',
48
+ 'showHiddenFiles',
49
+ 'noResolveAliases',
50
+ 'treatPackageAsDirectory',
51
+ 'dontAddToRecent'
52
+ ],
53
+ filters: [
54
+ { name: 'Virt Viewer connection file', extensions: ['vv'] },
55
+ { name: 'All files', extensions: ['*'] }
56
+ ]
57
+ }).catch(() => false)
58
+ if (!files || !files.length) {
59
+ return null
60
+ }
61
+ return files[0]
62
+ }
63
+
64
+ export default function VvFileField ({ form }) {
65
+ const [hint, setHint] = useState(null)
66
+ const [loading, setLoading] = useState(false)
67
+
68
+ const load = async () => {
69
+ setLoading(true)
70
+ setHint(null)
71
+ try {
72
+ const filePath = await pickVvFile()
73
+ if (!filePath) {
74
+ return
75
+ }
76
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
77
+ const text = await fs.readFile(filePath)
78
+ const parsed = parseVv(text)
79
+
80
+ if (!parsed.ok) {
81
+ setHint({ type: 'error', text: describeVv(parsed) })
82
+ return
83
+ }
84
+
85
+ // Only push the keys the file actually defined, so loading a .vv on top
86
+ // of a partly filled form does not wipe the other fields.
87
+ form.setFieldsValue(parsed.fields)
88
+
89
+ const notes = parsed.warnings.slice()
90
+ if (parsed.ignored.length) {
91
+ notes.push(
92
+ 'skipped: ' + parsed.ignored.map(d => d.key).join(', ')
93
+ )
94
+ }
95
+ if (parsed.unknown.length) {
96
+ notes.push(
97
+ 'unrecognised: ' + parsed.unknown.map(d => d.key).join(', ')
98
+ )
99
+ }
100
+
101
+ const summary = describeVv(parsed)
102
+ setHint({
103
+ type: notes.length ? 'warning' : 'success',
104
+ text: summary + (notes.length ? ' -- ' + notes.join('; ') : '')
105
+ })
106
+ } catch (err) {
107
+ setHint({ type: 'error', text: 'cannot read file: ' + err.message })
108
+ } finally {
109
+ setLoading(false)
110
+ }
111
+ }
112
+
113
+ return (
114
+ <FormItem
115
+ {...tailFormItemLayout}
116
+ >
117
+ <Button
118
+ icon={<FileAddOutlined />}
119
+ loading={loading}
120
+ onClick={load}
121
+ >
122
+ {vvLabel()}
123
+ </Button>
124
+ {
125
+ hint && (
126
+ <Alert
127
+ className='mg1t'
128
+ type={hint.type}
129
+ title={hint.text}
130
+ showIcon
131
+ closable
132
+ onClose={() => setHint(null)}
133
+ />
134
+ )
135
+ }
136
+ </FormItem>
137
+ )
138
+ }
@@ -12,6 +12,9 @@ const spiceConfig = {
12
12
  initValues: (props) => {
13
13
  return createBaseInitValues(props, terminalSpiceType, {
14
14
  port: 5900,
15
+ tls: false,
16
+ ca: '',
17
+ hostSubject: '',
15
18
  viewOnly: false,
16
19
  scaleViewport: true,
17
20
  connectionHoppings: [],
@@ -26,8 +29,27 @@ const spiceConfig = {
26
29
  fields: [
27
30
  commonFields.category,
28
31
  commonFields.colorTitle,
32
+ { type: 'vvFile', name: '__vv__', label: '' },
29
33
  { type: 'input', name: 'host', label: () => e('host'), rules: [{ required: true, message: e('host') + ' required' }] },
30
34
  commonFields.port,
35
+ { type: 'switch', name: 'tls', label: 'TLS', valuePropName: 'checked' },
36
+ {
37
+ type: 'textarea',
38
+ name: 'ca',
39
+ label: 'CA certificate (PEM)',
40
+ props: {
41
+ autoSize: { minRows: 2, maxRows: 8 },
42
+ placeholder: '-----BEGIN CERTIFICATE-----'
43
+ }
44
+ },
45
+ {
46
+ type: 'input',
47
+ name: 'hostSubject',
48
+ label: 'Certificate subject',
49
+ props: {
50
+ placeholder: 'CN=pve1,O=Proxmox Virtual Environment'
51
+ }
52
+ },
31
53
  { type: 'switch', name: 'viewOnly', label: () => e('viewOnly'), valuePropName: 'checked' },
32
54
  { type: 'switch', name: 'scaleViewport', label: () => e('scaleViewport'), valuePropName: 'checked' },
33
55
  { type: 'profileItem', name: '__profile__', label: '', profileFilter: d => !isEmpty(d.spice) },