@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.
Files changed (33) hide show
  1. package/client/common/constants.js +12 -0
  2. package/client/common/default-setting.js +1 -0
  3. package/client/common/distribute-tabs.js +42 -0
  4. package/client/common/import-task.js +46 -3
  5. package/client/common/parse-quick-connect.js +1 -0
  6. package/client/common/quick-connect-string.js +134 -0
  7. package/client/common/ui-theme-color.mjs +136 -0
  8. package/client/common/update-check.js +14 -0
  9. package/client/components/ai/ai-output.jsx +5 -0
  10. package/client/components/ai/ai.styl +19 -0
  11. package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
  12. package/client/components/bookmark-form/form-renderer.jsx +13 -1
  13. package/client/components/bookmark-form/index.jsx +20 -1
  14. package/client/components/bookmark-form/render-form.jsx +2 -2
  15. package/client/components/main/ui-theme.jsx +1 -20
  16. package/client/components/main/upgrade.jsx +2 -0
  17. package/client/components/setting-panel/install-command.jsx +151 -0
  18. package/client/components/setting-panel/setting-common.jsx +5 -0
  19. package/client/components/setting-panel/setting-terminal.jsx +9 -1
  20. package/client/components/terminal/mixins/term-attach.js +10 -2
  21. package/client/components/terminal/mixins/term-socket.js +12 -2
  22. package/client/components/terminal/mixins/term-suggestions.js +16 -3
  23. package/client/components/terminal/osc52-addon.js +7 -2
  24. package/client/components/terminal/session-status-overlay.jsx +44 -0
  25. package/client/components/terminal/ssh-reload-state.js +41 -2
  26. package/client/components/terminal/startup-queue.js +6 -2
  27. package/client/components/terminal/terminal.jsx +13 -2
  28. package/client/components/terminal/terminal.styl +14 -5
  29. package/client/store/sync.js +46 -18
  30. package/client/store/tab.js +45 -2
  31. package/client/store/watch.js +8 -0
  32. package/package.json +1 -1
  33. 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 (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
  }
@@ -491,6 +491,7 @@ export {
491
491
  getSupportedProtocols,
492
492
  SUPPORTED_PROTOCOLS,
493
493
  DEFAULT_PORTS,
494
+ TYPE_DEFAULT_VALUES,
494
495
  OPTS_DENY_LIST,
495
496
  DANGEROUS_SESSION_FIELDS
496
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,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
  }