@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
@@ -21,7 +21,8 @@ import {
21
21
  rendererTypes,
22
22
  isMac,
23
23
  isMacJs,
24
- connectionMap
24
+ connectionMap,
25
+ terminalSerialType
25
26
  } from '../../common/constants.js'
26
27
  import deepCopy from 'json-deep-copy'
27
28
  import { readClipboardAsync, readClipboard, copy } from '../../common/clipboard.js'
@@ -74,6 +75,38 @@ import {
74
75
 
75
76
  const e = window.translate
76
77
 
78
+ // Expands \n \t \r \\ and \xHH hex byte escapes, used to let users type
79
+ // control bytes (e.g. \x01 = Ctrl+A) in the serial "closeSequence" field.
80
+ function expandCloseSequence (text) {
81
+ let result = ''
82
+ for (let i = 0; i < text.length; i++) {
83
+ if (text[i] === '\\' && i + 1 < text.length) {
84
+ const next = text[i + 1]
85
+ if (next === 'n') {
86
+ result += '\n'
87
+ i++
88
+ } else if (next === 't') {
89
+ result += '\t'
90
+ i++
91
+ } else if (next === 'r') {
92
+ result += '\r'
93
+ i++
94
+ } else if (next === '\\') {
95
+ result += '\\'
96
+ i++
97
+ } else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
98
+ result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
99
+ i += 3
100
+ } else {
101
+ result += text[i]
102
+ }
103
+ } else {
104
+ result += text[i]
105
+ }
106
+ }
107
+ return result
108
+ }
109
+
77
110
  class Term extends Component {
78
111
  constructor (props) {
79
112
  super(props)
@@ -161,6 +194,9 @@ class Term extends Component {
161
194
 
162
195
  componentWillUnmount () {
163
196
  refs.remove(this.id)
197
+ clearTimeout(this.longPressTimer)
198
+ this.longPressTimer = null
199
+ this.touchStartPos = null
164
200
  if (window.store.activeTerminalId === this.props.tab.id) {
165
201
  window.store.activeTerminalId = ''
166
202
  }
@@ -625,6 +661,131 @@ class Term extends Component {
625
661
  }
626
662
  }
627
663
 
664
+ // ---- Mobile touch support ----
665
+ // On touch devices, long-press should (1) select the word under the finger
666
+ // and (2) open the context menu — mirroring desktop right-click behaviour.
667
+ // xterm.js only uses touch events for scrolling, so we add explicit
668
+ // long-press detection here.
669
+ longPressTimer = null
670
+ touchStartPos = null
671
+ longPressThreshold = 500 // ms
672
+ longPressMoveTolerance = 10 // px
673
+
674
+ onTouchStart = (e) => {
675
+ if (e.touches.length !== 1) {
676
+ return
677
+ }
678
+ const touch = e.touches[0]
679
+ this.touchStartPos = {
680
+ clientX: touch.clientX,
681
+ clientY: touch.clientY,
682
+ target: e.currentTarget
683
+ }
684
+ clearTimeout(this.longPressTimer)
685
+ this.longPressTimer = setTimeout(() => {
686
+ this.handleLongPress()
687
+ }, this.longPressThreshold)
688
+ }
689
+
690
+ onTouchMove = (e) => {
691
+ if (!this.touchStartPos) {
692
+ return
693
+ }
694
+ const touch = e.touches[0]
695
+ const dx = touch.clientX - this.touchStartPos.clientX
696
+ const dy = touch.clientY - this.touchStartPos.clientY
697
+ if (Math.sqrt(dx * dx + dy * dy) > this.longPressMoveTolerance) {
698
+ clearTimeout(this.longPressTimer)
699
+ this.longPressTimer = null
700
+ this.touchStartPos = null
701
+ }
702
+ }
703
+
704
+ onTouchEnd = () => {
705
+ clearTimeout(this.longPressTimer)
706
+ this.longPressTimer = null
707
+ this.touchStartPos = null
708
+ }
709
+
710
+ handleLongPress = () => {
711
+ if (!this.touchStartPos || this.state.loading) {
712
+ return
713
+ }
714
+ const { clientX, clientY, target } = this.touchStartPos
715
+
716
+ // Select the word at the touch position (same as desktop right-click word
717
+ // select) so the user can immediately copy or act on it.
718
+ this.selectWordAt(clientX, clientY)
719
+
720
+ // Respect pasteWhenContextMenu: when enabled, long-press pastes instead
721
+ // of showing the menu (same as desktop right-click).
722
+ if (this.props.config.pasteWhenContextMenu) {
723
+ this.onPaste()
724
+ return
725
+ }
726
+
727
+ // Dispatch a synthetic contextmenu event so antd Dropdown's contextMenu
728
+ // trigger opens the menu at the exact finger position.
729
+ const event = new MouseEvent('contextmenu', {
730
+ bubbles: true,
731
+ cancelable: true,
732
+ clientX,
733
+ clientY
734
+ })
735
+ target.dispatchEvent(event)
736
+ }
737
+
738
+ selectWordAt = (clientX, clientY) => {
739
+ if (!this.term) {
740
+ return
741
+ }
742
+ const termElement = this.term.element
743
+ if (!termElement) {
744
+ return
745
+ }
746
+ const rect = termElement.getBoundingClientRect()
747
+ const x = clientX - rect.left
748
+ const y = clientY - rect.top
749
+ if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
750
+ return
751
+ }
752
+
753
+ const cellWidth = rect.width / this.term.cols
754
+ const cellHeight = rect.height / this.term.rows
755
+ const col = Math.floor(x / cellWidth)
756
+ const row = Math.floor(y / cellHeight)
757
+
758
+ const buffer = this.term.buffer.active
759
+ const line = buffer.getLine(row)
760
+ if (!line) {
761
+ return
762
+ }
763
+
764
+ const text = line.translateToString(true)
765
+ const wordSeparator = this.props.config.terminalWordSeparator ||
766
+ ' ./\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?'
767
+
768
+ // If the touched cell is empty or a separator, nothing to select
769
+ if (col >= text.length || wordSeparator.includes(text[col])) {
770
+ return
771
+ }
772
+
773
+ // Find word start
774
+ let start = col
775
+ while (start > 0 && !wordSeparator.includes(text[start - 1])) {
776
+ start--
777
+ }
778
+ // Find word end
779
+ let end = col
780
+ while (end < text.length && !wordSeparator.includes(text[end])) {
781
+ end++
782
+ }
783
+
784
+ if (end > start) {
785
+ this.term.select(start, row, end - start)
786
+ }
787
+ }
788
+
628
789
  onContextMenuInner = e => {
629
790
  e.preventDefault()
630
791
  if (this.state.loading) {
@@ -1856,6 +2017,33 @@ class Term extends Component {
1856
2017
  this.props.delTab(id)
1857
2018
  }
1858
2019
 
2020
+ /**
2021
+ * Manually triggered from the "exit gracefully" control in
2022
+ * session-control.jsx (serial tabs only). Writes the configured key
2023
+ * sequence (default \x01ky = Ctrl+A, k, y to kill a GNU screen window) to
2024
+ * the socket, waits a bit so the remote end (e.g. a Bluetooth console
2025
+ * adapter) can release cleanly, then closes the tab.
2026
+ */
2027
+ exitGracefully = async () => {
2028
+ const { tab } = this.props
2029
+ if (tab.type !== terminalSerialType) {
2030
+ return
2031
+ }
2032
+ if (!this.onClose && this.attachAddon?._sendData) {
2033
+ try {
2034
+ const data = expandCloseSequence(tab.closeSequence || '\\x01ky')
2035
+ if (data) {
2036
+ this.attachAddon._sendData(data)
2037
+ }
2038
+ } catch (err) {
2039
+ console.error('send close sequence failed', err)
2040
+ }
2041
+ const delay = Number(tab.closeSequenceDelay)
2042
+ await new Promise(resolve => setTimeout(resolve, Number.isFinite(delay) && delay >= 0 ? delay : 500))
2043
+ }
2044
+ this.props.delTab(tab.id)
2045
+ }
2046
+
1859
2047
  handleShowInfo = () => {
1860
2048
  const { logName, tab } = this.props
1861
2049
  const infoProps = {
@@ -1926,7 +2114,10 @@ class Term extends Component {
1926
2114
  zIndex: 10
1927
2115
  },
1928
2116
  onDrop: this.onDrop,
1929
- onContextMenu: this.onContextMenuInner
2117
+ onContextMenu: this.onContextMenuInner,
2118
+ onTouchStart: this.onTouchStart,
2119
+ onTouchMove: this.onTouchMove,
2120
+ onTouchEnd: this.onTouchEnd
1930
2121
  }
1931
2122
  // const fileProps = {
1932
2123
  // type: 'file',
@@ -28,8 +28,6 @@
28
28
  .horizontal
29
29
  .term-wrap.not-first-term
30
30
  border-left 1px solid var(--main-lighter)
31
- .term-sftp-tabs
32
- margin-left 5px
33
31
 
34
32
  #container
35
33
  .xterm
@@ -206,7 +206,7 @@ export default (props) => {
206
206
  },
207
207
  {
208
208
  name: 'disks',
209
- cmd: 'df -h',
209
+ cmd: 'df -hP',
210
210
  interval: 10000,
211
211
  delay: 1600,
212
212
  formatter: formatDisks
@@ -14,6 +14,7 @@ import {
14
14
  } from '../../common/constants'
15
15
  import { getItemJSON, setItemJSON } from '../../common/safe-local-storage'
16
16
  import findParentBySel from '../../common/find-parent'
17
+ import { isDropAfterHalf, setDropIndicator, clearDropIndicator } from '../../common/drop-position'
17
18
  import copy from 'json-deep-copy'
18
19
  import NewButtonsGroup from './bookmark-toolbar'
19
20
  import findBookmarkGroupId from '../../common/find-bookmark-group-id'
@@ -479,8 +480,10 @@ export default class ItemListTree extends Component {
479
480
  )
480
481
  }
481
482
 
482
- onDragEnter = e => {
483
- e.preventDefault()
483
+ // resolve the .tree-item under the pointer and show a directional
484
+ // drop indicator (top edge = before, bottom edge = after).
485
+ // NOTE: preventDefault on dragover is required for onDrop to fire.
486
+ updateDropIndicator = e => {
484
487
  let {
485
488
  target
486
489
  } = e
@@ -488,7 +491,12 @@ export default class ItemListTree extends Component {
488
491
  if (tar) {
489
492
  target = tar
490
493
  }
491
- target.classList.add('item-dragover-top')
494
+ setDropIndicator(target, isDropAfterHalf(e, target))
495
+ }
496
+
497
+ onDragEnter = e => {
498
+ e.preventDefault()
499
+ this.updateDropIndicator(e)
492
500
  }
493
501
 
494
502
  onDragLeave = e => {
@@ -500,25 +508,21 @@ export default class ItemListTree extends Component {
500
508
  if (tar) {
501
509
  target = tar
502
510
  }
503
- target.classList.remove('item-dragover-top')
511
+ clearDropIndicator(target)
504
512
  }
505
513
 
506
514
  onDragOver = e => {
507
- let {
508
- target
509
- } = e
510
- const tar = findParentBySel(target, '.tree-item')
511
- if (tar) {
512
- target = tar
513
- }
514
- target.classList.add('item-dragover-top')
515
+ e.preventDefault()
516
+ this.updateDropIndicator(e)
515
517
  }
516
518
 
517
519
  onDrop = action(e => {
518
520
  e.preventDefault()
519
- const elems = document.querySelectorAll('.tree-item.item-dragover-top')
521
+ const elems = document.querySelectorAll(
522
+ '.tree-item.dnd-before, .tree-item.dnd-after'
523
+ )
520
524
  elems.forEach(elem => {
521
- elem.classList.remove('item-dragover-top')
525
+ clearDropIndicator(elem)
522
526
  })
523
527
  let {
524
528
  target
@@ -537,6 +541,9 @@ export default class ItemListTree extends Component {
537
541
  const pidDrops = target.getAttribute('data-parent-id') || ''
538
542
  const pidDropsArr = pidDrops.split('#')
539
543
  const pidDrop = pidDropsArr[pidDropsArr.length - 1]
544
+ // bottom half of the target row => insert after it, so the last
545
+ // item/group in a list can receive a drop (append to the end).
546
+ const insertAfter = isDropAfterHalf(e, target)
540
547
  // can not drag item to its own children
541
548
  if (
542
549
  (idDragged === 'default' &&
@@ -566,8 +573,9 @@ export default class ItemListTree extends Component {
566
573
  if (indexDrop < 0) {
567
574
  return
568
575
  }
576
+ const insertIndex = insertAfter ? indexDrop + 1 : indexDrop
569
577
  bookmarkGroups.splice(
570
- indexDrop,
578
+ insertIndex,
571
579
  0,
572
580
  dragItem
573
581
  )
@@ -580,9 +588,13 @@ export default class ItemListTree extends Component {
580
588
  )
581
589
  : false
582
590
  if (parentDrag) {
583
- parentDrag.bookmarkGroupIds = (parentDrag.bookmarkGroupIds || []).filter(
584
- id => id !== idDragged
585
- )
591
+ // remove from old parent IN PLACE: manate only reacts to in-place
592
+ // array mutations (the delete inside splice), not reassignment.
593
+ const fromArr = parentDrag.bookmarkGroupIds || []
594
+ const fromIdx = fromArr.indexOf(idDragged)
595
+ if (fromIdx >= 0) {
596
+ fromArr.splice(fromIdx, 1)
597
+ }
586
598
  }
587
599
  const parentDrop = pidDrop
588
600
  ? bookmarkGroups.find(
@@ -594,20 +606,18 @@ export default class ItemListTree extends Component {
594
606
  if (!parentDrop) {
595
607
  return
596
608
  }
609
+ const dropArr = parentDrop.bookmarkGroupIds || []
597
610
  if (!pidDrop) {
598
- parentDrop.bookmarkGroupIds = uniq(
599
- [
600
- ...(parentDrop.bookmarkGroupIds || []),
601
- idDragged
602
- ]
603
- )
611
+ if (!dropArr.includes(idDragged)) {
612
+ dropArr.push(idDragged)
613
+ }
604
614
  } else {
605
- const arr = parentDrop.bookmarkGroupIds || []
606
- let index = arr.findIndex(item => item === idDrop)
615
+ let index = dropArr.findIndex(item => item === idDrop)
607
616
  if (index < 0) {
608
617
  index = 0
609
618
  }
610
- arr.splice(index, 0, idDragged)
619
+ const insertIndex = insertAfter ? index + 1 : index
620
+ dropArr.splice(insertIndex, 0, idDragged)
611
621
  }
612
622
  } else {
613
623
  const parentDrag = bookmarkGroups.find(
@@ -616,9 +626,13 @@ export default class ItemListTree extends Component {
616
626
  if (!parentDrag) {
617
627
  return
618
628
  }
619
- parentDrag.bookmarkIds = (parentDrag.bookmarkIds || []).filter(
620
- id => id !== idDragged
621
- )
629
+ // remove from old parent IN PLACE: manate only reacts to in-place
630
+ // array mutations (the delete inside splice), not reassignment.
631
+ const fromArr = parentDrag.bookmarkIds || []
632
+ const fromIdx = fromArr.indexOf(idDragged)
633
+ if (fromIdx >= 0) {
634
+ fromArr.splice(fromIdx, 1)
635
+ }
622
636
  const parentDrop = isGroupDrop
623
637
  ? bookmarkGroups.find(
624
638
  item => item.id === idDrop
@@ -629,21 +643,25 @@ export default class ItemListTree extends Component {
629
643
  if (!parentDrop) {
630
644
  return
631
645
  }
646
+ const dropArr = parentDrop.bookmarkIds || []
632
647
  if (isGroupDrop) {
633
- parentDrop.bookmarkIds = uniq(
634
- [
635
- ...(parentDrop.bookmarkIds || []),
636
- idDragged
637
- ]
638
- )
648
+ if (!dropArr.includes(idDragged)) {
649
+ dropArr.push(idDragged)
650
+ }
639
651
  } else {
640
- const arr = parentDrop.bookmarkIds || []
641
- let index = arr.findIndex(item => item === idDrop)
652
+ let index = dropArr.findIndex(item => item === idDrop)
642
653
  if (index < 0) {
643
654
  index = 0
644
655
  }
645
- arr.splice(index, 0, idDragged)
656
+ const insertIndex = insertAfter ? index + 1 : index
657
+ dropArr.splice(insertIndex, 0, idDragged)
646
658
  }
659
+ // a manual bookmark reorder overrides any active sort, otherwise
660
+ // buildVisibleTreeRows would re-sort bookmarks and hide this order.
661
+ setItemJSON(treeSortLsKey, null)
662
+ this.setState({
663
+ sort: null
664
+ })
647
665
  }
648
666
  if (
649
667
  isGroupDrag &&
@@ -42,6 +42,20 @@ a
42
42
  .cap
43
43
  text-transform capitalize
44
44
 
45
+ // shared drag-and-drop reorder indicators (vertical lists)
46
+ // first half of the target => insert before => top edge
47
+ // second half of the target => insert after => bottom edge
48
+ // (tabs use their own horizontal left/right line indicators)
49
+ .dnd-before
50
+ box-shadow inset 0 2px 0 0 var(--primary)
51
+ .dnd-after
52
+ box-shadow inset 0 -2px 0 0 var(--primary)
53
+ // antd table rows: box-shadow on <tr> is not rendered, apply on cells
54
+ tr.dnd-before > td
55
+ box-shadow inset 0 2px 0 0 var(--primary)
56
+ tr.dnd-after > td
57
+ box-shadow inset 0 -2px 0 0 var(--primary)
58
+
45
59
  .sync-data-compare
46
60
  .sync-suggest-action
47
61
  color var(--success)
@@ -1,7 +1,3 @@
1
- // Session pane tab label: show full name by default, short label on mobile
2
- .type-tab-short
3
- display none
4
-
5
1
  // ---------------------------------------------------------------------------
6
2
  // Mobile UI driven by store.isMobile (is-mobile class on root)
7
3
  // Breakpoint: 600px (see mobileBreakpoint in constants.js)
@@ -33,6 +29,24 @@
33
29
  // close icon always visible on mobile
34
30
  .tab-close
35
31
  display block
32
+ // keep a left drag grip for the custom title bar (not webapp, not system-ui),
33
+ // so the empty area left of the tab is draggable. mac needs extra room for
34
+ // the traffic-light window controls in the top-left.
35
+ &.not-system-ui.not-webapp
36
+ .mobile-tabs-inner
37
+ margin-left 20px !important
38
+ &.is-mac
39
+ .mobile-tabs-inner
40
+ margin-left 72px !important
41
+ // AppDrag wraps .mobile-tabs-inner under a custom title bar; make it the flex
42
+ // item so the tab layout is unchanged and the inner's left drag grip stays
43
+ // inside the draggable .app-drag box.
44
+ .tabs.mobile-tabs > .app-drag
45
+ flex 1
46
+ min-width 0
47
+ display flex
48
+ align-items center
49
+ overflow hidden
36
50
  .mobile-tabs-extra
37
51
  flex-shrink 0
38
52
  display flex
@@ -147,11 +161,19 @@
147
161
  .upgrade-panel
148
162
  max-width calc(100vw - 20px)
149
163
 
150
- // ---- 9. Session pane tab label: hide full name, show short label
151
- .type-tab-full
152
- display none
153
- .type-tab-short
154
- display inline
164
+ // ---- 9. Terminal touch support: prevent the browser's native long-press
165
+ // callout (iOS "Copy/Look Up" menu) and text-selection gesture from
166
+ // interfering with xterm's own touch handling and our custom
167
+ // long-press context-menu detection. xterm.js already sets
168
+ // user-select:none on .xterm; we additionally suppress the iOS
169
+ // callout and set touch-action so the browser does not compete with
170
+ // xterm for touch gesture interpretation.
171
+ .term-wrap
172
+ -webkit-touch-callout none
173
+ -webkit-tap-highlight-color transparent
174
+ .xterm
175
+ -webkit-touch-callout none
176
+ touch-action none
155
177
 
156
178
  // ---------------------------------------------------------------------------
157
179
  // Touch-primary devices only (feature-detected via pointer media query, not
@@ -82,18 +82,22 @@ export default Store => {
82
82
  window.store[type] = arr
83
83
  }
84
84
 
85
- Store.prototype.adjustOrder = function (type, fromId, toId) {
85
+ Store.prototype.adjustOrder = function (type, fromId, toId, insertAfter = false) {
86
86
  const { store } = window
87
87
  const items = store.getItems(type)
88
88
  const fromIndex = items.findIndex(t => t.id === fromId)
89
- let toIndex = items.findIndex(t => t.id === toId)
89
+ const toIndex = items.findIndex(t => t.id === toId)
90
90
  if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
91
91
  return
92
92
  }
93
- if (fromIndex < toIndex) {
94
- toIndex = toIndex - 1
95
- }
93
+ // insertAfter lets the item land after the target, so the last
94
+ // item can receive a drop (insertIndex can reach items.length).
95
+ let insertIndex = insertAfter ? toIndex + 1 : toIndex
96
96
  const [removed] = items.splice(fromIndex, 1)
97
- items.splice(toIndex, 0, removed)
97
+ // account for the index shift caused by the removal above
98
+ if (fromIndex < insertIndex) {
99
+ insertIndex = insertIndex - 1
100
+ }
101
+ items.splice(insertIndex, 0, removed)
98
102
  }
99
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.175",
3
+ "version": "3.15.186",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",