@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.
- package/client/common/constants.js +12 -0
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/default-setting.js +1 -0
- package/client/common/distribute-tabs.js +42 -0
- package/client/common/import-task.js +46 -3
- package/client/common/parse-quick-connect.js +47 -6
- package/client/common/quick-connect-string.js +134 -0
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -0
- package/client/common/ui-theme-color.mjs +136 -0
- package/client/common/update-check.js +14 -0
- package/client/components/ai/ai-output.jsx +5 -0
- package/client/components/ai/ai.styl +19 -0
- package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
- package/client/components/bookmark-form/form-renderer.jsx +13 -1
- package/client/components/bookmark-form/index.jsx +20 -1
- package/client/components/bookmark-form/render-form.jsx +2 -2
- package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
- package/client/components/main/ui-theme.jsx +1 -20
- package/client/components/main/upgrade.jsx +2 -0
- package/client/components/setting-panel/install-command.jsx +151 -0
- package/client/components/setting-panel/setting-common.jsx +5 -0
- package/client/components/setting-panel/setting-terminal.jsx +10 -1
- package/client/components/terminal/mixins/term-attach.js +10 -2
- package/client/components/terminal/mixins/term-init.js +1 -0
- package/client/components/terminal/mixins/term-socket.js +12 -2
- package/client/components/terminal/mixins/term-suggestions.js +16 -3
- package/client/components/terminal/osc52-addon.js +7 -2
- package/client/components/terminal/session-status-overlay.jsx +44 -0
- package/client/components/terminal/shell.js +11 -3
- package/client/components/terminal/shortcut-bar-press.js +192 -0
- package/client/components/terminal/shortcut-bar.jsx +199 -4
- package/client/components/terminal/shortcut-bar.styl +40 -21
- package/client/components/terminal/ssh-reload-state.js +41 -2
- package/client/components/terminal/startup-queue.js +6 -2
- package/client/components/terminal/terminal.jsx +17 -2
- package/client/components/terminal/terminal.styl +14 -5
- package/client/store/mcp-handler.js +8 -15
- package/client/store/sync.js +47 -18
- package/client/store/tab.js +47 -17
- package/client/store/watch.js +8 -0
- package/package.json +1 -1
- 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 (
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|