@electerm/electerm-react 5.5.56 → 5.5.76

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 (43) hide show
  1. package/client/common/constants.js +12 -0
  2. package/client/common/dangerous-session-fields.js +39 -0
  3. package/client/common/default-setting.js +1 -0
  4. package/client/common/distribute-tabs.js +42 -0
  5. package/client/common/import-task.js +46 -3
  6. package/client/common/parse-quick-connect.js +47 -6
  7. package/client/common/quick-connect-string.js +134 -0
  8. package/client/common/safe-join.js +33 -0
  9. package/client/common/sanitize-filename.js +8 -0
  10. package/client/common/ui-theme-color.mjs +136 -0
  11. package/client/common/update-check.js +14 -0
  12. package/client/components/ai/ai-output.jsx +5 -0
  13. package/client/components/ai/ai.styl +19 -0
  14. package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
  15. package/client/components/bookmark-form/form-renderer.jsx +13 -1
  16. package/client/components/bookmark-form/index.jsx +20 -1
  17. package/client/components/bookmark-form/render-form.jsx +2 -2
  18. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  19. package/client/components/main/ui-theme.jsx +1 -20
  20. package/client/components/main/upgrade.jsx +2 -0
  21. package/client/components/setting-panel/install-command.jsx +151 -0
  22. package/client/components/setting-panel/setting-common.jsx +5 -0
  23. package/client/components/setting-panel/setting-terminal.jsx +10 -1
  24. package/client/components/terminal/mixins/term-attach.js +10 -2
  25. package/client/components/terminal/mixins/term-init.js +1 -0
  26. package/client/components/terminal/mixins/term-socket.js +12 -2
  27. package/client/components/terminal/mixins/term-suggestions.js +16 -3
  28. package/client/components/terminal/osc52-addon.js +7 -2
  29. package/client/components/terminal/session-status-overlay.jsx +44 -0
  30. package/client/components/terminal/shell.js +11 -3
  31. package/client/components/terminal/shortcut-bar-press.js +192 -0
  32. package/client/components/terminal/shortcut-bar.jsx +199 -4
  33. package/client/components/terminal/shortcut-bar.styl +40 -21
  34. package/client/components/terminal/ssh-reload-state.js +41 -2
  35. package/client/components/terminal/startup-queue.js +6 -2
  36. package/client/components/terminal/terminal.jsx +17 -2
  37. package/client/components/terminal/terminal.styl +14 -5
  38. package/client/store/mcp-handler.js +8 -15
  39. package/client/store/sync.js +47 -18
  40. package/client/store/tab.js +47 -17
  41. package/client/store/watch.js +8 -0
  42. package/package.json +1 -1
  43. package/client/components/terminal/reconnect-overlay.jsx +0 -14
@@ -301,6 +301,18 @@ export const proxyHelpLink = 'https://github.com/electerm/electerm/wiki/proxy-fo
301
301
  export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal-keywords-highlight-regular-expression-exmaples'
302
302
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
303
303
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
304
+ export const installCommandHelpLink = 'https://github.com/electerm/electerm/wiki/Install-electerm-command'
305
+
306
+ // Toggle options that have a wiki page explaining what they actually change.
307
+ // renderToggle() looks the config key up here and renders a HelpIcon only for
308
+ // the keys listed, so a toggle without an explanation stays clean. Add a page
309
+ // under https://github.com/electerm/electerm/wiki first, then add its key here.
310
+ export const toggleWikiMap = {
311
+ mouseEventsRequireAlt:
312
+ 'https://github.com/electerm/electerm/wiki/Mouse-events-require-alt',
313
+ sftpPathFollowSsh:
314
+ 'https://github.com/electerm/electerm/wiki/Warning-about-sftp-follow-ssh-path-function'
315
+ }
304
316
  export const aiChatModeLsKey = 'ai-chat-mode'
305
317
  export const aiAutoCompressLsKey = 'ai-auto-compress'
306
318
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session fields that must never be settable from untrusted input: a deep link
3
+ * URL, CLI `--opts`, an MCP/AI tool call, or a shortcut file.
4
+ *
5
+ * Every entry reaches one of: child_process.spawn(), a shell, the process
6
+ * environment, or the terminal input stream.
7
+ *
8
+ * Why this is a shared module and not a local array: `proxyCommand` was added
9
+ * by a later feature and reached spawn() from a single clicked link because the
10
+ * denylists of the time were five separate copies and none of them was updated
11
+ * (follow-up to GHSA-mpm8-cx2p-626q). One list, imported everywhere, is what
12
+ * makes the guard maintainable.
13
+ *
14
+ * Why a denylist and not an allowlist: a quick connect string may legitimately
15
+ * carry any session option (encode, term, authType, agentForward, sshTunnels,
16
+ * startDirectoryRemote, ...). An allowlist would silently break every field it
17
+ * does not know about, which is a worse failure than the one it prevents. The
18
+ * cost of that choice is that a future exec-capable option must be added here.
19
+ *
20
+ * Keep in sync with the copy of this file in the other bundle - there is a unit
21
+ * test that fails when the two copies drift.
22
+ */
23
+ export default [
24
+ // spawns an external process (before any SSH authentication)
25
+ 'proxyCommand',
26
+ // run scripts after connect / override the shell binary
27
+ 'runScripts',
28
+ 'execLinux',
29
+ 'execMac',
30
+ 'execWindows',
31
+ 'execLinuxArgs',
32
+ 'execMacArgs',
33
+ 'execWindowsArgs',
34
+ // injects environment variables into the session
35
+ 'setEnv',
36
+ // injects interactive prompts / auto-answers terminal output
37
+ 'interactiveValues',
38
+ 'triggers'
39
+ ]
@@ -96,6 +96,7 @@ export default {
96
96
  disableTabIndex: false,
97
97
  onlyShowTitleInTab: false,
98
98
  disableShortcutBar: false,
99
+ autoDistributeTabsWhenLayoutChange: false,
99
100
  disableConfirmForLargeClipboardContent: false,
100
101
  doubleClickToOpenBookmark: false,
101
102
  leftSideBarIcons: [
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tab-to-pane distribution maths for the `autoDistributeTabsWhenLayoutChange`
3
+ * setting.
4
+ *
5
+ * Scope is deliberately narrow: the setting only kicks in when leaving the
6
+ * single layout for a layout with more panes. Once there is more than one pane
7
+ * the arrangement is the user's own doing, so it is never reshuffled — the
8
+ * store falls through to the pre-existing behaviour for every other transition.
9
+ *
10
+ * Pure on purpose (no store access) so the behaviour can be pinned down by unit
11
+ * tests instead of by driving the app.
12
+ */
13
+
14
+ /**
15
+ * Evenly split tabs across `count` panes, preserving tab order. The leading
16
+ * panes stay fuller when it does not divide evenly, so the first tabs stay in
17
+ * the first pane.
18
+ *
19
+ * 6 -> 3 panes = 2/2/2 5 -> 3 panes = 2/2/1
20
+ * 4 -> 3 panes = 2/1/1 7 -> 3 panes = 3/2/2
21
+ * 6 -> 4 panes = 2/2/1/1 2 -> 3 panes = 1/1/0
22
+ *
23
+ * @param {number} total number of tabs
24
+ * @param {number} count number of panes
25
+ * @returns {number[]} the pane index for each tab, in tab order
26
+ */
27
+ export function distributeTabsEvenly (total, count) {
28
+ const panes = Math.max(1, Math.floor(Number(count)) || 1)
29
+ const n = Math.max(0, Math.floor(Number(total)) || 0)
30
+ const base = Math.floor(n / panes)
31
+ const extra = n % panes
32
+ const batches = []
33
+ for (let pane = 0; pane < panes; pane++) {
34
+ const size = base + (pane < extra ? 1 : 0)
35
+ for (let i = 0; i < size; i++) {
36
+ batches.push(pane)
37
+ }
38
+ }
39
+ return batches
40
+ }
41
+
42
+ export default distributeTabsEvenly
@@ -59,6 +59,14 @@ export function cancelImportTask () {
59
59
  * @param {string[]} options.stopWatchers db watcher names (window.watchXxx)
60
60
  * to pause during the task, so chunked writes do not trigger a db diff
61
61
  * per batch; watchers restart one per frame after the steps finish
62
+ * @param {Function} options.onAbort called as ({cancelled, error}) when the
63
+ * task did NOT run to completion, BEFORE the watchers restart. A stopped
64
+ * watcher cannot observe store changes, so its snapshot only describes the
65
+ * db while the task ran to completion; after a cancel/throw the store may
66
+ * hold a partial result while the snapshot still describes the pre-task
67
+ * table. Restarting there would classify every untouched record as
68
+ * `removed` and delete it, so the caller must use this hook to put the
69
+ * store back in sync with the snapshot first.
62
70
  * @param {Function} options.onProgress called as (current, total) per batch
63
71
  * @returns {Promise<{cancelled: boolean, error: Error|null}>}
64
72
  */
@@ -72,6 +80,7 @@ export async function runImportTask (options) {
72
80
  useModal = true,
73
81
  modalMinTotal = 200,
74
82
  stopWatchers = [],
83
+ onAbort,
75
84
  onProgress
76
85
  } = options
77
86
  if (controller) {
@@ -134,23 +143,57 @@ export async function runImportTask (options) {
134
143
  update({ label })
135
144
  }
136
145
  }
146
+ } catch (err) {
147
+ error = err
148
+ }
149
+
150
+ // A watcher that was stopped cannot observe store changes, so its snapshot
151
+ // is only a faithful description of the db while the task ran to completion.
152
+ // After a cancel or a throw the store can hold a partial result while the
153
+ // snapshot still describes the pre-task table; the restart below would then
154
+ // diff "snapshot \ store" and delete every record the task never touched.
155
+ // Let the caller reconcile the store with the snapshot first.
156
+ const aborted = controller.cancelled || !!error
157
+ if (aborted && typeof onAbort === 'function') {
158
+ try {
159
+ await onAbort({ cancelled: controller.cancelled, error })
160
+ } catch (abortErr) {
161
+ // the abort hook is best effort - a failure there must not stop the
162
+ // watchers from coming back up
163
+ error = error || abortErr
164
+ }
165
+ }
166
+
167
+ try {
137
168
  // restart watchers one per frame - each start() synchronously diffs
138
169
  // + deep-copies its collection and writes to db, so restarting all at
139
170
  // once would freeze the UI right when the import "finishes"
140
171
  for (const name of stopWatchers) {
141
172
  await yieldToUI(batchIdle)
173
+ // after an abort the store was just put back to its pre-task state;
174
+ // make that the watcher's baseline so the restart is a no-op instead of
175
+ // a mass deletion. reseed() is a no-op for watchers that do not have it.
176
+ if (aborted) {
177
+ window[`watch${name}`]?.reseed?.()
178
+ }
142
179
  window[`watch${name}`]?.start()
143
180
  restarted.add(name)
144
181
  done += 1
145
182
  update({ label: name })
146
183
  }
147
- } catch (err) {
148
- error = err
184
+ } catch (restartErr) {
185
+ // a watcher failing to come back up is reported through the return value,
186
+ // like every other failure here, and must not mask the original error
187
+ error = error || restartErr
149
188
  } finally {
150
189
  // on error/cancel still make sure everything is restarted
151
190
  for (const name of stopWatchers) {
152
191
  if (!restarted.has(name)) {
153
- window[`watch${name}`]?.start()
192
+ try {
193
+ window[`watch${name}`]?.start()
194
+ } catch (e) {
195
+ error = error || e
196
+ }
154
197
  }
155
198
  }
156
199
  }
@@ -19,13 +19,38 @@
19
19
  * 192.168.1.100:22
20
20
  */
21
21
 
22
+ import DANGEROUS_SESSION_FIELDS from './dangerous-session-fields'
23
+
22
24
  const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm']
23
25
 
24
26
  /**
25
- * Deny list for opts keys - these are parsed from the URL itself
26
- * and should not be overridable via the opts JSON parameter for safety
27
+ * Deny list for opts keys.
28
+ *
29
+ * `type` and `host` are parsed from the URL itself, so `opts` must not be able
30
+ * to override them. Everything else is a field that reaches
31
+ * child_process.spawn(), a shell, the environment or the terminal input stream
32
+ * - see common/dangerous-session-fields.js.
33
+ *
34
+ * Deliberately a deny list rather than an allow list: a quick connect string
35
+ * may legitimately carry any session option (encode, term, authType,
36
+ * agentForward, sshTunnels, ...), so an allow list would silently break every
37
+ * field it does not know about.
27
38
  */
28
- const OPTS_DENY_LIST = ['type', 'host']
39
+ const OPTS_DENY_LIST = ['type', 'host', ...DANGEROUS_SESSION_FIELDS]
40
+
41
+ /**
42
+ * Return a copy of an untrusted object without the denied keys.
43
+ * @param {object} obj - untrusted object
44
+ * @param {string[]} denyList - keys to drop
45
+ * @returns {object} a new object with the denied keys removed
46
+ */
47
+ function withoutDenied (obj, denyList = OPTS_DENY_LIST) {
48
+ const out = { ...(obj || {}) }
49
+ denyList.forEach(key => {
50
+ delete out[key]
51
+ })
52
+ return out
53
+ }
29
54
 
30
55
  /**
31
56
  * Default ports for each protocol
@@ -404,8 +429,22 @@ function parseQuickConnect (str) {
404
429
  if (optsStr) {
405
430
  try {
406
431
  const extraOpts = JSON.parse(optsStr)
407
- OPTS_DENY_LIST.forEach(key => delete extraOpts[key])
408
- Object.assign(opts, extraOpts)
432
+ if (
433
+ extraOpts &&
434
+ typeof extraOpts === 'object' &&
435
+ !Array.isArray(extraOpts)
436
+ ) {
437
+ Object.assign(opts, withoutDenied(extraOpts))
438
+ }
439
+ // a hop entry is spread into a sub-session (server/session-hop.js),
440
+ // so it is its own untrusted surface and needs the same filtering.
441
+ // Only the dangerous fields apply here - unlike the top level, a hop
442
+ // legitimately sets its own host and port.
443
+ if (Array.isArray(opts.connectionHoppings)) {
444
+ opts.connectionHoppings = opts.connectionHoppings
445
+ .filter(hop => hop && typeof hop === 'object' && !Array.isArray(hop))
446
+ .map(hop => withoutDenied(hop, DANGEROUS_SESSION_FIELDS))
447
+ }
409
448
  } catch (err) {
410
449
  console.error('Failed to parse opts:', err)
411
450
  }
@@ -452,5 +491,7 @@ export {
452
491
  getSupportedProtocols,
453
492
  SUPPORTED_PROTOCOLS,
454
493
  DEFAULT_PORTS,
455
- OPTS_DENY_LIST
494
+ TYPE_DEFAULT_VALUES,
495
+ OPTS_DENY_LIST,
496
+ DANGEROUS_SESSION_FIELDS
456
497
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Quick Connect String Serializer
3
+ *
4
+ * The inverse of ./parse-quick-connect.js: turn a bookmark (session config)
5
+ * into the string form the quick connect box and the electerm:// deep link
6
+ * accept, so the two stay round-trippable.
7
+ *
8
+ * protocol://[username[:password]@]host[:port]?opts={...}
9
+ *
10
+ * Anything the URL cannot carry (title, tunnels, hopping, terminal settings)
11
+ * goes into `opts`. Fields that would reach spawn() or a shell never do - they
12
+ * are in DANGEROUS_SESSION_FIELDS and parseQuickConnect() drops them anyway.
13
+ *
14
+ * Known limit, inherited from the parser: username/password are not
15
+ * percent-encoded, so a credential containing '@' or ':' will not round-trip.
16
+ */
17
+ import DANGEROUS_SESSION_FIELDS from './dangerous-session-fields'
18
+ import { DEFAULT_PORTS, TYPE_DEFAULT_VALUES } from './parse-quick-connect'
19
+
20
+ // the protocols a bookmark type can actually be expressed as
21
+ const STRINGIFY_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp']
22
+
23
+ // carried by the URL itself, internal to the config, or not connection data
24
+ const SKIP_FIELDS = [
25
+ 'id',
26
+ 'category',
27
+ 'color',
28
+ 'type',
29
+ 'host',
30
+ 'port',
31
+ 'username',
32
+ 'user',
33
+ 'password',
34
+ 'url',
35
+ 'path',
36
+ 'baudRate',
37
+ 'terminalBackground',
38
+ ...DANGEROUS_SESSION_FIELDS
39
+ ]
40
+
41
+ const isBlank = v =>
42
+ v === undefined ||
43
+ v === null ||
44
+ v === '' ||
45
+ (Array.isArray(v) && !v.length) ||
46
+ (typeof v === 'object' && !Object.keys(v).length)
47
+
48
+ /**
49
+ * The fields worth carrying in `opts`: everything the URL does not already
50
+ * hold, minus the values parseQuickConnect() restores on its own (its per-type
51
+ * defaults) and minus the empty ones a form leaves behind. A bookmark that is
52
+ * all defaults serializes to just the URL.
53
+ */
54
+ function buildOpts (bookmark, type) {
55
+ const defaults = TYPE_DEFAULT_VALUES[type] || {}
56
+ const opts = {}
57
+ Object.keys(bookmark).forEach(key => {
58
+ if (SKIP_FIELDS.includes(key)) {
59
+ return
60
+ }
61
+ const value = bookmark[key]
62
+ if (isBlank(value)) {
63
+ return
64
+ }
65
+ if (
66
+ key in defaults &&
67
+ JSON.stringify(defaults[key]) === JSON.stringify(value)
68
+ ) {
69
+ return
70
+ }
71
+ opts[key] = value
72
+ })
73
+ return Object.keys(opts).length ? opts : null
74
+ }
75
+
76
+ function appendOpts (str, bookmark, type) {
77
+ const opts = buildOpts(bookmark, type)
78
+ return opts
79
+ ? str + (str.includes('?') ? '&' : '?') + 'opts=' + JSON.stringify(opts)
80
+ : str
81
+ }
82
+
83
+ /**
84
+ * Serialize a bookmark into a quick connect string
85
+ * @param {object} bookmark - bookmark / session config
86
+ * @returns {string} quick connect string, '' when the type has no string form
87
+ */
88
+ export function stringifyQuickConnect (bookmark = {}) {
89
+ const {
90
+ type = 'ssh',
91
+ host,
92
+ port,
93
+ username,
94
+ user,
95
+ password,
96
+ url,
97
+ path
98
+ } = bookmark
99
+
100
+ // web: the url already is the whole address, only the extras need carrying
101
+ if (type === 'web') {
102
+ return url ? appendOpts(url, bookmark, type) : ''
103
+ }
104
+
105
+ if (!STRINGIFY_PROTOCOLS.includes(type)) {
106
+ return ''
107
+ }
108
+
109
+ if (type === 'serial') {
110
+ if (!path) {
111
+ return ''
112
+ }
113
+ const baudRate = isBlank(bookmark.baudRate) ? '' : `:${bookmark.baudRate}`
114
+ return appendOpts(`serial://${path}${baudRate}`, bookmark, type)
115
+ }
116
+
117
+ if (!host) {
118
+ return ''
119
+ }
120
+
121
+ const name = username || user
122
+ const auth = name
123
+ ? name + (isBlank(password) ? '' : `:${password}`) + '@'
124
+ : ''
125
+
126
+ // a default port is restored by the parser, so leave it out
127
+ const portStr = !isBlank(port) && Number(port) !== DEFAULT_PORTS[type]
128
+ ? `:${port}`
129
+ : ''
130
+
131
+ return appendOpts(`${type}://${auth}${host}${portStr}`, bookmark, type)
132
+ }
133
+
134
+ export default stringifyQuickConnect
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Join a base directory with a filename that may come from an untrusted source
3
+ * (a remote server's directory listing, a drag payload, ...) and keep the
4
+ * result inside the base.
5
+ *
6
+ * The sanitize step is load-bearing, not cosmetic. electerm's `resolve` does no
7
+ * normalisation and three of its branches discard the base outright: a leading
8
+ * `/`, a UNC `\\`, and a Windows drive prefix. The drive branch is the easy one
9
+ * to miss because it needs no separator at all — `resolve('/uploads', 'C:x')`
10
+ * returns `'C:x'`. Stripping `:` and the separators removes every one of those
11
+ * branches, leaving only the "append to base" path.
12
+ *
13
+ * @param {String} base directory the name must stay inside
14
+ * @param {String} name untrusted file name
15
+ * @return {String} base + separator + a safe name, always inside `base`
16
+ */
17
+
18
+ import resolve from './resolve.js'
19
+ import sanitizeFilename from './sanitize-filename.js'
20
+
21
+ // A single path component: no separator, and not `.` or `..`.
22
+ const SINGLE_COMPONENT = /^(?!\.\.?$)[^/\\]+$/
23
+
24
+ export default function safeJoin (base, name) {
25
+ const safeName = sanitizeFilename(name)
26
+ // sanitizeFilename already satisfies this, so the fallback is unreachable in
27
+ // practice: it is here so that a future change to its character list degrades
28
+ // to a neutral name instead of escaping the base. Deliberately not a throw —
29
+ // a bad entry in a remote listing must not take down the whole drop. It also
30
+ // stays silent on purpose: no legitimate name contains a separator, so the
31
+ // only names that can reach the fallback are hostile ones.
32
+ return resolve(base, SINGLE_COMPONENT.test(safeName) ? safeName : 'unnamed')
33
+ }
@@ -14,6 +14,14 @@
14
14
  * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
15
15
  * - Limit filename length to 255 bytes (common filesystem limit)
16
16
  * - Fallback to 'unnamed' if result is empty
17
+ *
18
+ * Note for callers using this to build a path: the output is guaranteed to
19
+ * contain no path separator (`/` or `\`) and never to be `.` or `..`, because
20
+ * all of those are in ILLEGAL_CHARS or stripped as trailing dots. That is what
21
+ * makes `resolve(dir, sanitizeFilename(name))` stay inside `dir` — and it is
22
+ * load-bearing for path containment (CWE-22), not just cosmetics. Prefer
23
+ * `safeJoin(base, name)` when the name is untrusted: it depends on this
24
+ * property and asserts it.
17
25
  */
18
26
 
19
27
  // Characters illegal on Windows (and problematic on many systems)
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Colour maths for the UI theme's derived variables (`--main-darker`,
3
+ * `--main-lighter`).
4
+ *
5
+ * These used to be computed by stylus at build time (`darken(main, 30%)`), so
6
+ * the runtime replacement has to produce the same values -- otherwise a theme
7
+ * renders differently depending on which side of the refactor computed it.
8
+ * Both halves below deliberately mirror stylus's own implementation, float
9
+ * ordering included (lib/nodes/hsla.js `fromRGBA`, lib/nodes/rgba.js
10
+ * `fromHSLA`, lib/functions/adjust.js): the two agree only up to the last bit,
11
+ * and that bit decides a rounded channel.
12
+ */
13
+
14
+ const HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
15
+
16
+ /**
17
+ * Parse `#rgb` / `#rrggbb` into `[r, g, b]` (0-255), or null when the value is
18
+ * not a hex colour (a theme value may be `rgba(...)`, a name, etc).
19
+ * @param {string} color
20
+ * @return {Array|null}
21
+ */
22
+ function parseHex (color) {
23
+ const m = HEX_RE.exec(String(color).trim())
24
+ if (!m) {
25
+ return null
26
+ }
27
+ let hex = m[1]
28
+ if (hex.length === 3) {
29
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
30
+ }
31
+ const num = parseInt(hex, 16)
32
+ return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]
33
+ }
34
+
35
+ /**
36
+ * `HSLA.fromRGBA` -- h in degrees (may come back negative, as stylus leaves
37
+ * it), s and l in percent.
38
+ * @param {Array} rgb [r, g, b] 0-255
39
+ * @return {Array} [h, s, l]
40
+ */
41
+ function rgbToHsl ([r, g, b]) {
42
+ r /= 255
43
+ g /= 255
44
+ b /= 255
45
+ const min = Math.min(r, g, b)
46
+ const max = Math.max(r, g, b)
47
+ const l = (max + min) / 2
48
+ const d = max - min
49
+ let h = 0
50
+ let s = 0
51
+ switch (max) {
52
+ case min: h = 0; break
53
+ case r: h = 60 * (g - b) / d; break
54
+ case g: h = 60 * (b - r) / d + 120; break
55
+ case b: h = 60 * (r - g) / d + 240; break
56
+ }
57
+ if (max === min) {
58
+ s = 0
59
+ } else if (l < 0.5) {
60
+ s = d / (2 * l)
61
+ } else {
62
+ s = d / (2 - 2 * l)
63
+ }
64
+ h %= 360
65
+ return [h, s * 100, l * 100]
66
+ }
67
+
68
+ /**
69
+ * `RGBA.fromHSLA` -- note this is the `m1`/`m2`/`hue()` form, not the usual
70
+ * chroma/hue-sector form; the two disagree by one ulp and therefore
71
+ * occasionally by one channel.
72
+ * @param {number} h degrees
73
+ * @param {number} s percent
74
+ * @param {number} l percent
75
+ * @return {Array} [r, g, b] 0-255
76
+ */
77
+ function hslToRgb (h, s, l) {
78
+ h /= 360
79
+ s /= 100
80
+ l /= 100
81
+ const m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s
82
+ const m1 = l * 2 - m2
83
+ const hue = (x) => {
84
+ if (x < 0) ++x
85
+ if (x > 1) --x
86
+ if (x * 6 < 1) return m1 + (m2 - m1) * x * 6
87
+ if (x * 2 < 1) return m2
88
+ if (x * 3 < 2) return m1 + (m2 - m1) * (2 / 3 - x) * 6
89
+ return m1
90
+ }
91
+ return [
92
+ hue(h + 1 / 3),
93
+ hue(h),
94
+ hue(h - 1 / 3)
95
+ ].map(v => Math.max(0, Math.min(Math.round(v * 0xff), 255)))
96
+ }
97
+
98
+ /**
99
+ * Move a colour's HSL lightness by `percent`, replicating stylus's
100
+ * `adjust(color, 'lightness', ...)` -- the function behind `darken()` /
101
+ * `lighten()` (see stylus/lib/functions/adjust.js). For a percentage `p`:
102
+ *
103
+ * p > 0 (lighten): l += (100 - l) * p / 100 -- approaches 100 asymptotically
104
+ * p < 0 (darken): l += l * (p / 100) -- multiplicative, never clamps
105
+ *
106
+ * The multiplication is the important half: `darken(c, 30%)` is `0.7 * l`, so a
107
+ * near-black colour stays a near-black of the same hue. Subtracting a flat
108
+ * amount per channel instead (the previous implementation) drove every channel
109
+ * of a dark colour to 0 and produced pure black.
110
+ *
111
+ * @param {string} color hex colour
112
+ * @param {number} percent positive lightens, negative darkens
113
+ * @return {string} hex colour, or `color` unchanged when it is not hex
114
+ */
115
+ export function adjustLightness (color, percent) {
116
+ const rgb = parseHex(color)
117
+ if (!rgb || !percent) {
118
+ return color
119
+ }
120
+ const [h, s, l] = rgbToHsl(rgb)
121
+ const delta = percent > 0
122
+ ? (100 - l) * percent / 100
123
+ : l * (percent / 100)
124
+ return '#' + hslToRgb(h, s, l + delta)
125
+ .map(v => v.toString(16).padStart(2, '0'))
126
+ .join('')
127
+ }
128
+
129
+ /**
130
+ * @param {string} color hex colour
131
+ * @param {number} amount fraction: 0.3 = 30% darker, -0.3 = 30% lighter
132
+ * @return {string} hex colour
133
+ */
134
+ export function darker (color, amount = 0.1) {
135
+ return adjustLightness(color, -amount * 100)
136
+ }
@@ -69,6 +69,18 @@ export async function getLatestReleaseVersion (n) {
69
69
  }
70
70
  }
71
71
 
72
+ // GitHub and atomgit serve release assets from the same path shape:
73
+ // https://github.com/electerm/electerm/releases/download/<tag>/<file>
74
+ // https://atomgit.com/electerm/electerm/releases/download/<tag>/<file>
75
+ // so only the two trailing segments (tag, file name) need carrying over.
76
+ // The asset itself only exists on atomgit for the newest release: the sync
77
+ // workflow empties every older release to stay under the attachment quota.
78
+ export function getAtomgitUrl (url) {
79
+ const arr = url.split('/')
80
+ const len = arr.length
81
+ return `https://atomgit.com/electerm/electerm/releases/download/${arr[len - 2]}/${arr[len - 1]}`
82
+ }
83
+
72
84
  export function getDownloadUrl (browserDownloadUrl, mirror) {
73
85
  if (!browserDownloadUrl) {
74
86
  return ''
@@ -81,6 +93,8 @@ export function getDownloadUrl (browserDownloadUrl, mirror) {
81
93
  return `https://master.dl.sourceforge.net/project/electerm.mirror/${arr[len - 2]}/${arr[len - 1]}?viasf=1`
82
94
  } else if (mirror === 'r2') {
83
95
  return `https://electerm-store.html5beta.com/r/${browserDownloadUrl.split('/').pop()}`
96
+ } else if (mirror === 'atomgit') {
97
+ return getAtomgitUrl(browserDownloadUrl)
84
98
  } else {
85
99
  return browserDownloadUrl
86
100
  }
@@ -1,5 +1,6 @@
1
1
  import { useRef, useEffect } from 'react'
2
2
  import ReactMarkdown from 'react-markdown'
3
+ import remarkGfm from 'remark-gfm'
3
4
  import { copy } from '../../common/clipboard'
4
5
  import Link from '../common/external-link'
5
6
  import { Tag, Popconfirm, Button, Alert } from 'antd'
@@ -139,8 +140,12 @@ export default function AIOutput ({ item }) {
139
140
  )
140
141
  }
141
142
 
143
+ // remark-gfm: react-markdown only does CommonMark by default, so GFM tables
144
+ // (and strikethrough / task lists / autolinks) arrive as literal text.
145
+ // Styling for the resulting table lives in ai.styl.
142
146
  const mdProps = {
143
147
  children: response,
148
+ remarkPlugins: [remarkGfm],
144
149
  components: {
145
150
  code: renderCode
146
151
  }
@@ -59,6 +59,25 @@
59
59
  right 10px
60
60
  z-index 10
61
61
 
62
+ // markdown (react-markdown, GFM) tables in the AI answer. Just enough to be a
63
+ // usable table, using the theme's own colours.
64
+ // display:block + overflow-x:auto is the load bearing part: .ai-history-wrap is
65
+ // overflow-x hidden, so a table wider than the panel would otherwise be clipped
66
+ // with no way to reach its right hand columns. width:max-content keeps it
67
+ // shrink-to-fit while it still fits.
68
+ .ai-stream-output
69
+ table
70
+ display block
71
+ width max-content
72
+ max-width 100%
73
+ overflow-x auto
74
+ border-collapse collapse
75
+ th,
76
+ td
77
+ border 1px solid var(--main-darker)
78
+ padding 4px 8px
79
+ text-align left
80
+
62
81
 
63
82
  .ai-chat-input
64
83
  position relative