@electerm/electerm-react 3.15.175 → 3.15.186

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 (37) hide show
  1. package/client/common/bookmark-schemas.js +2 -0
  2. package/client/common/drop-position.js +59 -0
  3. package/client/components/bookmark-form/bookmark-schema.js +2 -0
  4. package/client/components/bookmark-form/common/connection-hopping.jsx +33 -7
  5. package/client/components/bookmark-form/common/ssh-tunnel-form.jsx +25 -7
  6. package/client/components/bookmark-form/common/ssh-tunnels.jsx +1 -1
  7. package/client/components/bookmark-form/config/serial.js +14 -0
  8. package/client/components/main/term-fullscreen.styl +5 -0
  9. package/client/components/quick-commands/on-drop.js +6 -1
  10. package/client/components/quick-commands/quick-commands-box.jsx +18 -2
  11. package/client/components/quick-commands/quick-commands-list-form.jsx +15 -4
  12. package/client/components/quick-commands/quick-commands-list.jsx +18 -2
  13. package/client/components/session/session-control.jsx +331 -0
  14. package/client/components/session/session-control.styl +38 -0
  15. package/client/components/session/session.jsx +41 -246
  16. package/client/components/session/session.styl +1 -0
  17. package/client/components/sftp/address-bookmark-item.jsx +15 -2
  18. package/client/components/sftp/code-compare-utils.js +260 -0
  19. package/client/components/sftp/code-compare.jsx +183 -0
  20. package/client/components/sftp/code-compare.styl +13 -0
  21. package/client/components/sftp/file-compare-modal.jsx +87 -42
  22. package/client/components/shortcuts/shortcut-handler.js +20 -0
  23. package/client/components/sidebar/transport-ui.jsx +21 -14
  24. package/client/components/tabs/index.jsx +18 -3
  25. package/client/components/tabs/tab.jsx +27 -8
  26. package/client/components/tabs/tabs.styl +8 -0
  27. package/client/components/terminal/attach-addon-custom.js +147 -36
  28. package/client/components/terminal/highlight-addon.js +26 -8
  29. package/client/components/terminal/term-search.jsx +1 -1
  30. package/client/components/terminal/terminal.jsx +193 -2
  31. package/client/components/terminal/terminal.styl +0 -2
  32. package/client/components/terminal-info/run-cmd.jsx +1 -1
  33. package/client/components/tree-list/tree-list.jsx +57 -39
  34. package/client/css/basic.styl +14 -0
  35. package/client/css/mobile.styl +31 -9
  36. package/client/store/item.js +10 -6
  37. package/package.json +1 -1
@@ -99,6 +99,8 @@ export const serialBookmarkSchema = {
99
99
  xany: z.boolean().optional().describe('XANY flow control'),
100
100
  txLineEnding: z.enum(['\r', '\n', '\r\n']).optional().describe('TX line ending appended on Enter: "\\r" (CR, default), "\\n" (LF), "\\r\\n" (CR+LF)'),
101
101
  rxLineEnding: z.enum(['none', 'lf_to_crlf', 'cr_to_crlf']).optional().describe('RX line ending conversion: "none" (pass-through, default), "lf_to_crlf" (LF→CRLF for LF-only devices), "cr_to_crlf" (CR→CRLF for CR-only devices)'),
102
+ closeSequence: z.string().optional().describe('Key sequence sent to the serial port when the user clicks "exit gracefully" in the terminal controls (e.g. to cleanly exit GNU screen before disconnecting a Bluetooth serial console). Supports \\n \\t \\r \\\\ and \\xHH hex bytes, default "\\x01ky" (Ctrl+A, k, y - GNU screen kill-window confirm)'),
103
+ closeSequenceDelay: z.number().optional().describe('Milliseconds to wait after sending closeSequence before actually closing the port, default 500'),
102
104
  description: z.string().optional().describe('Bookmark description')
103
105
  }
104
106
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Decide whether a dragged item should be inserted
3
+ * AFTER the drop target, based on which half of the
4
+ * target element the pointer is currently in.
5
+ *
6
+ * This is the fix for the "can not drag to the last
7
+ * position" bug: splitting each item into a top/bottom
8
+ * (or left/right) half gives a valid drop zone after the
9
+ * last item, so the insertion index can reach `length`
10
+ * (append) instead of being capped at `length - 1`.
11
+ *
12
+ * @param {Object} e native drag event (needs clientX/clientY)
13
+ * @param {HTMLElement} el resolved drop target element
14
+ * @param {boolean} horizontal true for horizontal lists (e.g. tabs)
15
+ * @returns {boolean} true => insert the dragged item after the target
16
+ */
17
+ export function isDropAfterHalf (e, el, horizontal = false) {
18
+ if (!el) {
19
+ return false
20
+ }
21
+ const rect = el.getBoundingClientRect()
22
+ if (!rect.height && !rect.width) {
23
+ return false
24
+ }
25
+ if (horizontal) {
26
+ return e.clientX > rect.left + rect.width / 2
27
+ }
28
+ return e.clientY > rect.top + rect.height / 2
29
+ }
30
+
31
+ const DROP_BEFORE_CLS = 'dnd-before'
32
+ const DROP_AFTER_CLS = 'dnd-after'
33
+
34
+ /**
35
+ * Show a directional drop indicator on the target element.
36
+ * first half => top edge (insert before)
37
+ * second half => bottom edge (insert after)
38
+ * Used together with the `.dnd-before` / `.dnd-after` CSS rules.
39
+ *
40
+ * @param {HTMLElement} el resolved drop target element
41
+ * @param {boolean} insertAfter result of isDropAfterHalf()
42
+ */
43
+ export function setDropIndicator (el, insertAfter) {
44
+ if (!el) {
45
+ return
46
+ }
47
+ el.classList.remove(DROP_BEFORE_CLS, DROP_AFTER_CLS)
48
+ el.classList.add(insertAfter ? DROP_AFTER_CLS : DROP_BEFORE_CLS)
49
+ }
50
+
51
+ /**
52
+ * Remove any directional drop indicator from the target element.
53
+ */
54
+ export function clearDropIndicator (el) {
55
+ if (!el) {
56
+ return
57
+ }
58
+ el.classList.remove(DROP_BEFORE_CLS, DROP_AFTER_CLS)
59
+ }
@@ -84,6 +84,8 @@ const bookmarkSchema = {
84
84
  xany: 'boolean - enable XANY flow control, default is false',
85
85
  txLineEnding: 'string - TX line ending on Enter: "\\r" (CR, default), "\\n" (LF), "\\r\\n" (CR+LF)',
86
86
  rxLineEnding: 'string - RX line ending conversion: "none" (default), "lf_to_crlf" (for LF-only devices), "cr_to_crlf" (for CR-only devices)',
87
+ closeSequence: 'string - key sequence sent to the serial port when the user clicks "exit gracefully" in the terminal controls, supports \\n \\t \\r \\\\ and \\xHH hex bytes, default "\\x01ky" (Ctrl+A, k, y - GNU screen kill-window confirm)',
88
+ closeSequenceDelay: 'number - ms to wait after sending closeSequence before closing the port, default 500',
87
89
  runScripts: 'array - run scripts after connected ({delay,script})',
88
90
  description: 'string - bookmark description'
89
91
  },
@@ -17,6 +17,7 @@ import {
17
17
  connectionHoppingWarnKey
18
18
  } from '../../../common/constants'
19
19
  import { useState, useRef, useCallback } from 'react'
20
+ import { isDropAfterHalf } from '../../../common/drop-position'
20
21
  import ConnectionHoppingWarningText from '../../common/connection-hopping-warning-text'
21
22
  import * as ls from '../../../common/safe-local-storage'
22
23
  import Modal from '../../common/modal'
@@ -49,33 +50,56 @@ export default function renderConnectionHopping (props) {
49
50
  const [list, setList] = useState(formData.connectionHoppings || [])
50
51
  const dragItem = useRef(null)
51
52
  const dragOverItem = useRef(null)
53
+ // tracks whether the pointer is in the bottom half of the hovered row,
54
+ // so a drop can land after the target (including after the last row).
55
+ const dragOverAfter = useRef(false)
56
+ // { index, after } of the row currently hovered, or null. Drives the
57
+ // directional drop indicator on the antd table row via a className.
58
+ const [overRow, setOverRow] = useState(null)
52
59
 
53
60
  const handleDragStart = useCallback((index) => {
54
61
  dragItem.current = index
55
62
  }, [])
56
63
 
57
- const handleDragEnter = useCallback((index) => {
64
+ // track target index + before/after half on every drag-over pass,
65
+ // because onDragEnter only fires once per row and cannot detect
66
+ // moving between the top and bottom half of the same row.
67
+ const handleDragOver = useCallback((e, index) => {
68
+ e.preventDefault()
69
+ const after = isDropAfterHalf(e, e.currentTarget)
58
70
  dragOverItem.current = index
71
+ dragOverAfter.current = after
72
+ setOverRow({ index, after })
59
73
  }, [])
60
74
 
61
75
  const handleDragEnd = useCallback(() => {
76
+ setOverRow(null)
62
77
  if (dragItem.current === null || dragOverItem.current === null) {
63
78
  return
64
79
  }
65
80
  if (dragItem.current === dragOverItem.current) {
66
81
  dragItem.current = null
67
82
  dragOverItem.current = null
83
+ dragOverAfter.current = false
68
84
  return
69
85
  }
70
86
  setList(old => {
71
87
  const newList = [...old]
72
- const [removed] = newList.splice(dragItem.current, 1)
73
- newList.splice(dragOverItem.current, 0, removed)
88
+ const fromIndex = dragItem.current
89
+ let insertIndex = dragOverAfter.current
90
+ ? dragOverItem.current + 1
91
+ : dragOverItem.current
92
+ const [removed] = newList.splice(fromIndex, 1)
93
+ if (fromIndex < insertIndex) {
94
+ insertIndex = insertIndex - 1
95
+ }
96
+ newList.splice(insertIndex, 0, removed)
74
97
  form.setFieldsValue({
75
98
  connectionHoppings: newList
76
99
  })
77
100
  dragItem.current = null
78
101
  dragOverItem.current = null
102
+ dragOverAfter.current = false
79
103
  return newList
80
104
  })
81
105
  }, [form])
@@ -205,15 +229,17 @@ export default function renderConnectionHopping (props) {
205
229
  <FormItem {...tailFormItemLayout}>
206
230
  <Table
207
231
  columns={cols}
208
- className='mg3b'
232
+ className='mg3b connection-hopping-table'
209
233
  pagination={false}
210
234
  size='small'
211
235
  onRow={(record, index) => ({
212
236
  draggable: true,
237
+ className: overRow && overRow.index === index
238
+ ? (overRow.after ? 'dnd-after' : 'dnd-before')
239
+ : '',
213
240
  onDragStart: () => handleDragStart(index),
214
- onDragEnter: () => handleDragEnter(index),
215
- onDragEnd: handleDragEnd,
216
- onDragOver: (e) => e.preventDefault()
241
+ onDragOver: (e) => handleDragOver(e, index),
242
+ onDragEnd: handleDragEnd
217
243
  })}
218
244
  dataSource={list.map((d, i) => {
219
245
  return {
@@ -32,10 +32,18 @@ export default function SshTunnelForm (props) {
32
32
  } = props
33
33
 
34
34
  const [isDynamic, setIsDynamic] = useState(
35
- (initialValues?.sshTunnel || 'forwardRemoteToLocal') === 'dynamicForward'
35
+ (initialValues?.sshTunnel || 'forwardLocalToRemote') === 'dynamicForward'
36
36
  )
37
37
  const [dirty, setDirty] = useState(false)
38
38
 
39
+ // watch host/port fields so the tooltip examples reflect what the user typed
40
+ const localHost = Form.useWatch('sshTunnelLocalHost', formChild) || '127.0.0.1'
41
+ const localPort = Form.useWatch('sshTunnelLocalPort', formChild) || '12200'
42
+ const remoteHost = Form.useWatch('sshTunnelRemoteHost', formChild) || '127.0.0.1'
43
+ const remotePort = Form.useWatch('sshTunnelRemotePort', formChild) || '12300'
44
+ const localAddr = `${localHost}:${localPort}`
45
+ const remoteAddr = `${remoteHost}:${remotePort}`
46
+
39
47
  function onChange (ev) {
40
48
  setIsDynamic(ev.target.value === 'dynamicForward')
41
49
  }
@@ -63,13 +71,23 @@ export default function SshTunnelForm (props) {
63
71
  <div>
64
72
  <p>{e(direction)}</p>
65
73
  <p><UserOutlined /> → {middle} → {last}</p>
74
+ {localToRemote
75
+ ? (
76
+ <p>Connect to <b>{localAddr}</b> on your machine to reach <b>{remoteAddr}</b> on the remote server (e.g. open <b>http://{localAddr}</b> in a browser).</p>
77
+ )
78
+ : (
79
+ <p>Connect to <b>{remoteAddr}</b> on the remote server to reach <b>{localAddr}</b> on your machine.</p>
80
+ )}
66
81
  </div>
67
82
  )
68
83
  }
69
84
 
70
85
  function renderDynamicForward () {
71
86
  return (
72
- <p><UserOutlined /> → socks proxy → url</p>
87
+ <div>
88
+ <p><UserOutlined /> → socks proxy → url</p>
89
+ <p>Point your app's SOCKS5 proxy at <b>{localAddr}</b> to reach any host through the remote server.</p>
90
+ </div>
73
91
  )
74
92
  }
75
93
 
@@ -125,16 +143,16 @@ export default function SshTunnelForm (props) {
125
143
  required
126
144
  >
127
145
  <RadioGroup onChange={onChange}>
128
- <RadioButton value='forwardRemoteToLocal'>
129
- <Tooltip title={renderSshTunnelFlow('remoteToLocal')}>
130
- <span>R→L <QuestionCircleOutlined /></span>
131
- </Tooltip>
132
- </RadioButton>
133
146
  <RadioButton value='forwardLocalToRemote'>
134
147
  <Tooltip title={renderSshTunnelFlow('localToRemote')}>
135
148
  <span>L→R <QuestionCircleOutlined /></span>
136
149
  </Tooltip>
137
150
  </RadioButton>
151
+ <RadioButton value='forwardRemoteToLocal'>
152
+ <Tooltip title={renderSshTunnelFlow('remoteToLocal')}>
153
+ <span>R→L <QuestionCircleOutlined /></span>
154
+ </Tooltip>
155
+ </RadioButton>
138
156
  <RadioButton value='dynamicForward'>
139
157
  <Tooltip title={renderDynamicForward()}>
140
158
  <span>{e('dynamicForward')}(socks proxy) <QuestionCircleOutlined /></span>
@@ -14,7 +14,7 @@ const FormItem = Form.Item
14
14
  const e = window.translate
15
15
 
16
16
  const defaultInitialValues = {
17
- sshTunnel: 'forwardRemoteToLocal',
17
+ sshTunnel: 'forwardLocalToRemote',
18
18
  sshTunnelLocalPort: 12200,
19
19
  sshTunnelLocalHost: '127.0.0.1',
20
20
  sshTunnelRemotePort: 12300,
@@ -20,6 +20,8 @@ const serialConfig = {
20
20
  xon: false,
21
21
  xoff: false,
22
22
  xany: false,
23
+ closeSequence: '\\x01ky',
24
+ closeSequenceDelay: 500,
23
25
  term: defaultSettings.terminalType,
24
26
  displayRaw: false,
25
27
  runScripts: [{}],
@@ -59,6 +61,18 @@ const serialConfig = {
59
61
  { type: 'switch', name: 'xany', label: 'xany', valuePropName: 'checked' },
60
62
  { type: 'select', name: 'txLineEnding', label: 'txLineEnding', options: commonTxLineEndings.map(d => ({ value: d.value, label: d.label })) },
61
63
  { type: 'select', name: 'rxLineEnding', label: 'rxLineEnding', options: commonRxLineEndings.map(d => ({ value: d.value, label: d.label })) },
64
+ {
65
+ type: 'input',
66
+ name: 'closeSequence',
67
+ label: e('closeSequence'),
68
+ props: { placeholder: '\\x01ky' }
69
+ },
70
+ {
71
+ type: 'number',
72
+ name: 'closeSequenceDelay',
73
+ label: e('closeSequenceDelay'),
74
+ props: { min: 0, max: 10000, step: 100 }
75
+ },
62
76
  commonFields.runScripts,
63
77
  commonFields.description,
64
78
  { type: 'input', name: 'type', label: 'type', hidden: true }
@@ -7,6 +7,11 @@
7
7
  .main-footer
8
8
  .session-v-info
9
9
  display none
10
+ // Mobile tabs carry the extra .mobile-tabs class, and mobile.styl uses
11
+ // .is-mobile .tabs.mobile-tabs (specificity 0,3,0) which overrides the
12
+ // .fullscreen .tabs rule above (0,2,0). This ensures they stay hidden.
13
+ .tabs.mobile-tabs
14
+ display none
10
15
  // Hide all sessions first
11
16
  .session-wrap
12
17
  display none
@@ -1,8 +1,13 @@
1
+ import { isDropAfterHalf } from '../../common/drop-position'
2
+
1
3
  export default function onDrop (e, cls) {
2
4
  e.preventDefault()
3
5
  const { store } = window
4
6
  const idDragged = e.dataTransfer.getData('idDragged')
5
7
  const tar = cls ? e.target.closest(cls) : e.target
6
8
  const idDrop = tar.getAttribute('data-id')
7
- store.adjustOrder('quickCommands', idDragged, idDrop)
9
+ // drop on the bottom half of the target => insert after it,
10
+ // which makes dropping on the last item append to the end.
11
+ const insertAfter = isDropAfterHalf(e, tar)
12
+ store.adjustOrder('quickCommands', idDragged, idDrop, insertAfter)
8
13
  }
@@ -15,6 +15,7 @@ import {
15
15
  } from '@ant-design/icons'
16
16
  import classNames from 'classnames'
17
17
  import onDropFunc from './on-drop'
18
+ import { isDropAfterHalf, setDropIndicator, clearDropIndicator } from '../../common/drop-position'
18
19
  import './qm.styl'
19
20
 
20
21
  const e = window.translate
@@ -81,6 +82,10 @@ export default function QuickCommandsFooterBox (props) {
81
82
 
82
83
  function onDragOver (e) {
83
84
  e.preventDefault()
85
+ const el = e.target.closest('.qm-item')
86
+ if (el) {
87
+ setDropIndicator(el, isDropAfterHalf(e, el))
88
+ }
84
89
  }
85
90
 
86
91
  function onDragStart (e) {
@@ -88,14 +93,25 @@ export default function QuickCommandsFooterBox (props) {
88
93
  }
89
94
 
90
95
  function onDragEnter (e) {
91
- e.target.closest('.qm-item')?.classList.add('qm-item-dragover')
96
+ e.preventDefault()
97
+ const el = e.target.closest('.qm-item')
98
+ if (el) {
99
+ setDropIndicator(el, isDropAfterHalf(e, el))
100
+ }
92
101
  }
93
102
 
94
103
  function onDragLeave (e) {
95
- e.target.closest('.qm-item')?.classList.remove('qm-item-dragover')
104
+ const el = e.target.closest('.qm-item')
105
+ if (el) {
106
+ clearDropIndicator(el)
107
+ }
96
108
  }
97
109
 
98
110
  function onDrop (e) {
111
+ const el = e.target.closest('.qm-item')
112
+ if (el) {
113
+ clearDropIndicator(el)
114
+ }
99
115
  onDropFunc(e, '.qm-item')
100
116
  }
101
117
 
@@ -8,6 +8,7 @@ import {
8
8
  import { MinusCircleOutlined, PlusOutlined, HolderOutlined } from '@ant-design/icons'
9
9
  import HelpIcon from '../common/help-icon'
10
10
  import { copy } from '../../common/clipboard'
11
+ import { isDropAfterHalf, setDropIndicator, clearDropIndicator } from '../../common/drop-position'
11
12
  import { useRef } from 'react'
12
13
 
13
14
  const FormItem = Form.Item
@@ -30,28 +31,38 @@ export default function renderQm (form) {
30
31
  e.dataTransfer.dropEffect = 'move'
31
32
  const el = e.target.closest('.ant-space-compact')
32
33
  if (dragIndexRef.current !== index && el) {
33
- el.classList.add('qm-field-dragover')
34
+ setDropIndicator(el, isDropAfterHalf(e, el))
34
35
  }
35
36
  }
36
37
 
37
38
  function handleDragLeave (e) {
38
- e.target.closest('.ant-space-compact')?.classList.remove('qm-field-dragover')
39
+ const el = e.target.closest('.ant-space-compact')
40
+ if (el) {
41
+ clearDropIndicator(el)
42
+ }
39
43
  }
40
44
 
41
45
  function handleDrop (e, index, form) {
42
46
  e.preventDefault()
43
47
  const el = e.target.closest('.ant-space-compact')
44
- el?.classList.remove('qm-field-dragover')
48
+ clearDropIndicator(el)
45
49
  const dragIndex = dragIndexRef.current
46
50
  if (dragIndex === null || dragIndex === index) {
47
51
  dragIndexRef.current = null
48
52
  return
49
53
  }
54
+ // bottom half of the row => insert after it, so the last
55
+ // sub-command can receive a drop (append to the end).
56
+ const insertAfter = isDropAfterHalf(e, el)
50
57
  const commands = form.getFieldValue('commands') || []
51
58
  const item = commands[dragIndex]
52
59
  const newCommands = [...commands]
53
60
  newCommands.splice(dragIndex, 1)
54
- newCommands.splice(index, 0, item)
61
+ let insertIndex = insertAfter ? index + 1 : index
62
+ if (dragIndex < insertIndex) {
63
+ insertIndex = insertIndex - 1
64
+ }
65
+ newCommands.splice(insertIndex, 0, item)
55
66
  form.setFieldValue('commands', newCommands)
56
67
  dragIndexRef.current = null
57
68
  }
@@ -9,6 +9,7 @@ import classnames from 'classnames'
9
9
  import highlight from '../common/highlight'
10
10
  import QmTransport from './quick-command-transport'
11
11
  import onDrop from './on-drop'
12
+ import { isDropAfterHalf, setDropIndicator, clearDropIndicator } from '../../common/drop-position'
12
13
  import copy from 'json-deep-copy'
13
14
  import uid from '../../common/uid'
14
15
 
@@ -37,6 +38,10 @@ export default class QuickCommandsList extends List {
37
38
 
38
39
  handleDragOver = e => {
39
40
  e.preventDefault()
41
+ const el = e.target.closest('.item-list-unit')
42
+ if (el) {
43
+ setDropIndicator(el, isDropAfterHalf(e, el))
44
+ }
40
45
  }
41
46
 
42
47
  handleDragStart = e => {
@@ -47,14 +52,25 @@ export default class QuickCommandsList extends List {
47
52
  }
48
53
 
49
54
  handleDragEnter = e => {
50
- e.target.closest('.item-list-unit').classList.add('qm-field-dragover')
55
+ e.preventDefault()
56
+ const el = e.target.closest('.item-list-unit')
57
+ if (el) {
58
+ setDropIndicator(el, isDropAfterHalf(e, el))
59
+ }
51
60
  }
52
61
 
53
62
  handleDragLeave = e => {
54
- e.target.closest('.item-list-unit').classList.remove('qm-field-dragover')
63
+ const el = e.target.closest('.item-list-unit')
64
+ if (el) {
65
+ clearDropIndicator(el)
66
+ }
55
67
  }
56
68
 
57
69
  handleDrop = e => {
70
+ const el = e.target.closest('.item-list-unit')
71
+ if (el) {
72
+ clearDropIndicator(el)
73
+ }
58
74
  onDrop(e, '.item-list-unit')
59
75
  }
60
76