@electerm/electerm-react 5.5.66 → 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/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 +1 -0
- package/client/common/quick-connect-string.js +134 -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/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 +9 -1
- package/client/components/terminal/mixins/term-attach.js +10 -2
- 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/ssh-reload-state.js +41 -2
- package/client/components/terminal/startup-queue.js +6 -2
- package/client/components/terminal/terminal.jsx +13 -2
- package/client/components/terminal/terminal.styl +14 -5
- package/client/store/sync.js +46 -18
- package/client/store/tab.js +45 -2
- 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'
|
|
@@ -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
|
}
|
|
@@ -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,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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy the bookmark form's current values: as a quick connect string, or as
|
|
3
|
+
* the raw JSON that lands in the config file.
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { Dropdown } from 'antd'
|
|
7
|
+
import { CopyOutlined } from '@ant-design/icons'
|
|
8
|
+
import { copy } from '../../common/clipboard'
|
|
9
|
+
import { stringifyQuickConnect } from '../../common/quick-connect-string'
|
|
10
|
+
|
|
11
|
+
const e = window.translate
|
|
12
|
+
|
|
13
|
+
const maxLen = 50
|
|
14
|
+
|
|
15
|
+
function oneLine (str) {
|
|
16
|
+
const s = String(str || '').replace(/\s+/g, ' ').trim()
|
|
17
|
+
return s.length > maxLen ? s.slice(0, maxLen) + '...' : s
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function label (name, value) {
|
|
21
|
+
return (
|
|
22
|
+
<span title={value}>
|
|
23
|
+
<b className='mg1r'>{name}</b>
|
|
24
|
+
<span>{oneLine(value)}</span>
|
|
25
|
+
<CopyOutlined className='mg1l' />
|
|
26
|
+
</span>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function CopyBookmark ({ getValues }) {
|
|
31
|
+
const [open, setOpen] = useState(false)
|
|
32
|
+
const [items, setItems] = useState([])
|
|
33
|
+
|
|
34
|
+
const getStrings = () => {
|
|
35
|
+
const values = getValues() || {}
|
|
36
|
+
return {
|
|
37
|
+
quickConnect: stringifyQuickConnect(values),
|
|
38
|
+
json: JSON.stringify(values, null, 2)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const onOpenChange = (next) => {
|
|
43
|
+
setOpen(next)
|
|
44
|
+
if (!next) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const { quickConnect, json } = getStrings()
|
|
48
|
+
setItems([
|
|
49
|
+
{
|
|
50
|
+
key: 'quickConnect',
|
|
51
|
+
disabled: !quickConnect,
|
|
52
|
+
label: label(e('quickConnect'), quickConnect)
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: 'json',
|
|
56
|
+
label: label('JSON', json)
|
|
57
|
+
}
|
|
58
|
+
])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const onClick = ({ key }) => {
|
|
62
|
+
const { quickConnect, json } = getStrings()
|
|
63
|
+
const value = key === 'json' ? json : quickConnect
|
|
64
|
+
if (value) {
|
|
65
|
+
copy(value)
|
|
66
|
+
}
|
|
67
|
+
setOpen(false)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Dropdown
|
|
72
|
+
menu={{ items, onClick }}
|
|
73
|
+
open={open}
|
|
74
|
+
onOpenChange={onOpenChange}
|
|
75
|
+
trigger={['click']}
|
|
76
|
+
placement='bottomRight'
|
|
77
|
+
>
|
|
78
|
+
<span className='pointer fright' title={e('copy')}>
|
|
79
|
+
<CopyOutlined />
|
|
80
|
+
</span>
|
|
81
|
+
</Dropdown>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -22,7 +22,7 @@ import newTerm from '../../common/new-terminal'
|
|
|
22
22
|
import { isValidIP } from '../../common/is-ip'
|
|
23
23
|
import { action as manateAction } from 'manate'
|
|
24
24
|
|
|
25
|
-
export default function FormRenderer ({ config, props }) {
|
|
25
|
+
export default function FormRenderer ({ config, props, onForm }) {
|
|
26
26
|
const initialValues = config.initValues(props)
|
|
27
27
|
const [form] = Form.useForm()
|
|
28
28
|
const [ips, setIps] = useState([])
|
|
@@ -30,6 +30,18 @@ export default function FormRenderer ({ config, props }) {
|
|
|
30
30
|
const [testing, setTesting] = useState(false)
|
|
31
31
|
const action = useRef('submit')
|
|
32
32
|
|
|
33
|
+
// hand the form instance up so the header can read the current values
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (onForm) {
|
|
36
|
+
onForm(form)
|
|
37
|
+
}
|
|
38
|
+
return () => {
|
|
39
|
+
if (onForm) {
|
|
40
|
+
onForm(null)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, [onForm, form])
|
|
44
|
+
|
|
33
45
|
useEffect(() => {
|
|
34
46
|
const init = config.initValues(props)
|
|
35
47
|
form.resetFields()
|
|
@@ -21,6 +21,7 @@ import { LoadingOutlined, BookOutlined, RobotOutlined } from '@ant-design/icons'
|
|
|
21
21
|
import sessionConfig from './config/session-config'
|
|
22
22
|
import renderForm from './render-form'
|
|
23
23
|
import AIBookmarkForm from './ai-bookmark-form'
|
|
24
|
+
import CopyBookmark from './copy-bookmark'
|
|
24
25
|
import { isAIDisabled } from '../../common/ai-feature'
|
|
25
26
|
import './bookmark-form.styl'
|
|
26
27
|
|
|
@@ -80,6 +81,19 @@ export default class BookmarkIndex2 extends PureComponent {
|
|
|
80
81
|
this.setState({ bookmarkType: e.target.value })
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// the form instance lives in FormRenderer, the copy menu lives in the header
|
|
85
|
+
handleFormReady = (form) => {
|
|
86
|
+
this.form = form
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// same shape the form submits: saved bookmark + what is currently typed
|
|
90
|
+
getFormValues = () => {
|
|
91
|
+
return {
|
|
92
|
+
...this.props.formData,
|
|
93
|
+
...(this.form ? this.form.getFieldsValue() : {})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
handleCancelAiMode = () => {
|
|
84
98
|
this.setState({ aiMode: false })
|
|
85
99
|
}
|
|
@@ -147,12 +161,16 @@ export default class BookmarkIndex2 extends PureComponent {
|
|
|
147
161
|
)
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
renderCopy () {
|
|
165
|
+
return <CopyBookmark getValues={this.getFormValues} />
|
|
166
|
+
}
|
|
167
|
+
|
|
150
168
|
renderForm () {
|
|
151
169
|
const { bookmarkType, aiMode } = this.state
|
|
152
170
|
if (aiMode) {
|
|
153
171
|
return this.renderAiForm()
|
|
154
172
|
}
|
|
155
|
-
return renderForm(bookmarkType, this.props)
|
|
173
|
+
return renderForm(bookmarkType, this.props, this.handleFormReady)
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
render () {
|
|
@@ -180,6 +198,7 @@ export default class BookmarkIndex2 extends PureComponent {
|
|
|
180
198
|
</span>
|
|
181
199
|
{this.renderTitle(formData, isNew)}
|
|
182
200
|
{this.renderAIButton(isNew)}
|
|
201
|
+
{this.renderCopy()}
|
|
183
202
|
</p>
|
|
184
203
|
{this.renderTypes(bookmarkType, isNew, keys)}
|
|
185
204
|
</div>
|
|
@@ -5,7 +5,7 @@ import sessionConfig from './config/session-config'
|
|
|
5
5
|
import { connectionMap } from '../../common/constants'
|
|
6
6
|
import FormRenderer from './form-renderer'
|
|
7
7
|
|
|
8
|
-
export default function renderForm (type, props) {
|
|
8
|
+
export default function renderForm (type, props, onForm) {
|
|
9
9
|
const conf = sessionConfig[type] || sessionConfig[connectionMap.ssh]
|
|
10
|
-
return <FormRenderer config={conf} props={props} />
|
|
10
|
+
return <FormRenderer config={conf} props={props} onForm={onForm} />
|
|
11
11
|
}
|