@electerm/electerm-react 2.16.8 → 2.17.8

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.
@@ -203,6 +203,7 @@ export const settingTerminalId = 'setting-terminal'
203
203
  export const settingShortcutsId = 'setting-shortcuts'
204
204
  export const settingAiId = 'setting-ai'
205
205
  export const settingCommonId = 'setting-common'
206
+ export const settingPasswordsId = 'setting-passwords'
206
207
  export const defaultEnvLang = 'en_US.UTF-8'
207
208
  export const fileActions = {
208
209
  cancel: 'cancel',
@@ -47,7 +47,7 @@ export default {
47
47
  autoRefreshWhenSwitchToSftp: false,
48
48
  addTimeStampToTermLog: false,
49
49
  sftpPathFollowSsh: false,
50
- keepaliveInterval: 0,
50
+ keepaliveInterval: 10000,
51
51
  backspaceMode: '^?',
52
52
  showHiddenFilesOnSftpStart: true,
53
53
  terminalInfos: [
@@ -2,7 +2,8 @@ import {
2
2
  settingSyncId,
3
3
  settingShortcutsId,
4
4
  settingTerminalId,
5
- settingAiId
5
+ settingAiId,
6
+ settingPasswordsId
6
7
  } from '../common/constants'
7
8
 
8
9
  const e = window.translate
@@ -23,5 +24,9 @@ export default () => ([
23
24
  {
24
25
  id: settingAiId,
25
26
  title: 'AI'
27
+ },
28
+ {
29
+ id: settingPasswordsId,
30
+ title: e('password')
26
31
  }
27
32
  ])
@@ -4,8 +4,8 @@ import InputConfirmCommon from './input-confirm-common'
4
4
  export default function InputConfirm (props) {
5
5
  return (
6
6
  <InputConfirmCommon
7
- {...props}
8
7
  inputComponent={Input}
8
+ {...props}
9
9
  />
10
10
  )
11
11
  }
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
- import { FrownOutlined, ReloadOutlined } from '@ant-design/icons'
2
+ import { FrownOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
3
3
  import { Button } from 'antd'
4
- import message from '../common/message'
5
4
  import {
6
5
  logoPath1,
7
6
  packInfo,
@@ -9,11 +8,20 @@ import {
9
8
  isWin
10
9
  } from '../../common/constants'
11
10
  import Link from '../common/external-link'
12
- import fs from '../../common/fs'
13
11
  import { copy } from '../../common/clipboard'
12
+ import compare from '../../common/version-compare'
14
13
 
15
14
  const e = window.translate
15
+ const version = packInfo.version
16
16
  const os = isMac ? 'mac' : isWin ? 'windows' : 'linux'
17
+ const isVersion2OrAbove = compare(version, '2.0.0') >= 0
18
+
19
+ const userDataPath = {
20
+ mac: '~/Library/Application\\ Support/electerm/users/default_user',
21
+ linux: '~/.config/electerm/users/default_user',
22
+ windows: 'C:\\Users\\your-user-name\\AppData\\Roaming\\electerm\\users\\default_user'
23
+ }
24
+
17
25
  const troubleshootContent = {
18
26
  runInCommandLine: {
19
27
  mac: '/Applications/electerm.app/Contents/MacOS/electerm',
@@ -21,14 +29,20 @@ const troubleshootContent = {
21
29
  windows: 'path\\to\\electerm.exe'
22
30
  },
23
31
  clearConfig: {
24
- mac: 'rm -rf ~/Library/Application\\ Support/electerm/users/default_user/electerm_data.db && rm -rf ~/Library/Application\\ Support/electerm/users/default_user/electerm.data.nedb',
25
- linux: 'rm -rf ~/.config/electerm/users/default_user/electerm_data.db && rm -rf ~/.config/electerm/users/default_user/electerm.data.nedb',
26
- windows: 'Delete C:\\Users\\your-user-name\\AppData\\Roaming\\electerm\\users\\default_user\\electerm_data.db && Delete C:\\Users\\your-user-name\\AppData\\Roaming\\electerm\\users\\default_user\\electerm.data.nedb'
32
+ mac: isVersion2OrAbove
33
+ ? `rm -rf ${userDataPath.mac}/electerm_data.db`
34
+ : `rm -rf ${userDataPath.mac}/electerm.data.nedb`,
35
+ linux: isVersion2OrAbove
36
+ ? `rm -rf ${userDataPath.linux}/electerm_data.db`
37
+ : `rm -rf ${userDataPath.linux}/electerm.data.nedb`,
38
+ windows: isVersion2OrAbove
39
+ ? `Delete ${userDataPath.windows}\\electerm_data.db`
40
+ : `Delete ${userDataPath.windows}\\electerm.data.nedb`
27
41
  },
28
- clearData: {
29
- mac: 'rm -rf ~/Library/Application\\ Support/electerm*',
30
- linux: 'rm -rf ~/.config/electerm',
31
- windows: 'Delete C:\\Users\\your-user-name\\AppData\\Roaming\\electerm'
42
+ backupData: {
43
+ mac: `cp -r ${userDataPath.mac} ~/Desktop/electerm_backup_${Date.now()}`,
44
+ linux: `cp -r ${userDataPath.linux} ~/Desktop/electerm_backup_${Date.now()}`,
45
+ windows: `xcopy "${userDataPath.windows}\\*" "%USERPROFILE%\\Desktop\\electerm_backup_${Date.now()}" /E /I`
32
46
  }
33
47
  }
34
48
 
@@ -53,56 +67,12 @@ export default class ErrorBoundary extends React.PureComponent {
53
67
  window.location.reload()
54
68
  }
55
69
 
56
- handleClearData = async () => {
57
- await fs.rmrf(troubleshootContent.clearData[os])
58
- .then(
59
- () => {
60
- message.success('Data cleared')
61
- }
62
- )
63
- }
64
-
65
- handleClearConfig = async () => {
66
- await fs.rmrf(troubleshootContent.clearConfig[os])
67
- .then(
68
- () => {
69
- message.success('Config cleared')
70
- }
71
- )
72
- }
73
-
74
- handleCopy = () => {
75
- copy(troubleshootContent.runInCommandLine[os])
76
- }
77
-
78
- renderButton = type => {
79
- if (type === 'clearData') {
80
- return (
81
- <Button
82
- className='mg1l'
83
- onClick={this.handleClearData}
84
- >
85
- {e('clearData')}
86
- </Button>
87
- )
88
- }
89
- if (type === 'runInCommandLine') {
90
- return (
91
- <Button
92
- className='mg1l'
93
- onClick={this.handleCopy}
94
- >
95
- {e('copy')}
96
- </Button>
97
- )
98
- }
70
+ renderIconCopy = (cmd) => {
99
71
  return (
100
- <Button
101
- className='mg1l'
102
- onClick={this.handleClearConfig}
103
- >
104
- {e('clearConfig')}
105
- </Button>
72
+ <CopyOutlined
73
+ className='mg2l pointer'
74
+ onClick={() => copy(cmd)}
75
+ />
106
76
  )
107
77
  }
108
78
 
@@ -123,7 +93,7 @@ export default class ErrorBoundary extends React.PureComponent {
123
93
  const cmd = v[os]
124
94
  return (
125
95
  <div className='pd1b' key={k}>
126
- <h3>{e(k)} {this.renderButton(k)}</h3>
96
+ <h3>{e(k)} {this.renderIconCopy(cmd)}</h3>
127
97
  <p><code>{cmd}</code></p>
128
98
  </div>
129
99
  )
@@ -132,6 +102,10 @@ export default class ErrorBoundary extends React.PureComponent {
132
102
  <div className='pd1b'>
133
103
  <Link to={bugUrl}>{e('bugReport')}</Link>
134
104
  </div>
105
+ <div className='pd1b'>
106
+ <span>Contact author: </span>
107
+ <Link to='mailto:zxdong@gmail.com'>zxdong@gmail.com</Link>
108
+ </div>
135
109
  <div className='pd3y'>
136
110
  <img
137
111
  src='https://electerm.html5beta.com/electerm-wechat-group-qr.jpg'
@@ -14,7 +14,8 @@ import {
14
14
  FullscreenOutlined,
15
15
  PaperClipOutlined,
16
16
  CloseOutlined,
17
- ApartmentOutlined
17
+ ApartmentOutlined,
18
+ HeartOutlined
18
19
  } from '@ant-design/icons'
19
20
  import {
20
21
  Tooltip,
@@ -52,7 +53,8 @@ export default class SessionWrapper extends Component {
52
53
  splitSize: [50, 50],
53
54
  sessionOptions: null,
54
55
  delKeyPressed: false,
55
- broadcastInput: false
56
+ broadcastInput: false,
57
+ keepaliveEnabled: false
56
58
  }
57
59
  props.tab.sshSftpSplitView = !!props.config.sshSftpSplitView
58
60
  }
@@ -482,6 +484,15 @@ export default class SessionWrapper extends Component {
482
484
  })
483
485
  }
484
486
 
487
+ toggleKeepalive = () => {
488
+ const term = refs.get('term-' + this.props.tab.id)
489
+ if (!term) {
490
+ return
491
+ }
492
+ const enabled = term.toggleKeepalive()
493
+ this.setState({ keepaliveEnabled: enabled })
494
+ }
495
+
485
496
  handleOpenSearch = () => {
486
497
  refs.get('term-' + this.props.tab.id)?.toggleSearch()
487
498
  }
@@ -525,6 +536,25 @@ export default class SessionWrapper extends Component {
525
536
  )
526
537
  }
527
538
 
539
+ renderKeepaliveIcon = () => {
540
+ if (this.isSshDisabled() || !this.props.tab.authType) {
541
+ return null
542
+ }
543
+ const { keepaliveEnabled } = this.state
544
+ const title = e('keepalive')
545
+ const iconProps = {
546
+ className: classnames('sess-icon pointer keepalive-icon', {
547
+ active: keepaliveEnabled
548
+ }),
549
+ onClick: this.toggleKeepalive
550
+ }
551
+ return (
552
+ <Tooltip title={title}>
553
+ <HeartOutlined {...iconProps} />
554
+ </Tooltip>
555
+ )
556
+ }
557
+
528
558
  renderBroadcastIcon = () => {
529
559
  if (
530
560
  this.isSshDisabled()
@@ -711,6 +741,7 @@ export default class SessionWrapper extends Component {
711
741
  {this.renderPaneControl()}
712
742
  {this.renderSftpPathFollowControl()}
713
743
  {this.renderSplitToggle()}
744
+ {this.renderKeepaliveIcon()}
714
745
  {this.renderBroadcastIcon()}
715
746
  {this.renderTermControls()}
716
747
  </div>
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Passwords management component
3
+ * Allows grouping bookmarks by password, changing passwords, and copying passwords
4
+ */
5
+ import React, { Component } from 'react'
6
+ import {
7
+ CopyOutlined,
8
+ EditOutlined,
9
+ KeyOutlined,
10
+ LaptopOutlined
11
+ } from '@ant-design/icons'
12
+ import {
13
+ Button,
14
+ Modal,
15
+ Space,
16
+ Table,
17
+ Tag,
18
+ Tooltip,
19
+ Typography,
20
+ Input
21
+ } from 'antd'
22
+ import Search from '../common/search'
23
+ import InputConfirm from '../common/input-confirm'
24
+ import createTitle from '../../common/create-title'
25
+ import { copy as copyToClipboard } from '../../common/clipboard'
26
+ import { settingMap } from '../../common/constants'
27
+ import './setting.styl'
28
+
29
+ const { Text: TextAnt } = Typography
30
+ const e = window.translate
31
+
32
+ export default class SettingPasswords extends Component {
33
+ state = {
34
+ passwordGroups: [],
35
+ newPassword: '',
36
+ editModalVisible: false,
37
+ selectedBookmarks: [],
38
+ search: '',
39
+ pagination: {
40
+ current: 1,
41
+ pageSize: 10
42
+ }
43
+ }
44
+
45
+ componentDidMount () {
46
+ this.groupBookmarksByPassword()
47
+ }
48
+
49
+ groupBookmarksByPassword = () => {
50
+ const { bookmarks = [] } = this.props
51
+ const passwordMap = new Map()
52
+
53
+ bookmarks.forEach(bookmark => {
54
+ const pwd = bookmark.password || ''
55
+ if (!pwd) {
56
+ return
57
+ }
58
+ if (!passwordMap.has(pwd)) {
59
+ passwordMap.set(pwd, [])
60
+ }
61
+ passwordMap.get(pwd).push(bookmark)
62
+ })
63
+
64
+ const groups = []
65
+ passwordMap.forEach((bookmarksList, password) => {
66
+ groups.push({
67
+ password,
68
+ count: bookmarksList.length,
69
+ bookmarks: bookmarksList,
70
+ titles: bookmarksList.map(b => createTitle(b))
71
+ })
72
+ })
73
+
74
+ // Sort by count descending
75
+ groups.sort((a, b) => b.count - a.count)
76
+
77
+ this.setState({ passwordGroups: groups })
78
+ }
79
+
80
+ handleCopyPassword = (password) => {
81
+ copyToClipboard(password)
82
+ }
83
+
84
+ showEditModal = (record) => {
85
+ this.setState({
86
+ newPassword: record.password,
87
+ editModalVisible: true,
88
+ selectedBookmarks: record.bookmarks
89
+ })
90
+ }
91
+
92
+ handleEditConfirm = () => {
93
+ const { newPassword, selectedBookmarks } = this.state
94
+ if (!newPassword) {
95
+ return
96
+ }
97
+
98
+ const { editItem } = this.props
99
+ selectedBookmarks.forEach(bookmark => {
100
+ editItem(bookmark.id, { password: newPassword }, settingMap.bookmarks)
101
+ })
102
+
103
+ this.setState({
104
+ editModalVisible: false,
105
+ newPassword: ''
106
+ })
107
+
108
+ this.groupBookmarksByPassword()
109
+ }
110
+
111
+ handleEditCancel = () => {
112
+ this.setState({
113
+ editModalVisible: false,
114
+ newPassword: ''
115
+ })
116
+ }
117
+
118
+ handlePasswordChange = (newPwd) => {
119
+ this.setState({ newPassword: newPwd }, this.handleEditConfirm)
120
+ }
121
+
122
+ handleSearchChange = (evt) => {
123
+ this.setState({
124
+ search: evt.target.value,
125
+ pagination: { ...this.state.pagination, current: 1 }
126
+ })
127
+ }
128
+
129
+ handleTableChange = (pagination) => {
130
+ this.setState({ pagination })
131
+ }
132
+
133
+ getFilteredData = () => {
134
+ const { passwordGroups, search } = this.state
135
+ if (!search) {
136
+ return passwordGroups
137
+ }
138
+ const keyword = search.toLowerCase()
139
+ return passwordGroups.filter(group => {
140
+ return group.titles.some(title =>
141
+ title.toLowerCase().includes(keyword)
142
+ )
143
+ })
144
+ }
145
+
146
+ getColumns = () => {
147
+ const columns = [
148
+ {
149
+ title: e('password'),
150
+ dataIndex: 'password',
151
+ key: 'password',
152
+ render: () => {
153
+ const props0 = {
154
+ children: [
155
+ <KeyOutlined key='icon' />,
156
+ <TextAnt keyboard key='text'>********</TextAnt>
157
+ ]
158
+ }
159
+ return <Space>{props0.children}</Space>
160
+ }
161
+ },
162
+ {
163
+ title: e('count'),
164
+ dataIndex: 'count',
165
+ key: 'count',
166
+ width: 80,
167
+ render: (count) => {
168
+ const props0 = {
169
+ color: 'blue',
170
+ children: count
171
+ }
172
+ return <Tag {...props0} />
173
+ }
174
+ },
175
+ {
176
+ title: e('host'),
177
+ dataIndex: 'titles',
178
+ key: 'host',
179
+ render: (titles) => {
180
+ const display = titles.length > 2
181
+ ? `${titles.slice(0, 2).join(', ')}... (+${titles.length - 2})`
182
+ : titles.join(', ')
183
+ const props0 = {
184
+ title: titles.join('\n'),
185
+ children: <span>{display}</span>
186
+ }
187
+ return <Tooltip {...props0} />
188
+ }
189
+ },
190
+ {
191
+ title: e('actions'),
192
+ key: 'actions',
193
+ width: 80,
194
+ render: (_, record) => {
195
+ const copyProps0 = {
196
+ type: 'text',
197
+ icon: <CopyOutlined />,
198
+ onClick: () => this.handleCopyPassword(record.password)
199
+ }
200
+ const editProps0 = {
201
+ type: 'text',
202
+ icon: <EditOutlined />,
203
+ onClick: () => this.showEditModal(record)
204
+ }
205
+ const copyTooltipProps = {
206
+ title: e('copy'),
207
+ children: <Button {...copyProps0} />
208
+ }
209
+ const editTooltipProps = {
210
+ title: e('changePassword'),
211
+ children: <Button {...editProps0} />
212
+ }
213
+ const spaceProps0 = {
214
+ children: [
215
+ <Tooltip key='copy' {...copyTooltipProps} />,
216
+ <Tooltip key='edit' {...editTooltipProps} />
217
+ ]
218
+ }
219
+ return <Space>{spaceProps0.children}</Space>
220
+ }
221
+ }
222
+ ]
223
+ return columns
224
+ }
225
+
226
+ renderContent () {
227
+ const { search, pagination } = this.state
228
+ const data = this.getFilteredData()
229
+
230
+ if (data.length === 0) {
231
+ return (
232
+ <div className='setting-passwords-empty'>
233
+ <LaptopOutlined style={{ fontSize: 48, color: '#ccc' }} />
234
+ <p>{e('noPasswordsFound')}</p>
235
+ </div>
236
+ )
237
+ }
238
+
239
+ const searchProps0 = {
240
+ value: search,
241
+ onChange: this.handleSearchChange,
242
+ placeholder: e('search')
243
+ }
244
+ const tableProps0 = {
245
+ dataSource: data,
246
+ columns: this.getColumns(),
247
+ rowKey: 'password',
248
+ pagination: { ...pagination, showSizeChanger: true },
249
+ onChange: this.handleTableChange,
250
+ size: 'small'
251
+ }
252
+
253
+ return (
254
+ <div>
255
+ <Search {...searchProps0} />
256
+ <Table {...tableProps0} />
257
+ </div>
258
+ )
259
+ }
260
+
261
+ render () {
262
+ const { editModalVisible, newPassword, selectedBookmarks } = this.state
263
+
264
+ const modalProps0 = {
265
+ title: e('changePassword'),
266
+ open: editModalVisible,
267
+ onCancel: this.handleEditCancel,
268
+ footer: null
269
+ }
270
+
271
+ return (
272
+ <div className='setting-passwords'>
273
+ <div className='setting-passwords-header'>
274
+ <h3>
275
+ <KeyOutlined /> {e('passwords')}
276
+ </h3>
277
+ </div>
278
+
279
+ {this.renderContent()}
280
+
281
+ <Modal {...modalProps0}>
282
+ <div className='password-edit-form'>
283
+ <InputConfirm
284
+ value={newPassword}
285
+ onChange={this.handlePasswordChange}
286
+ placeholder={e('newPassword')}
287
+ inputComponent={Input.Password}
288
+ />
289
+ <div className='affected-bookmarks pd2y'>
290
+ <h3>{e('bookmarks')}</h3>
291
+ {
292
+ selectedBookmarks.map(b => (
293
+ <p key={b.id}>
294
+ # {createTitle(b)}
295
+ </p>
296
+ ))
297
+ }
298
+ </div>
299
+ </div>
300
+ </Modal>
301
+ </div>
302
+ )
303
+ }
304
+ }
@@ -6,13 +6,15 @@ import SettingCol from './col'
6
6
  import SettingAi from '../ai/ai-config'
7
7
  import SyncSetting from '../setting-sync/setting-sync'
8
8
  import Shortcuts from '../shortcuts/shortcuts'
9
+ import SettingPasswords from './setting-passwords'
9
10
  import List from './list'
10
11
  import {
11
12
  settingMap,
12
13
  settingSyncId,
13
14
  settingTerminalId,
14
15
  settingAiId,
15
- settingShortcutsId
16
+ settingShortcutsId,
17
+ settingPasswordsId
16
18
  } from '../../common/constants'
17
19
  import { aiConfigsArr } from '../ai/ai-config-props'
18
20
  import { pick } from 'lodash-es'
@@ -71,6 +73,13 @@ export default auto(function TabSettings (props) {
71
73
  config: store.config
72
74
  }
73
75
  elem = <Shortcuts {...shortcutsProps} />
76
+ } else if (sid === settingPasswordsId) {
77
+ const passwordsProps = {
78
+ bookmarks: store.bookmarks,
79
+ editItem: store.editItem,
80
+ copyToClipboard: window.copyToClipboard
81
+ }
82
+ elem = <SettingPasswords {...passwordsProps} />
74
83
  } else {
75
84
  elem = (
76
85
  <SettingCommon
@@ -78,9 +78,9 @@ export default function SyncForm (props) {
78
78
  description: test.stack || 'Request failed'
79
79
  })
80
80
  }
81
- if (!res.gistId && syncType !== syncTypes.custom && syncType !== syncTypes.cloud) {
82
- window.store.createGist(syncType)
83
- }
81
+ // if (!res.gistId && syncType !== syncTypes.custom && syncType !== syncTypes.cloud) {
82
+ // window.store.createGist(syncType)
83
+ // }
84
84
  }
85
85
 
86
86
  function upload () {
@@ -217,6 +217,7 @@ export default function SyncForm (props) {
217
217
  <FormItem
218
218
  label={gistLabel}
219
219
  name='gistId'
220
+ required
220
221
  normalize={trim}
221
222
  rules={[{
222
223
  max: 100, message: '100 chars max'
@@ -156,6 +156,12 @@ export function shortcutExtend (Cls) {
156
156
  !altKey &&
157
157
  !ctrlKey
158
158
  ) {
159
+ // If IME is composing, let the browser delete the composition char only
160
+ // Returning false tells xterm not to process the event (and not to call
161
+ // preventDefault), so the native textarea backspace still works for IME.
162
+ if (event.isComposing) {
163
+ return false
164
+ }
159
165
  this.props.onDelKeyPressed()
160
166
  const delKey = this.props.config.backspaceMode === '^?' ? 8 : 127
161
167
  const altDelDelKey = delKey === 8 ? 127 : 8
@@ -23,8 +23,8 @@ export default () => {
23
23
  },
24
24
  {
25
25
  name: 'app_cloneToNextLayout',
26
- shortcut: 'ctrl+/',
27
- shortcutMac: 'meta+/'
26
+ shortcut: 'alt+/',
27
+ shortcutMac: 'alt+/'
28
28
  },
29
29
  {
30
30
  name: 'app_duplicateTab',
@@ -13,9 +13,6 @@ const SORT_BY_FREQ_KEY = 'electerm-history-sort-by-frequency'
13
13
 
14
14
  export default auto(function HistoryPanel (props) {
15
15
  const { store } = window
16
- if (store.config.disableConnectionHistory) {
17
- return null
18
- }
19
16
  const [sortByFrequency, setSortByFrequency] = useState(() => {
20
17
  return getItemJSON(SORT_BY_FREQ_KEY, false)
21
18
  })
@@ -27,7 +24,7 @@ export default auto(function HistoryPanel (props) {
27
24
  const {
28
25
  history
29
26
  } = store
30
- let arr = [...history]
27
+ let arr = store.config.disableConnectionHistory ? [] : history
31
28
  if (sortByFrequency) {
32
29
  arr = arr.sort((a, b) => { return b.count - a.count })
33
30
  }
@@ -15,6 +15,10 @@ export default class AttachAddonCustom {
15
15
  this._disposables = []
16
16
  this._socket = socket
17
17
  this.decoder = new TextDecoder('utf-8')
18
+ this._lastDataTime = Date.now()
19
+ this._lastInputTime = Date.now()
20
+ this._keepaliveTimer = null
21
+ this._keepaliveInterval = 3000
18
22
  }
19
23
 
20
24
  _initBase = async () => {
@@ -31,13 +35,15 @@ export default class AttachAddonCustom {
31
35
  }
32
36
  }
33
37
 
34
- startOutputSuppression = (timeout = 3000, onEnd = null) => {
38
+ startOutputSuppression = (timeout = 3000, onEnd = null, discardOnTimeout = false) => {
35
39
  this.outputSuppressed = true
36
40
  this.suppressedData = []
37
41
  this.onSuppressionEndCallback = onEnd
38
42
  this.suppressTimeout = setTimeout(() => {
39
- console.warn('[AttachAddon] Output suppression timeout reached, resuming')
40
- this.stopOutputSuppression(false)
43
+ if (!discardOnTimeout) {
44
+ console.warn('[AttachAddon] Output suppression timeout reached, resuming')
45
+ }
46
+ this.stopOutputSuppression(discardOnTimeout)
41
47
  }, timeout)
42
48
  }
43
49
 
@@ -82,6 +88,7 @@ export default class AttachAddonCustom {
82
88
  }
83
89
 
84
90
  onMsg = (ev) => {
91
+ this._lastDataTime = Date.now()
85
92
  if (typeof ev.data === 'string') {
86
93
  try {
87
94
  const msg = JSON.parse(ev.data)
@@ -163,9 +170,50 @@ export default class AttachAddonCustom {
163
170
  }
164
171
 
165
172
  sendToServer = (data) => {
173
+ this._lastInputTime = Date.now()
166
174
  this._sendData(data)
167
175
  }
168
176
 
177
+ _startKeepalive = () => {
178
+ this._stopKeepalive()
179
+ this._keepaliveTimer = setInterval(this._checkKeepalive, this._keepaliveInterval)
180
+ }
181
+
182
+ _stopKeepalive = () => {
183
+ if (this._keepaliveTimer) {
184
+ clearInterval(this._keepaliveTimer)
185
+ this._keepaliveTimer = null
186
+ }
187
+ }
188
+
189
+ _checkKeepalive = () => {
190
+ if (this.outputSuppressed) {
191
+ return
192
+ }
193
+ const now = Date.now()
194
+ const idleSinceData = now - this._lastDataTime
195
+ const idleSinceInput = now - this._lastInputTime
196
+ if (idleSinceData >= this._keepaliveInterval && idleSinceInput >= this._keepaliveInterval) {
197
+ // Tell the server to write \n to the PTY so bash's read() wakes up and
198
+ // resets the TMOUT alarm. The user has explicitly enabled keepalive and
199
+ // accepts the side-effect of an occasional echoed newline / re-prompt.
200
+ // Start output suppression to hide the echoed prompt.
201
+ const sock = this._socket
202
+ if (sock && sock.readyState === 1 /* OPEN */) {
203
+ this.startOutputSuppression(500, null, true)
204
+ sock.send(JSON.stringify({ action: 'keepalive' }))
205
+ }
206
+ }
207
+ }
208
+
209
+ setKeepalive = (enabled) => {
210
+ if (enabled) {
211
+ this._startKeepalive()
212
+ } else {
213
+ this._stopKeepalive()
214
+ }
215
+ }
216
+
169
217
  addSocketListener = (socket, type, handler) => {
170
218
  socket.addEventListener(type, handler)
171
219
  return {
@@ -179,6 +227,7 @@ export default class AttachAddonCustom {
179
227
  }
180
228
 
181
229
  dispose = () => {
230
+ this._stopKeepalive()
182
231
  this.term = null
183
232
  this._disposables.forEach(d => d.dispose())
184
233
  this._disposables.length = 0
@@ -79,17 +79,52 @@ export class KeywordHighlighterAddon {
79
79
  segments.push({ type: 'text', content: text.slice(lastIndex) })
80
80
  }
81
81
 
82
- // Highlight only plain text segments
82
+ // Highlight only plain text segments using two-phase approach
83
+ // to prevent patterns from interfering with each other's ANSI codes
83
84
  const result = segments.map(seg => {
84
85
  if (seg.type === 'ansi') {
85
86
  return seg.content
86
87
  }
87
- let content = seg.content
88
+ const content = seg.content
89
+ // Phase 1: collect all match positions from all patterns on original text
90
+ const matches = []
88
91
  for (const { regex, colorCode } of this.compiledPatterns) {
89
92
  regex.lastIndex = 0
90
- content = content.replace(regex, (m) => `${colorCode}${m}\u001b[0m`)
93
+ let m
94
+ while ((m = regex.exec(content)) !== null) {
95
+ if (m[0].length === 0) {
96
+ regex.lastIndex++
97
+ continue
98
+ }
99
+ matches.push({
100
+ start: m.index,
101
+ end: m.index + m[0].length,
102
+ text: m[0],
103
+ colorCode
104
+ })
105
+ }
106
+ }
107
+ if (matches.length === 0) {
108
+ return content
109
+ }
110
+ // Phase 2: sort by position, remove overlaps (first pattern wins)
111
+ matches.sort((a, b) => a.start - b.start || a.end - b.end)
112
+ const filtered = [matches[0]]
113
+ for (let i = 1; i < matches.length; i++) {
114
+ if (matches[i].start >= filtered[filtered.length - 1].end) {
115
+ filtered.push(matches[i])
116
+ }
117
+ }
118
+ // Build result with ANSI codes inserted at correct positions
119
+ let highlighted = ''
120
+ let pos = 0
121
+ for (const m of filtered) {
122
+ highlighted += content.slice(pos, m.start)
123
+ highlighted += m.colorCode + m.text + '\u001b[0m'
124
+ pos = m.end
91
125
  }
92
- return content
126
+ highlighted += content.slice(pos)
127
+ return highlighted
93
128
  }).join('')
94
129
 
95
130
  return result
@@ -22,7 +22,8 @@ import {
22
22
  typeMap,
23
23
  isWin,
24
24
  rendererTypes,
25
- isMac
25
+ isMac,
26
+ isMacJs
26
27
  } from '../../common/constants.js'
27
28
  import deepCopy from 'json-deep-copy'
28
29
  import { readClipboardAsync, readClipboard, copy } from '../../common/clipboard.js'
@@ -479,9 +480,9 @@ class Term extends Component {
479
480
  this.term.focus()
480
481
  }
481
482
 
482
- // onSelectAll = () => {
483
- // this.term.selectAll()
484
- // }
483
+ onSelectAll = () => {
484
+ this.term.selectAll()
485
+ }
485
486
 
486
487
  onClear = () => {
487
488
  this.term.clear()
@@ -515,6 +516,15 @@ class Term extends Component {
515
516
  window.store.toggleTerminalSearch()
516
517
  }
517
518
 
519
+ toggleKeepalive = () => {
520
+ if (!this.attachAddon) {
521
+ return false
522
+ }
523
+ this._keepaliveEnabled = !this._keepaliveEnabled
524
+ this.attachAddon.setKeepalive(this._keepaliveEnabled)
525
+ return this._keepaliveEnabled
526
+ }
527
+
518
528
  onSearchResultsChange = ({ resultIndex, resultCount }) => {
519
529
  window.store.storeAssign({
520
530
  termSearchMatchCount: resultCount,
@@ -561,7 +571,7 @@ class Term extends Component {
561
571
  const pasteShortcut = this.getShortcut('terminal_paste')
562
572
  const clearShortcut = this.getShortcut('terminal_clear')
563
573
  const searchShortcut = this.getShortcut('terminal_search')
564
-
574
+ const selectAllShortcut = isMacJs ? 'meta+a' : 'ctrl+shift+a'
565
575
  return [
566
576
  {
567
577
  key: 'onCopy',
@@ -583,6 +593,13 @@ class Term extends Component {
583
593
  label: e('pasteSelected'),
584
594
  disabled: !hasSelection
585
595
  },
596
+ {
597
+
598
+ key: 'onSelectAll',
599
+ icon: <iconsMap.CheckSquareOutlined />,
600
+ label: e('selectall'),
601
+ extra: selectAllShortcut
602
+ },
586
603
  {
587
604
  key: 'explainWithAi',
588
605
  icon: <AIIcon />,
@@ -1100,6 +1117,7 @@ class Term extends Component {
1100
1117
  }
1101
1118
  }
1102
1119
  }
1120
+ const keepaliveInterval = tab.keepaliveInterval || config.keepaliveInterval
1103
1121
  const opts = clone({
1104
1122
  cols,
1105
1123
  rows,
@@ -1112,12 +1130,11 @@ class Term extends Component {
1112
1130
  sessionLogPath: config.sessionLogPath || createDefaultLogPath(),
1113
1131
  ...pick(config, [
1114
1132
  'addTimeStampToTermLog',
1115
- 'keepaliveInterval',
1116
1133
  'keepaliveCountMax',
1117
1134
  'keyword2FA',
1118
1135
  'debug'
1119
1136
  ]),
1120
- keepaliveInterval: tab.keepaliveInterval || config.keepaliveInterval,
1137
+ keepaliveInterval,
1121
1138
  tabId: id,
1122
1139
  uid: id,
1123
1140
  srcTabId: tab.id,
@@ -1,13 +1,30 @@
1
1
  /**
2
2
  * Widget form component
3
3
  */
4
- import React from 'react'
5
- import { Form, Input, InputNumber, Switch, Select, Button, Tooltip } from 'antd'
4
+ import React, { useState, useEffect } from 'react'
5
+ import { Form, Input, InputNumber, Switch, Select, Button, Tooltip, Alert } from 'antd'
6
6
  import { formItemLayout, tailFormItemLayout } from '../../common/form-layout'
7
7
  import HelpIcon from '../common/help-icon'
8
8
 
9
9
  export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInstance }) {
10
10
  const [form] = Form.useForm()
11
+ const [showDownloadWarning, setShowDownloadWarning] = useState(false)
12
+
13
+ useEffect(() => {
14
+ let timer
15
+ if (loading) {
16
+ timer = setTimeout(() => {
17
+ setShowDownloadWarning(true)
18
+ }, 3000)
19
+ } else {
20
+ setShowDownloadWarning(false)
21
+ }
22
+ return () => {
23
+ if (timer) {
24
+ clearTimeout(timer)
25
+ }
26
+ }
27
+ }, [loading])
11
28
 
12
29
  if (!widget) {
13
30
  return null
@@ -76,6 +93,20 @@ export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInsta
76
93
  )
77
94
  }
78
95
 
96
+ function renderWarn () {
97
+ if (!showDownloadWarning) {
98
+ return null
99
+ }
100
+ return (
101
+ <Alert
102
+ message='Downloading package may take some time on first use...'
103
+ type='warning'
104
+ showIcon
105
+ className='mg1b'
106
+ />
107
+ )
108
+ }
109
+
79
110
  const initialValues = configs.reduce((acc, config) => {
80
111
  acc[config.name] = config.default
81
112
  return acc
@@ -92,6 +123,7 @@ export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInsta
92
123
  </h4>
93
124
  <p>{info.description}</p>
94
125
  </div>
126
+ {renderWarn()}
95
127
  <Form
96
128
  form={form}
97
129
  onFinish={handleSubmit}
@@ -101,32 +101,32 @@ export default (Store) => {
101
101
  return gist
102
102
  }
103
103
 
104
- Store.prototype.createGist = async function (type) {
105
- const { store } = window
106
- store.isSyncingSetting = true
107
- const token = store.getSyncToken(type)
108
- const data = {
109
- description: 'sync electerm data',
110
- files: {
111
- 'placeholder.js': {
112
- content: 'placeholder'
113
- }
114
- },
115
- public: false
116
- }
117
- const res = await fetchData(
118
- type, 'create', [data], token, store.getSyncProxy(type)
119
- ).catch(
120
- store.onError
121
- )
122
- if (res && type !== syncTypes.custom) {
123
- store.updateSyncSetting({
124
- [type + 'GistId']: res.id,
125
- [type + 'Url']: res.html_url
126
- })
127
- }
128
- store.isSyncingSetting = false
129
- }
104
+ // Store.prototype.createGist = async function (type) {
105
+ // const { store } = window
106
+ // store.isSyncingSetting = true
107
+ // const token = store.getSyncToken(type)
108
+ // const data = {
109
+ // description: 'sync electerm data',
110
+ // files: {
111
+ // 'placeholder.js': {
112
+ // content: 'placeholder'
113
+ // }
114
+ // },
115
+ // public: false
116
+ // }
117
+ // const res = await fetchData(
118
+ // type, 'create', [data], token, store.getSyncProxy(type)
119
+ // ).catch(
120
+ // store.onError
121
+ // )
122
+ // if (res && type !== syncTypes.custom) {
123
+ // store.updateSyncSetting({
124
+ // [type + 'GistId']: res.id,
125
+ // [type + 'Url']: res.html_url
126
+ // })
127
+ // }
128
+ // store.isSyncingSetting = false
129
+ // }
130
130
 
131
131
  Store.prototype.handleClearSyncSetting = async function () {
132
132
  const { store } = window
@@ -196,11 +196,11 @@ export default (Store) => {
196
196
  Store.prototype.uploadSettingAction = async function (type) {
197
197
  const { store } = window
198
198
  const token = store.getSyncToken(type)
199
- let gistId = store.getSyncGistId(type)
200
- if (!gistId && type !== syncTypes.cloud && type !== syncTypes.custom) {
201
- await store.createGist(type)
202
- gistId = store.getSyncGistId(type)
203
- }
199
+ const gistId = store.getSyncGistId(type)
200
+ // if (!gistId && type !== syncTypes.cloud && type !== syncTypes.custom) {
201
+ // await store.createGist(type)
202
+ // gistId = store.getSyncGistId(type)
203
+ // }
204
204
  if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud) {
205
205
  return
206
206
  }
@@ -280,11 +280,11 @@ export default (Store) => {
280
280
  Store.prototype.downloadSettingAction = async function (type) {
281
281
  const { store } = window
282
282
  const token = store.getSyncToken(type)
283
- let gistId = store.getSyncGistId(type)
284
- if (!gistId && type !== syncTypes.cloud && type !== syncTypes.custom) {
285
- await store.createGist(type)
286
- gistId = store.getSyncGistId(type)
287
- }
283
+ const gistId = store.getSyncGistId(type)
284
+ // if (!gistId && type !== syncTypes.cloud && type !== syncTypes.custom) {
285
+ // await store.createGist(type)
286
+ // gistId = store.getSyncGistId(type)
287
+ // }
288
288
  if (!gistId && type !== syncTypes.custom && type !== syncTypes.cloud) {
289
289
  return
290
290
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "2.16.8",
3
+ "version": "2.17.8",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",