@electerm/electerm-react 5.5.9 → 5.5.25

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 (62) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/download.jsx +12 -0
  5. package/client/common/parse-vv.js +313 -0
  6. package/client/common/resolve.js +20 -14
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/components/ai/ai-history-item.jsx +1 -1
  10. package/client/components/ai/ai-history.jsx +1 -0
  11. package/client/components/ai/ai-history.styl +23 -0
  12. package/client/components/ai/ai-presets.js +13 -2
  13. package/client/components/bg/css-overwrite.jsx +16 -1
  14. package/client/components/bookmark-form/common/color-picker.styl +3 -0
  15. package/client/components/bookmark-form/common/fields.jsx +3 -0
  16. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  17. package/client/components/bookmark-form/config/spice.js +1 -0
  18. package/client/components/common/remote-pointer.js +49 -0
  19. package/client/components/common/remote-session-shell.jsx +137 -0
  20. package/client/components/common/remote-session-shell.styl +80 -0
  21. package/client/components/common/responsive-tabs.jsx +89 -0
  22. package/client/components/common/responsive-tabs.styl +34 -0
  23. package/client/components/footer/footer-entry.jsx +39 -14
  24. package/client/components/icons/ai-icon.styl +4 -1
  25. package/client/components/layout/layout.jsx +7 -1
  26. package/client/components/main/main.jsx +9 -2
  27. package/client/components/main/term-fullscreen.styl +7 -0
  28. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  29. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  30. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  31. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  32. package/client/components/rdp/rdp-session.jsx +94 -124
  33. package/client/components/rdp/rdp.styl +13 -36
  34. package/client/components/session/session.styl +0 -16
  35. package/client/components/setting-panel/list.jsx +2 -2
  36. package/client/components/setting-panel/setting-modal.jsx +8 -3
  37. package/client/components/sftp/sftp-entry.jsx +26 -0
  38. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  39. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  40. package/client/components/sidebar/bookmark-select.jsx +3 -1
  41. package/client/components/sidebar/index.jsx +13 -4
  42. package/client/components/sidebar/side-panel.jsx +8 -3
  43. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  44. package/client/components/spice/spice-session.jsx +69 -72
  45. package/client/components/spice/spice.styl +10 -19
  46. package/client/components/terminal/shortcut-bar.styl +14 -0
  47. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  48. package/client/components/terminal-info/terminal-info.styl +3 -0
  49. package/client/components/theme/terminal-theme-list.styl +3 -0
  50. package/client/components/theme/theme-ai-editor.jsx +2 -2
  51. package/client/components/theme/theme-form.jsx +20 -23
  52. package/client/components/theme/theme-form.styl +8 -10
  53. package/client/components/theme/theme-list-item.jsx +59 -25
  54. package/client/components/vnc/vnc-session.jsx +72 -104
  55. package/client/components/vnc/vnc.styl +25 -16
  56. package/client/css/mobile.styl +28 -0
  57. package/client/store/common.js +44 -0
  58. package/client/store/init-state.js +2 -1
  59. package/client/store/setting.js +4 -0
  60. package/client/store/sidebar.js +5 -1
  61. package/client/store/store.js +9 -2
  62. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ This is react components pack for electerm sub projects
16
16
 
17
17
  Open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client(Linux, Mac, Windows, Android, HarmonyOS, iOS).
18
18
 
19
- Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS, iOS, and older systems — Ubuntu 18, Windows 7, macOS 10+, and special Chinese Linux distributions such as UOS, Kylin, and LoongArch (both old-world and new-world).
19
+ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS, iOS, and older systems — Ubuntu 18, Windows 7, macOS 10+, and special Chinese Linux distributions such as UOS, Kylin, and LoongArch (both old-world and new-world), as well as RISC-V (riscv64) and PowerPC 64-bit little-endian (ppc64le) Linux.
20
20
 
21
21
  <p>
22
22
  <a href="https://electerm.org">Homepage / Downloads</a> ·
@@ -49,6 +49,16 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
49
49
 
50
50
  ----
51
51
 
52
+ <div align="center">
53
+ <a href="https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm">
54
+ <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI" height="180" />
55
+ </a>
56
+ </div>
57
+
58
+ [Fluxion AI](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm) — One gateway to the world's leading AI models. [Sign up here to get started](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm).
59
+
60
+ ----
61
+
52
62
  <div align="center">
53
63
  <br />
54
64
  <br />
@@ -86,7 +96,7 @@ Thanks to APIMart for sponsoring electerm! APIMart is a low-cost API platform fo
86
96
  ## Features
87
97
 
88
98
  - Works as a terminal/file manager, ssh/sftp/ftp/telnet/serialport/RDP/VNC/Spice client
89
- - Support Window 7+(X64/ARM64), HarmonyOS,Android, iOS, Mac OS 10.15+(x64/arm64), Linux(x64/arm64/Loong64 new world & old world), even old Linux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc
99
+ - Support Window 7+(X64/ARM64), HarmonyOS,Android, iOS, Mac OS 10.15+(x64/arm64), Linux(x64/arm64/riscv64/ppc64le/Loong64 new world & old world), even old Linux with glibc 2.17+ like UOS/Kylin/Ubuntu 18.04 etc
90
100
  - Global hotkey to toggle window visibility (similar to guake, default is `ctrl + 2`)
91
101
  - 🇺🇸 🇨🇳 🇧🇷 🇷🇺 🇪🇸 🇫🇷 🇹🇷 🇭🇰 🇯🇵 🇸🇦 🇩🇪 🇰🇷 🇮🇩 🇵🇱 Multi-language support([electerm-locales](https://github.com/electerm/electerm-locales), contributions/fixes welcome)
92
102
  - Double click to directly edit (small) remote files.
@@ -2,6 +2,7 @@
2
2
  * constants
3
3
  */
4
4
  import { isMacJs } from './platform.js'
5
+ import { defaultThemeId, defaultThemeLightId } from './theme-defaults.js'
5
6
  import logoPath1Ref from '@electerm/electerm-resource/res/imgs/electerm-round-128x128.png'
6
7
  import logoPath2Ref from '@electerm/electerm-resource/res/imgs/electerm.png'
7
8
  import logoPath3Ref from '@electerm/electerm-resource/res/imgs/electerm-watermark.png'
@@ -156,6 +157,7 @@ export const leftSideBarOpenKey = 'left-side-bar-open'
156
157
  export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar'
157
158
  export const leftSidePanelWidthKey = 'left-sidebar-width'
158
159
  export const rightSidebarWidthKey = 'right-sidebar-width'
160
+ export const rightPanelPinnedKey = 'right-panel-pinned'
159
161
  export const addPanelWidthLsKey = 'addPanelWidth'
160
162
  export const sftpDefaultSortSettingKey = 'sftp-default-sort'
161
163
  export const qmSortByFrequencyKey = 'qm-sort-by-frequency'
@@ -241,6 +243,18 @@ export const settingShortcutsId = 'setting-shortcuts'
241
243
  export const settingAiId = 'setting-ai'
242
244
  export const settingCommonId = 'setting-common'
243
245
  export const settingPasswordsId = 'setting-passwords'
246
+ // Built in items the user is not allowed to delete: the two settings tabs,
247
+ // the default bookmark group and the two built in terminal themes.
248
+ // Keep this an explicit id list. A prefix test like `id.startsWith('default')`
249
+ // would also cover user facing seeds (default quick commands, default local
250
+ // terminal bookmarks) that the user must be able to remove.
251
+ export const undeletableIds = new Set([
252
+ settingSyncId,
253
+ settingCommonId,
254
+ defaultBookmarkGroupId,
255
+ defaultThemeId,
256
+ defaultThemeLightId
257
+ ])
244
258
  // settings that only apply to the desktop (electron) app,
245
259
  // hidden when window.et.isWebApp is true
246
260
  export const webAppHiddenSettings = [
@@ -78,8 +78,8 @@ export default {
78
78
  hideIP: false,
79
79
  dataSyncSelected: 'all',
80
80
  nameAI: '',
81
- baseURLAI: 'https://api.atlascloud.ai/v1',
82
- modelAI: 'deepseek-chat',
81
+ baseURLAI: 'https://fluxionai.world/v1',
82
+ modelAI: 'deepseek-v4-flash-0731',
83
83
  roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式',
84
84
  apiPathAI: '/chat/completions',
85
85
  proxyAI: '',
@@ -7,6 +7,18 @@ import { chooseSaveDirectory } from './choose-save-folder'
7
7
  import { DownloadOutlined } from '@ant-design/icons'
8
8
 
9
9
  export default async function download (filename, text) {
10
+ // iOS WKWebView: sandbox targets are invisible to the Files app and
11
+ // blob-anchor downloads are a no-op — save to Documents (visible in
12
+ // Files) via the native bridge instead (see
13
+ // web-components/native-file-save.js).
14
+ if (window.et && window.et.isWebApp && window.et.saveTextNative) {
15
+ try {
16
+ await window.et.saveTextNative(filename, text)
17
+ return
18
+ } catch (e) {
19
+ console.log('native save failed, falling back to sandbox save:', e)
20
+ }
21
+ }
10
22
  const opts = window.et.isWebApp
11
23
  ? { filename, content: text }
12
24
  : undefined
@@ -0,0 +1,313 @@
1
+ /**
2
+ * .vv connection file parser (virt-viewer / remote-viewer)
3
+ *
4
+ * A .vv file is an INI document with a mandatory [virt-viewer] group and a
5
+ * mandatory `type` key. Everything SPICE-related lives directly in
6
+ * [virt-viewer] -- there is NO [spice] group. An optional [ovirt] group
7
+ * carries oVirt REST integration data.
8
+ *
9
+ * Behaviour is deliberately matched against:
10
+ * - src/virt-viewer-file.c (gitlab.com/virt-viewer/virt-viewer)
11
+ * - remote-viewer(1), "CONNECTION FILE" section
12
+ *
13
+ * Rejection rules copied from virt_viewer_file_new():
14
+ * no [virt-viewer] group -> invalid file
15
+ * no `type` key -> invalid file
16
+ * unknown keys/groups -> kept, never an error (the format is
17
+ * extensible; the convention is an `x-` prefix)
18
+ *
19
+ * Keep in sync with src/client/common/parse-vv.js -- the two differ only in
20
+ * the export statement (see sanitize-filename.js for the same pattern).
21
+ */
22
+
23
+ const MAIN_GROUP = 'virt-viewer'
24
+ const OVIRT_GROUP = 'ovirt'
25
+
26
+ /**
27
+ * .vv key -> bookmark field understood by
28
+ * src/client/components/bookmark-form/config/spice.js
29
+ */
30
+ const FIELD_MAP = {
31
+ host: 'host',
32
+ port: 'port',
33
+ password: 'password',
34
+ title: 'title',
35
+ proxy: 'proxy'
36
+ }
37
+
38
+ /**
39
+ * Keys the .vv format defines but electerm cannot honour for a spice session.
40
+ * Each becomes a user-visible note instead of being dropped silently.
41
+ */
42
+ const UNSUPPORTED = {
43
+ 'tls-port': 'TLS is not supported (electerm makes a plain TCP connection)',
44
+ 'tls-ciphers': 'TLS is not supported',
45
+ ca: 'TLS certificate verification is not supported',
46
+ 'host-subject': 'TLS certificate verification is not supported',
47
+ 'unix-path': 'Unix-socket transport is not supported',
48
+ username: 'spice sessions authenticate with the password only',
49
+ 'disable-channels': 'spice channel selection is not supported',
50
+ 'secure-channels': 'spice channel selection is not supported',
51
+ 'color-depth': 'colour depth is negotiated with the server',
52
+ 'disable-effects': 'remote desktop-effect control is not supported',
53
+ 'enable-smartcard': 'smartcard redirection is not supported',
54
+ 'enable-usbredir': 'USB redirection is not supported',
55
+ 'enable-usb-autoshare': 'USB redirection is not supported',
56
+ 'usb-filter': 'USB redirection is not supported',
57
+ 'usb-redirect-on-connect': 'USB redirection is not supported',
58
+ fullscreen: 'fullscreen is a session-time toggle',
59
+ version: 'client version requirements are not enforced',
60
+ versions: 'client version requirements are not enforced',
61
+ 'newer-version-url': 'client version requirements are not enforced',
62
+ 'toggle-fullscreen': 'hotkey rebinding is not supported',
63
+ 'release-cursor': 'hotkey rebinding is not supported',
64
+ 'secure-attention': 'hotkey rebinding is not supported',
65
+ 'zoom-in': 'hotkey rebinding is not supported',
66
+ 'zoom-out': 'hotkey rebinding is not supported',
67
+ 'zoom-reset': 'hotkey rebinding is not supported',
68
+ 'smartcard-insert': 'hotkey rebinding is not supported',
69
+ 'smartcard-remove': 'hotkey rebinding is not supported',
70
+ 'usb-device-reset': 'hotkey rebinding is not supported'
71
+ }
72
+
73
+ const BOOL_TRUE = new Set(['1', 'true', 'yes', 'on'])
74
+
75
+ function stripBom (text) {
76
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
77
+ }
78
+
79
+ function isComment (line) {
80
+ return line.startsWith('#') || line.startsWith(';')
81
+ }
82
+
83
+ /**
84
+ * Parse INI text into { groupName: { key: value } }.
85
+ *
86
+ * GKeyFile itself is case-sensitive; group and key names are lowercased on
87
+ * purpose, because hand-written .vv files are inconsistent about casing and
88
+ * the alternative is a confusing "host not set" for a file that clearly
89
+ * sets `Host=`.
90
+ *
91
+ * A trailing backslash continues the value on the next line (GKeyFile rule).
92
+ *
93
+ * @param {string} text
94
+ * @returns {Object} groups
95
+ */
96
+ function parseIni (text) {
97
+ const groups = {}
98
+ let group = null
99
+ const lines = stripBom(text).split(/\r\n|\r|\n/)
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ let line = lines[i]
103
+
104
+ while (/\\[ \t]*$/.test(line) && i + 1 < lines.length) {
105
+ line = line.replace(/\\[ \t]*$/, '') + lines[++i]
106
+ }
107
+
108
+ const trimmed = line.trim()
109
+ if (!trimmed || isComment(trimmed)) {
110
+ continue
111
+ }
112
+
113
+ const groupMatch = trimmed.match(/^\[(.+)\]$/)
114
+ if (groupMatch) {
115
+ group = groupMatch[1].trim().toLowerCase()
116
+ if (!groups[group]) {
117
+ groups[group] = {}
118
+ }
119
+ continue
120
+ }
121
+
122
+ const eq = line.indexOf('=')
123
+ if (eq < 0 || !group) {
124
+ continue
125
+ }
126
+
127
+ const key = line.slice(0, eq).trim().toLowerCase()
128
+ // Only leading whitespace is stripped. A trailing space is significant
129
+ // for values such as passwords, so it is preserved.
130
+ const value = line.slice(eq + 1).replace(/^[ \t]+/, '')
131
+ if (key) {
132
+ groups[group][key] = value
133
+ }
134
+ }
135
+
136
+ return groups
137
+ }
138
+
139
+ /**
140
+ * Parse the contents of a .vv file.
141
+ *
142
+ * @param {string} text - raw contents of a .vv file
143
+ * @returns {{
144
+ * ok: boolean, error: string|null, type: string|null,
145
+ * fields: Object, applied: Array, ignored: Array,
146
+ * unknown: Array, warnings: string[], deleteThisFile: boolean,
147
+ * groups: Object
148
+ * }}
149
+ */
150
+ function parseVv (text) {
151
+ const result = {
152
+ ok: false,
153
+ error: null,
154
+ type: null,
155
+ fields: {},
156
+ applied: [],
157
+ ignored: [],
158
+ unknown: [],
159
+ warnings: [],
160
+ deleteThisFile: false,
161
+ groups: {}
162
+ }
163
+
164
+ if (typeof text !== 'string' || !text.trim()) {
165
+ result.error = 'empty file'
166
+ return result
167
+ }
168
+
169
+ let groups
170
+ try {
171
+ groups = parseIni(text)
172
+ } catch (e) {
173
+ result.error = 'cannot parse file: ' + e.message
174
+ return result
175
+ }
176
+ result.groups = groups
177
+
178
+ const main = groups[MAIN_GROUP]
179
+ if (!main) {
180
+ result.error = 'invalid .vv file: missing [' + MAIN_GROUP + '] group'
181
+ return result
182
+ }
183
+
184
+ const type = (main.type || '').trim().toLowerCase()
185
+ if (!type) {
186
+ result.error = 'invalid .vv file: missing "type" key in [' + MAIN_GROUP + ']'
187
+ return result
188
+ }
189
+ result.type = type
190
+ if (type !== 'spice') {
191
+ result.error = 'unsupported session type "' + type + '": spice only'
192
+ return result
193
+ }
194
+
195
+ Object.keys(FIELD_MAP).forEach(key => {
196
+ if (!(key in main)) {
197
+ return
198
+ }
199
+ const raw = main[key]
200
+ const field = FIELD_MAP[key]
201
+
202
+ if (key === 'port') {
203
+ const port = parseInt(raw.trim(), 10)
204
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
205
+ result.warnings.push('ignoring invalid port "' + raw.trim() + '"')
206
+ return
207
+ }
208
+ result.fields.port = port
209
+ } else if (key === 'password') {
210
+ result.fields.password = raw
211
+ } else {
212
+ const value = raw.trim()
213
+ if (!value) {
214
+ result.warnings.push('ignoring empty "' + key + '"')
215
+ return
216
+ }
217
+ result.fields[field] = value
218
+ }
219
+ result.applied.push({ key, value: raw, field })
220
+ })
221
+
222
+ if (!result.fields.host) {
223
+ result.error = 'no usable host: [virt-viewer] has no "host" key'
224
+ return result
225
+ }
226
+
227
+ // A file that only offers a TLS port has nothing we can dial, because the
228
+ // spice transport here is plain TCP (see src/app/server/spice-proxy.js).
229
+ if (main['tls-port'] && !('port' in main)) {
230
+ result.warnings.push(
231
+ 'this file defines only tls-port, and TLS is not supported here, ' +
232
+ 'so there is no plain port to connect to'
233
+ )
234
+ }
235
+
236
+ // delete-this-file=1 makes remote-viewer unlink the file after loading it
237
+ // (unless VIRT_VIEWER_KEEP_FILE is set). We never delete the user's file;
238
+ // surface it instead so the caller can decide.
239
+ if ('delete-this-file' in main) {
240
+ result.deleteThisFile = BOOL_TRUE.has(
241
+ main['delete-this-file'].trim().toLowerCase()
242
+ )
243
+ if (result.deleteThisFile) {
244
+ result.warnings.push(
245
+ 'the file is marked delete-this-file=1; it was NOT deleted'
246
+ )
247
+ }
248
+ }
249
+
250
+ Object.keys(main).forEach(key => {
251
+ if (key === 'type' || key === 'delete-this-file' || key in FIELD_MAP) {
252
+ return
253
+ }
254
+ const value = main[key]
255
+ if (key in UNSUPPORTED) {
256
+ result.ignored.push({ key, value, reason: UNSUPPORTED[key] })
257
+ } else {
258
+ result.unknown.push({ key, value })
259
+ }
260
+ })
261
+
262
+ if (groups[OVIRT_GROUP]) {
263
+ result.ignored.push({
264
+ key: '[' + OVIRT_GROUP + ']',
265
+ value: Object.keys(groups[OVIRT_GROUP]).join(', '),
266
+ reason: 'oVirt REST integration is not supported'
267
+ })
268
+ }
269
+
270
+ if (result.ignored.length) {
271
+ result.warnings.push(
272
+ result.ignored.length +
273
+ ' option(s) in this file are not supported and were skipped'
274
+ )
275
+ }
276
+
277
+ result.ok = true
278
+ return result
279
+ }
280
+
281
+ /**
282
+ * Turn a parse result into a one-line summary for a UI hint.
283
+ *
284
+ * @param {Object} parsed - result of parseVv
285
+ * @returns {string}
286
+ */
287
+ function describeVv (parsed) {
288
+ if (!parsed || !parsed.ok) {
289
+ return (parsed && parsed.error) || 'invalid .vv file'
290
+ }
291
+ const { host, port, title, proxy } = parsed.fields
292
+ const bits = ['spice://' + host + (port ? ':' + port : '')]
293
+ if (title) {
294
+ bits.push('"' + title + '"')
295
+ }
296
+ if (proxy) {
297
+ bits.push('via ' + proxy)
298
+ }
299
+ if (parsed.ignored.length) {
300
+ bits.push(parsed.ignored.length + ' option(s) skipped')
301
+ }
302
+ return bits.join(' ')
303
+ }
304
+
305
+ export {
306
+ parseVv,
307
+ parseIni,
308
+ describeVv,
309
+ MAIN_GROUP,
310
+ OVIRT_GROUP,
311
+ FIELD_MAP,
312
+ UNSUPPORTED
313
+ }
@@ -13,24 +13,30 @@ export const isWslDistroRoot = (path) => {
13
13
  }
14
14
 
15
15
  export default function resolve (basePath, nameOrDot) {
16
+ // Callers can hand this a value that is not a path yet (a session path that
17
+ // is still loading, a missing tab option, ...). Reading `.includes` off it
18
+ // used to throw and take the whole renderer down with a white screen, so
19
+ // anything that is not a string is treated as an empty path here.
20
+ const base = typeof basePath === 'string' ? basePath : ''
21
+ const name = typeof nameOrDot === 'string' ? nameOrDot : ''
16
22
  const hasWinDrive = (path) => /^[a-zA-Z]:/.test(path)
17
- const isWin = basePath.includes('\\') || nameOrDot.includes('\\') || hasWinDrive(basePath) || hasWinDrive(nameOrDot)
23
+ const isWin = base.includes('\\') || name.includes('\\') || hasWinDrive(base) || hasWinDrive(name)
18
24
  const sep = isWin ? '\\' : '/'
19
- if (/^[a-zA-Z]:/.test(nameOrDot)) {
20
- return nameOrDot.replace(/^\//, '').replace(/\//g, sep)
25
+ if (/^[a-zA-Z]:/.test(name)) {
26
+ return name.replace(/^\//, '').replace(/\//g, sep)
21
27
  }
22
- if (nameOrDot.startsWith('/')) {
23
- return nameOrDot.replace(/\\/g, sep)
28
+ if (name.startsWith('/')) {
29
+ return name.replace(/\\/g, sep)
24
30
  }
25
- if (nameOrDot.startsWith('\\\\')) {
26
- return nameOrDot
31
+ if (name.startsWith('\\\\')) {
32
+ return name
27
33
  }
28
- if (nameOrDot === '..') {
29
- if (isWslDistroRoot(basePath)) {
34
+ if (name === '..') {
35
+ if (isWslDistroRoot(base)) {
30
36
  return '/'
31
37
  }
32
- const baseEndsWithSep = basePath.endsWith(sep)
33
- const parts = basePath.split(sep)
38
+ const baseEndsWithSep = base.endsWith(sep)
39
+ const parts = base.split(sep)
34
40
  if (parts.length > 1) {
35
41
  parts.pop()
36
42
  if (isWin && parts.length === 1) {
@@ -40,10 +46,10 @@ export default function resolve (basePath, nameOrDot) {
40
46
  }
41
47
  return '/'
42
48
  }
43
- if (isWslDistroRoot(basePath) && !basePath.endsWith(sep)) {
44
- return basePath + sep + nameOrDot
49
+ if (isWslDistroRoot(base) && !base.endsWith(sep)) {
50
+ return base + sep + name
45
51
  }
46
- const result = basePath.endsWith(sep) ? basePath + nameOrDot : basePath + sep + nameOrDot
52
+ const result = base.endsWith(sep) ? base + name : base + sep + name
47
53
  return isWin && result.length === 3 && result.endsWith(':\\') ? '/' : result
48
54
  }
49
55
 
@@ -101,9 +101,12 @@ const defaultThemeDarkTerminal = () => {
101
101
  }
102
102
  }
103
103
 
104
+ export const defaultThemeId = 'default'
105
+ export const defaultThemeLightId = 'defaultLight'
106
+
104
107
  export function defaultTheme () {
105
108
  return {
106
- id: 'default',
109
+ id: defaultThemeId,
107
110
  name: 'default',
108
111
  themeConfig: defaultThemeDarkTerminal(),
109
112
  uiThemeConfig: defaultThemeDark()
@@ -112,7 +115,7 @@ export function defaultTheme () {
112
115
 
113
116
  export function defaultThemeLight () {
114
117
  return {
115
- id: 'defaultLight',
118
+ id: defaultThemeLightId,
116
119
  name: 'default light',
117
120
  themeConfig: defaultThemeLightTerminal(),
118
121
  uiThemeConfig: defaultThemeLightFunc()
@@ -23,7 +23,7 @@ async function fetchData (url, options) {
23
23
 
24
24
  function getInfo (url) {
25
25
  const n = Date.now()
26
- const tail = url.includes('?') ? '' : '?_=' + n
26
+ const tail = url.includes('?') ? '&_=' + n : '?_=' + n
27
27
  return fetchData(url + tail, {
28
28
  action: 'get-update-info',
29
29
  headers: {
@@ -87,16 +87,17 @@ export function getDownloadUrl (browserDownloadUrl, mirror) {
87
87
  }
88
88
 
89
89
  export async function getLatestReleaseInfo () {
90
- let url = `${baseUpdateCheckUrls[0]}/data/electerm-github-release.json`
90
+ const { installSrc } = window.store
91
+ const src = installSrc ? '?src=' + encodeURIComponent(installSrc) : ''
92
+ let url = `${baseUpdateCheckUrls[0]}/data/electerm-github-release.json${src}`
91
93
  let res = await getInfo(url)
92
94
  if (!res?.release?.body) {
93
- url = `${baseUpdateCheckUrls[1]}/data/electerm-github-release.json`
95
+ url = `${baseUpdateCheckUrls[1]}/data/electerm-github-release.json${src}`
94
96
  res = await getInfo(url)
95
97
  }
96
98
  if (!res || !res.release) {
97
99
  return undefined
98
100
  }
99
- const { installSrc } = window.store
100
101
  const asset = (res.release.assets || []).find(r => r.name.includes(installSrc))
101
102
  return {
102
103
  body: res.release.body,
@@ -30,7 +30,7 @@ export default function AiHistoryItem (props) {
30
30
  onClick={() => onSelect(item)}
31
31
  className='pointer'
32
32
  >
33
- {displayItem}
33
+ <span className='ai-history-label'>{displayItem}</span>
34
34
  </Tag>
35
35
  )
36
36
 
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'
5
5
  import { Space } from 'antd'
6
6
  import { safeGetItemJSON, safeSetItemJSON } from '../../common/safe-local-storage'
7
7
  import AiHistoryItem from './ai-history-item'
8
+ import './ai-history.styl'
8
9
 
9
10
  const MAX_HISTORY = 20
10
11
 
@@ -0,0 +1,23 @@
1
+ // AI history items (ai-history.jsx / ai-history-item.jsx)
2
+ //
3
+ // Each entry is an antd Tag: nowrap, no max-width, and the text is a user
4
+ // written description up to 50 chars long. Space renders an inline-flex box,
5
+ // which shrink-to-fits its widest *unwrappable* child — so one long entry
6
+ // pushes the whole panel into horizontal scrolling instead of using the width
7
+ // it already has. The label gets its own ellipsis box so the tag can shrink to
8
+ // the available width while the close icon stays reachable.
9
+ .ai-bookmark-history
10
+ .ant-space
11
+ display flex
12
+ .ant-space-item
13
+ min-width 0
14
+ .ant-tag
15
+ max-width 100%
16
+
17
+ .ai-history-label
18
+ display inline-block
19
+ // leave room for the tag close icon
20
+ max-width calc(100% - 16px)
21
+ overflow hidden
22
+ text-overflow ellipsis
23
+ vertical-align middle
@@ -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?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm'
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
  }
@@ -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
  }
@@ -10,6 +10,9 @@
10
10
  width 92px
11
11
  .color-picker-box
12
12
  width 420px
13
+ // the popover is portaled to the body, a 420px box simply falls off a phone
14
+ // screen (measured: the swatch list and the antd picker still fit at 280px)
15
+ max-width calc(100vw - 40px)
13
16
 
14
17
  .color-picker-defaults
15
18
  width 200px
@@ -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':