@electerm/electerm-react 5.5.6 → 5.5.9

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.
@@ -95,6 +95,7 @@ export default {
95
95
  switchTabOnHover: false,
96
96
  disableTabIndex: false,
97
97
  disableShortcutBar: false,
98
+ disableConfirmForLargeClipboardContent: false,
98
99
  leftSideBarIcons: [
99
100
  'newBookmark',
100
101
  'quickConnect',
@@ -3,13 +3,24 @@
3
3
  */
4
4
 
5
5
  import { useState, useEffect } from 'react'
6
- import { Button, Empty, Popover } from 'antd'
6
+ import { Button, Empty, Popover, Dropdown } from 'antd'
7
7
  import { auto } from 'manate/react'
8
8
  import SwitchLabel from '../common/switch'
9
9
  import { copy } from '../../common/clipboard'
10
- import { HistoryOutlined, DeleteOutlined, CopyOutlined, UnorderedListOutlined } from '@ant-design/icons'
10
+ import {
11
+ HistoryOutlined,
12
+ DeleteOutlined,
13
+ CopyOutlined,
14
+ UnorderedListOutlined,
15
+ MoreOutlined,
16
+ PlusOutlined,
17
+ CodeOutlined
18
+ } from '@ant-design/icons'
11
19
  import InputAutoFocus from '../common/input-auto-focus'
12
20
  import { getItemJSON, setItemJSON } from '../../common/safe-local-storage'
21
+ import QuickCommandCreateModal from '../quick-commands/quick-command-create-modal'
22
+ import MultiTabRunModal from './multi-tab-run-modal'
23
+ import classNames from 'classnames'
13
24
  import './cmd-history.styl'
14
25
 
15
26
  const e = window.translate
@@ -20,6 +31,10 @@ export default auto(function CmdHistory (props) {
20
31
  const [sortByFrequency, setSortByFrequency] = useState(() => {
21
32
  return getItemJSON(SORT_BY_FREQ_KEY, false)
22
33
  })
34
+ // cmd of the item whose action menu is open / being acted on
35
+ const [menuOpenCmd, setMenuOpenCmd] = useState('')
36
+ const [quickCommandCmd, setQuickCommandCmd] = useState('')
37
+ const [multiTabCmd, setMultiTabCmd] = useState('')
23
38
  const { terminalCommandHistory } = props.store
24
39
 
25
40
  useEffect(() => {
@@ -30,11 +45,6 @@ export default auto(function CmdHistory (props) {
30
45
  window.store.runCmdFromHistory(cmd)
31
46
  }
32
47
 
33
- function handleDeleteCommand (cmd, ev) {
34
- ev.stopPropagation()
35
- window.store.deleteCmdHistory(cmd)
36
- }
37
-
38
48
  function handleCopyCommand (cmd, ev) {
39
49
  ev.stopPropagation()
40
50
  copy(cmd)
@@ -44,6 +54,46 @@ export default auto(function CmdHistory (props) {
44
54
  window.store.clearAllCmdHistory()
45
55
  }
46
56
 
57
+ function handleMenuAction (key, cmd) {
58
+ if (key === 'delete') {
59
+ window.store.deleteCmdHistory(cmd)
60
+ } else if (key === 'quickCommand') {
61
+ setQuickCommandCmd(cmd)
62
+ } else if (key === 'multiTab') {
63
+ window.store.filterBatchInputSelectedTabIds()
64
+ setMultiTabCmd(cmd)
65
+ }
66
+ }
67
+
68
+ function getMenuProps (cmd) {
69
+ return {
70
+ items: [
71
+ {
72
+ key: 'delete',
73
+ icon: <DeleteOutlined />,
74
+ label: e('del'),
75
+ danger: true
76
+ },
77
+ {
78
+ key: 'quickCommand',
79
+ icon: <PlusOutlined />,
80
+ label: e('addQuickCommands')
81
+ },
82
+ {
83
+ key: 'multiTab',
84
+ icon: <CodeOutlined />,
85
+ label: e('runInAllTerminals')
86
+ }
87
+ ],
88
+ onClick: ({ key, domEvent }) => {
89
+ // the menu renders in a portal but React events still bubble through
90
+ // the row, which would run the command
91
+ domEvent.stopPropagation()
92
+ handleMenuAction(key, cmd)
93
+ }
94
+ }
95
+ }
96
+
47
97
  function filterArray (array, keyword) {
48
98
  if (!keyword) {
49
99
  return array
@@ -76,34 +126,46 @@ export default auto(function CmdHistory (props) {
76
126
  />
77
127
  )
78
128
  }
79
- return filtered.map((item, index) => (
80
- <div
81
- key={index}
82
- className='cmd-history-item'
83
- onClick={() => handleRunCommand(item.cmd)}
84
- >
85
- <span className='cmd-history-item-text' title={item.cmd}>{item.cmd}</span>
86
- <div className='cmd-history-item-actions'>
87
- <span className='cmd-history-item-count' title={e('count') + ': ' + item.count}>
88
- {item.count}
89
- </span>
90
- <Button
91
- type='text'
92
- size='small'
93
- icon={<CopyOutlined />}
94
- className='cmd-history-item-copy'
95
- onClick={(ev) => handleCopyCommand(item.cmd, ev)}
96
- />
97
- <Button
98
- type='text'
99
- size='small'
100
- icon={<DeleteOutlined />}
101
- className='cmd-history-item-delete'
102
- onClick={(ev) => handleDeleteCommand(item.cmd, ev)}
103
- />
129
+ return filtered.map((item, index) => {
130
+ const cls = classNames(
131
+ 'cmd-history-item',
132
+ { 'menu-open': menuOpenCmd === item.cmd }
133
+ )
134
+ return (
135
+ <div
136
+ key={index}
137
+ className={cls}
138
+ onClick={() => handleRunCommand(item.cmd)}
139
+ >
140
+ <span className='cmd-history-item-text' title={item.cmd}>{item.cmd}</span>
141
+ <div className='cmd-history-item-actions'>
142
+ <span className='cmd-history-item-count' title={e('count') + ': ' + item.count}>
143
+ {item.count}
144
+ </span>
145
+ <Button
146
+ type='text'
147
+ size='small'
148
+ icon={<CopyOutlined />}
149
+ className='cmd-history-item-copy'
150
+ onClick={(ev) => handleCopyCommand(item.cmd, ev)}
151
+ />
152
+ <Dropdown
153
+ menu={getMenuProps(item.cmd)}
154
+ trigger={['click']}
155
+ onOpenChange={(open) => setMenuOpenCmd(open ? item.cmd : '')}
156
+ >
157
+ <Button
158
+ type='text'
159
+ size='small'
160
+ icon={<MoreOutlined />}
161
+ className='cmd-history-item-more'
162
+ onClick={(ev) => ev.stopPropagation()}
163
+ />
164
+ </Dropdown>
165
+ </div>
104
166
  </div>
105
- </div>
106
- ))
167
+ )
168
+ })
107
169
  }
108
170
 
109
171
  function renderHeader () {
@@ -148,16 +210,41 @@ export default auto(function CmdHistory (props) {
148
210
  )
149
211
 
150
212
  return (
151
- <Popover
152
- content={content}
153
- trigger='click'
154
- placement='topLeft'
155
- >
156
- <Button
157
- size='small'
158
- type='text'
159
- icon={<HistoryOutlined />}
160
- />
161
- </Popover>
213
+ <>
214
+ <Popover
215
+ content={content}
216
+ trigger='click'
217
+ placement='topLeft'
218
+ >
219
+ <Button
220
+ size='small'
221
+ type='text'
222
+ icon={<HistoryOutlined />}
223
+ />
224
+ </Popover>
225
+ {
226
+ quickCommandCmd
227
+ ? (
228
+ <QuickCommandCreateModal
229
+ key={quickCommandCmd}
230
+ store={props.store}
231
+ command={quickCommandCmd}
232
+ onClose={() => setQuickCommandCmd('')}
233
+ />
234
+ )
235
+ : null
236
+ }
237
+ {
238
+ multiTabCmd
239
+ ? (
240
+ <MultiTabRunModal
241
+ store={props.store}
242
+ cmd={multiTabCmd}
243
+ onClose={() => setMultiTabCmd('')}
244
+ />
245
+ )
246
+ : null
247
+ }
248
+ </>
162
249
  )
163
250
  })
@@ -4,7 +4,6 @@
4
4
  overflow hidden
5
5
  display flex
6
6
  flex-direction column
7
-
8
7
  .cmd-history-search
9
8
  flex-shrink 0
10
9
  position sticky
@@ -46,6 +45,13 @@
46
45
  cursor pointer
47
46
  &:hover
48
47
  background var(--main-lighter)
48
+ // keep the row actions visible while its action menu is open, otherwise the
49
+ // menu trigger would be hidden (display none) and the popup would lose its
50
+ // anchor when the mouse moves onto the menu
51
+ &.menu-open
52
+ background var(--main-lighter)
53
+ .cmd-history-item-actions
54
+ display flex
49
55
 
50
56
  .cmd-history-item-text
51
57
  overflow hidden
@@ -68,14 +74,46 @@
68
74
  .cmd-history-item:hover &
69
75
  display flex
70
76
 
71
- .cmd-history-item-delete
77
+ .cmd-history-item-more
72
78
  cursor pointer
73
79
  padding 2px 4px
74
- &:hover
75
- color red
76
80
 
77
81
  .cmd-history-item-copy
78
82
  cursor pointer
79
83
  padding 2px 4px
80
84
  &:hover
81
85
  color blue
86
+
87
+ .multi-tab-run-cmd
88
+ font-size 12px
89
+ padding 6px 8px
90
+ margin-bottom 10px
91
+ border-radius 4px
92
+ background var(--main-lighter)
93
+ word-break break-all
94
+ max-height 96px
95
+ overflow-y auto
96
+
97
+ // Narrow screens: the popup is aligned to the history trigger (placement
98
+ // topLeft) and is 324px wide, while the footer trigger sits within the first
99
+ // ~96px of the screen (43px icon bar + footer margin + the icons before it), so
100
+ // below ~420px the popup's right edge would fall outside the window. antd's
101
+ // auto-shift does not correct it, so pin the popup to the viewport instead.
102
+ // Matched by its content: the popup lives in a body-level portal, so neither
103
+ // the component tree nor an antd popup class reaches it reliably.
104
+ @media (max-width: 420px)
105
+ .ant-popover:has(.cmd-history-popover-content)
106
+ // rc-trigger writes `inset: auto auto 40px <trigger left>px` inline
107
+ left 0px !important
108
+ // pinned to the viewport, the arrow no longer points at the trigger
109
+ .ant-popover-arrow
110
+ display none
111
+ .cmd-history-popover-content
112
+ // popup = content + 24px container padding; keep 8px on both sides
113
+ width 100%
114
+
115
+ // Touch devices have no hover, so the row actions (count / copy / ⋯ menu)
116
+ // would never be reachable — feature-detect instead of sniffing the UA
117
+ @media (pointer: coarse)
118
+ .cmd-history-item-actions
119
+ display flex
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Run one command (usually picked from the command history) in several
3
+ * terminals at once.
4
+ *
5
+ * Reuses the footer batch input tab panel (`TabSelectList`) for picking the
6
+ * target terminals, and the same selection state + `batchInput` terminal API
7
+ * the footer batch input uses, so both entry points stay consistent.
8
+ */
9
+
10
+ import { auto } from 'manate/react'
11
+ import {
12
+ Button,
13
+ Modal
14
+ } from 'antd'
15
+ import { TabSelectList } from './tab-select'
16
+ import { refs } from '../common/ref'
17
+ import {
18
+ terminalWebType,
19
+ terminalRdpType,
20
+ terminalVncType
21
+ } from '../../common/constants'
22
+
23
+ const e = window.translate
24
+
25
+ export default auto(function MultiTabRunModal (props) {
26
+ const { store, cmd, onClose } = props
27
+ const selectedTabIds = store.batchInputSelectedTabIds
28
+ const tabs = store.tabs.filter(tab => {
29
+ return tab.type !== terminalWebType &&
30
+ tab.type !== terminalRdpType &&
31
+ tab.type !== terminalVncType
32
+ }).sort((a, b) => {
33
+ // current tab goes first
34
+ if (a.id === store.activeTabId) return -1
35
+ if (b.id === store.activeTabId) return 1
36
+ return 0
37
+ })
38
+ const listProps = {
39
+ tabs,
40
+ activeTabId: store.activeTabId,
41
+ selectedTabIds,
42
+ onSelect: store.onSelectBatchInputSelectedTabId,
43
+ onSelectAll: store.selectAllBatchInputTabs,
44
+ onSelectNone: store.selectNoneBatchInputTabs
45
+ }
46
+ function handleRun () {
47
+ selectedTabIds.map(id => {
48
+ return refs.get('term-' + id)
49
+ }).forEach(term => {
50
+ term?.batchInput(cmd)
51
+ })
52
+ store.addCmdHistory(cmd)
53
+ onClose()
54
+ }
55
+ return (
56
+ <Modal
57
+ open
58
+ title={e('runInAllTerminals')}
59
+ onCancel={onClose}
60
+ destroyOnHidden
61
+ width={520}
62
+ footer={[
63
+ <Button
64
+ key='cancel'
65
+ onClick={onClose}
66
+ >
67
+ {e('cancel')}
68
+ </Button>,
69
+ <Button
70
+ key='run'
71
+ type='primary'
72
+ disabled={!selectedTabIds.length}
73
+ onClick={handleRun}
74
+ >
75
+ {e('ok')}
76
+ </Button>
77
+ ]}
78
+ >
79
+ <div className='multi-tab-run-cmd'>{cmd}</div>
80
+ <TabSelectList {...listProps} />
81
+ </Modal>
82
+ )
83
+ })
@@ -6,15 +6,28 @@ import {
6
6
  CodeOutlined
7
7
  } from '@ant-design/icons'
8
8
 
9
- export default function TabSelect (props) {
10
- const { selectedTabIds, tabs, activeTabId } = props
9
+ /**
10
+ * The multi terminal selection panel: select-all/none plus one toggle button
11
+ * per terminal tab.
12
+ * Extracted so it can be reused outside the batch input popover (e.g. run a
13
+ * command from the history panel in several terminals at once).
14
+ */
15
+ export function TabSelectList (props) {
16
+ const {
17
+ selectedTabIds = [],
18
+ tabs = [],
19
+ activeTabId,
20
+ onSelect,
21
+ onSelectAll,
22
+ onSelectNone
23
+ } = props
11
24
  function renderTabs () {
12
25
  return tabs.map(tab => {
13
26
  const selected = selectedTabIds.includes(tab.id)
14
27
  const itemProps = {
15
28
  tab,
16
29
  selected,
17
- onSelect: window.store.onSelectBatchInputSelectedTabId,
30
+ onSelect,
18
31
  id: tab.id,
19
32
  isCurrent: tab.id === activeTabId
20
33
  }
@@ -26,12 +39,6 @@ export default function TabSelect (props) {
26
39
  )
27
40
  })
28
41
  }
29
- function onSelectAll () {
30
- window.store.selectAllBatchInputTabs()
31
- }
32
- function onSelectNone () {
33
- window.store.selectNoneBatchInputTabs()
34
- }
35
42
  function renderBtns () {
36
43
  return (
37
44
  <div className='pd1t pd2b font12'>
@@ -50,17 +57,27 @@ export default function TabSelect (props) {
50
57
  </div>
51
58
  )
52
59
  }
53
- function renderContent () {
54
- return (
55
- <div className='pd1x alignright'>
56
- {renderBtns()}
57
- {renderTabs()}
58
- </div>
59
- )
60
+ return (
61
+ <div className='pd1x alignright'>
62
+ {renderBtns()}
63
+ {renderTabs()}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export default function TabSelect (props) {
69
+ const { selectedTabIds, tabs, activeTabId } = props
70
+ const listProps = {
71
+ selectedTabIds,
72
+ tabs,
73
+ activeTabId,
74
+ onSelect: window.store.onSelectBatchInputSelectedTabId,
75
+ onSelectAll: window.store.selectAllBatchInputTabs,
76
+ onSelectNone: window.store.selectNoneBatchInputTabs
60
77
  }
61
78
  return (
62
79
  <Popover
63
- content={renderContent()}
80
+ content={<TabSelectList {...listProps} />}
64
81
  trigger='click'
65
82
  >
66
83
  <span className='pointer iblock pd1x'>
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Create a quick command from outside the settings panel (e.g. from the
3
+ * command history action menu), reusing the very same form the settings panel
4
+ * uses — no fork of the form, so fields/validation stay in sync.
5
+ *
6
+ * The form is heavy (form, shortcuts editor, templates), and it otherwise only
7
+ * lives inside the lazily loaded quick commands setting tab, so lazy load it
8
+ * here as well.
9
+ */
10
+
11
+ import { lazy, Suspense } from 'react'
12
+ import {
13
+ Modal,
14
+ Spin
15
+ } from 'antd'
16
+ import LazyBoundary from '../common/lazy-boundary'
17
+ import generate from '../../common/uid'
18
+
19
+ const QuickCommandForm = lazy(() => import('./quick-commands-form'))
20
+
21
+ const e = window.translate
22
+
23
+ export default function QuickCommandCreateModal (props) {
24
+ const { store, command, onClose } = props
25
+ const formData = {
26
+ id: '',
27
+ name: e('newQuickCommand'),
28
+ commands: [{
29
+ command,
30
+ id: generate(),
31
+ delay: 100
32
+ }]
33
+ }
34
+ return (
35
+ <Modal
36
+ open
37
+ title={e('addQuickCommands')}
38
+ onCancel={onClose}
39
+ destroyOnHidden
40
+ footer={null}
41
+ width={560}
42
+ >
43
+ <LazyBoundary>
44
+ <Suspense
45
+ fallback={(
46
+ <div className='pd3 aligncenter'>
47
+ <Spin />
48
+ </div>
49
+ )}
50
+ >
51
+ <QuickCommandForm
52
+ store={store}
53
+ formData={formData}
54
+ onSaved={onClose}
55
+ />
56
+ </Suspense>
57
+ </LazyBoundary>
58
+ </Modal>
59
+ )
60
+ }
@@ -73,12 +73,18 @@ export default function QuickCommandForm (props) {
73
73
  store.editQuickCommand(formData.id, update)
74
74
  } else {
75
75
  store.addQuickCommand(update1)
76
- store.setSettingItem({
77
- id: '',
78
- name: e('newQuickCommand')
79
- })
76
+ // resetting the settings panel form only makes sense there; when the
77
+ // form is hosted elsewhere (e.g. the command history modal) the caller
78
+ // decides what happens next
79
+ if (!props.onSaved) {
80
+ store.setSettingItem({
81
+ id: '',
82
+ name: e('newQuickCommand')
83
+ })
84
+ }
80
85
  }
81
86
  message.success(e('saved'))
87
+ props.onSaved?.()
82
88
  }
83
89
  const initialValues = formData
84
90
  if (!initialValues.labels) {
@@ -571,6 +571,7 @@ export default class SettingTerminal extends Component {
571
571
  'rightClickSelectsWord',
572
572
  'pasteWhenContextMenu',
573
573
  'copyWhenSelect',
574
+ 'disableConfirmForLargeClipboardContent',
574
575
  'ctrlOrMetaOpenTerminalLink',
575
576
  'sftpPathFollowSsh',
576
577
  'sshSftpSplitView',
@@ -104,8 +104,12 @@ export default class Sftp extends Component {
104
104
  refs.remove(this.id)
105
105
  this.sftp && this.sftp.destroy()
106
106
  this.sftp = null
107
+ clearTimeout(this.timer)
108
+ this.timer = null
107
109
  clearTimeout(this.timer4)
108
110
  this.timer4 = null
111
+ clearTimeout(this.retryHandler)
112
+ this.retryHandler = null
109
113
  // Clear sort cache to prevent memory leaks
110
114
  this._sortCache?.clear()
111
115
  this._lastSortArgs = null
@@ -19,7 +19,7 @@ export default function SidePanel (props) {
19
19
  }
20
20
  }, [props.leftSidePanelWidth, props.leftSideBarWidth])
21
21
  const dragProps = {
22
- min: 343,
22
+ min: 200,
23
23
  max: 600,
24
24
  width: props.leftSidePanelWidth,
25
25
  onDragEnd,
@@ -84,5 +84,43 @@ export const contentMixin = {
84
84
  const ts = `[${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}] `
85
85
  return ts + text
86
86
  }).join('\n')
87
+ },
88
+
89
+ /**
90
+ * The whole buffer as clean plain text, for the touch select-text overlay.
91
+ *
92
+ * Differs from getTerminalBufferText() on purpose: that one is for log files
93
+ * and keeps xterm's fixed-width padding (`translateToString(false)`), which
94
+ * would trail a screen-full of spaces into a textarea and make every line
95
+ * look wrapped. Here each row is right-trimmed, rows xterm reports as
96
+ * `isWrapped` are glued back so a line the shell printed as one line is
97
+ * copied as one line, and the blank rows scrollback starts out with are
98
+ * dropped so the panel opens on real output.
99
+ */
100
+ getSelectableBufferText () {
101
+ const buffer = this.term.buffer.active
102
+ const len = buffer.length
103
+ const lines = []
104
+ for (let i = 0; i < len; i++) {
105
+ const line = buffer.getLine(i)
106
+ if (!line) {
107
+ continue
108
+ }
109
+ const text = line.translateToString(true)
110
+ if (line.isWrapped && lines.length) {
111
+ lines[lines.length - 1] += text
112
+ } else {
113
+ lines.push(text)
114
+ }
115
+ }
116
+ let start = 0
117
+ let end = lines.length
118
+ while (start < end && !lines[start].trim()) {
119
+ start++
120
+ }
121
+ while (end > start && !lines[end - 1].trim()) {
122
+ end--
123
+ }
124
+ return lines.slice(start, end).join('\n')
87
125
  }
88
126
  }