@electerm/electerm-react 3.9.5 → 3.10.0

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 (28) hide show
  1. package/client/common/default-setting.js +3 -2
  2. package/client/components/ai/ai-config.jsx +9 -1
  3. package/client/components/batch-op/batch-op-runner.jsx +1 -2
  4. package/client/components/bookmark-form/common/bookmark-group-tree-format.js +1 -1
  5. package/client/components/bookmark-form/common/bookmark-select.jsx +1 -1
  6. package/client/components/bookmark-form/tree-select.jsx +1 -1
  7. package/client/components/setting-panel/setting-terminal.jsx +12 -0
  8. package/client/components/setting-panel/start-session-select.jsx +1 -1
  9. package/client/components/sftp/list-table-ui.jsx +5 -1
  10. package/client/components/tabs/tab.jsx +38 -19
  11. package/client/components/tabs/tabs.styl +8 -17
  12. package/client/components/terminal/attach-addon-custom.js +7 -3
  13. package/client/components/terminal/drop-file-modal.jsx +3 -3
  14. package/client/components/terminal/terminal-apis.js +8 -0
  15. package/client/components/terminal/terminal-command-dropdown.jsx +1 -1
  16. package/client/components/terminal/terminal-interactive.jsx +69 -30
  17. package/client/components/terminal/terminal.jsx +30 -14
  18. package/client/components/terminal-info/base.jsx +25 -14
  19. package/client/components/terminal-info/log-path-edit.jsx +86 -0
  20. package/client/components/text-editor/edit-with-custom-editor.jsx +22 -3
  21. package/client/components/text-editor/text-editor.jsx +21 -0
  22. package/client/components/tree-list/tree-list-item.jsx +6 -12
  23. package/client/components/tree-list/tree-list-row.jsx +5 -0
  24. package/client/components/tree-list/tree-list-rows.js +3 -1
  25. package/client/components/widgets/widget-form.jsx +3 -0
  26. package/client/store/mcp-handler.js +83 -10
  27. package/client/store/tab.js +14 -0
  28. package/package.json +1 -1
@@ -64,7 +64,7 @@ export default {
64
64
  ],
65
65
  hideIP: false,
66
66
  dataSyncSelected: 'all',
67
- baseURLAI: 'https://api.deepseek.com',
67
+ baseURLAI: 'https://api.atlascloud.ai/v1',
68
68
  modelAI: 'deepseek-chat',
69
69
  roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式',
70
70
  apiPathAI: '/chat/completions',
@@ -75,5 +75,6 @@ export default {
75
75
  autoReconnectTerminal: false,
76
76
  startDirectoryLocal: '',
77
77
  allowMultiInstance: false,
78
- disableDeveloperTool: false
78
+ disableDeveloperTool: false,
79
+ dragDropBehavior: 'ask'
79
80
  }
@@ -36,6 +36,7 @@ const proxyOptions = [
36
36
 
37
37
  export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig }) {
38
38
  const [form] = Form.useForm()
39
+ const baseURLAI = Form.useWatch('baseURLAI', form)
39
40
 
40
41
  useEffect(() => {
41
42
  if (initialValues) {
@@ -67,6 +68,13 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
67
68
  return { label, title }
68
69
  }
69
70
 
71
+ function renderApiUrlLabel () {
72
+ if (baseURLAI === 'https://api.atlascloud.ai/v1') {
73
+ return <span>API URL (<Link to='https://atlascloud.ai'>AtlasCloud</Link>)</span>
74
+ }
75
+ return 'API URL'
76
+ }
77
+
70
78
  if (!showAIConfig) {
71
79
  return null
72
80
  }
@@ -90,7 +98,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
90
98
  layout='vertical'
91
99
  className='ai-config-form'
92
100
  >
93
- <Form.Item label='API URL' required>
101
+ <Form.Item label={renderApiUrlLabel()} required>
94
102
  <Space.Compact className='width-100'>
95
103
  <Form.Item
96
104
  label='API URL'
@@ -1,5 +1,5 @@
1
1
  import { Component } from 'react'
2
- import { refsStatic } from '../common/ref'
2
+ import { refsStatic, refs } from '../common/ref'
3
3
  import { statusMap } from '../../common/constants'
4
4
  import { autoRun } from 'manate'
5
5
  import uid from '../../common/uid'
@@ -237,7 +237,6 @@ export default class BatchOpRunner extends Component {
237
237
  throw new Error('No active tab. Please connect first.')
238
238
  }
239
239
 
240
- const { refs } = await import('../common/ref')
241
240
  const term = refs.get('term-' + tabId)
242
241
  if (!term || !term.term) {
243
242
  throw new Error('Terminal not found')
@@ -21,7 +21,7 @@ export default (bookmarkGroups = [], disabledId = '', returnMap = false, current
21
21
  }
22
22
  return y
23
23
  }
24
- const level1 = bookmarkGroups.filter(d => d.level !== 2)
24
+ const level1 = bookmarkGroups.filter(d => d.level === 1 || !d.level)
25
25
  .map(d => {
26
26
  const r = {
27
27
  title: d.title,
@@ -38,7 +38,7 @@ function buildTreeData (bookmarkGroups, tree) {
38
38
  if (!x) return ''
39
39
  return { value: x.id, key: x.id, title: createTitle(x) }
40
40
  }
41
- const level1 = cats.filter(d => d.level !== 2)
41
+ const level1 = cats.filter(d => d.level === 1 || !d.level)
42
42
  .map(d => {
43
43
  const r = {
44
44
  title: d.title,
@@ -79,7 +79,7 @@ function buildData (bookmarks, bookmarkGroups, searchText = '') {
79
79
  title: createTitleWithTag(x)
80
80
  }
81
81
  }
82
- const level1 = cats.filter(d => d.level !== 2)
82
+ const level1 = cats.filter(d => d.level === 1 || !d.level)
83
83
  .map(d => {
84
84
  const children = [
85
85
  ...(d.bookmarkGroupIds || []).map(buildSubCats),
@@ -84,6 +84,7 @@ export default class SettingTerminal extends Component {
84
84
 
85
85
  handleChangeDelMode = v => this.onChangeValue(v, 'backspaceMode')
86
86
  handleChangeRenderType = v => this.onChangeValue(v, 'rendererType')
87
+ handleChangeDragDropBehavior = v => this.onChangeValue(v, 'dragDropBehavior')
87
88
 
88
89
  handleChangeFont = (values) => {
89
90
  this.onChangeValue(
@@ -471,6 +472,7 @@ export default class SettingTerminal extends Component {
471
472
  const {
472
473
  rendererType,
473
474
  backspaceMode = '^?',
475
+ dragDropBehavior = 'ask',
474
476
  keywords = [{ color: 'red' }]
475
477
  } = this.props.config
476
478
  const {
@@ -593,6 +595,16 @@ export default class SettingTerminal extends Component {
593
595
  'autoReconnectTerminal'
594
596
  ].map(d => this.renderToggle(d))
595
597
  }
598
+ <div className='pd1b'>{e('dragDropBehavior')}</div>
599
+ <Select
600
+ onChange={this.handleChangeDragDropBehavior}
601
+ value={dragDropBehavior}
602
+ popupMatchSelectWidth={false}
603
+ >
604
+ {['ask', 'trz', 'rz', 'inputOnly'].map(id => (
605
+ <Option key={id} value={id}>{e(id)}</Option>
606
+ ))}
607
+ </Select>
596
608
  <div className='pd1b'>{e('terminalBackSpaceMode')}</div>
597
609
  <Select
598
610
  onChange={this.handleChangeDelMode}
@@ -65,7 +65,7 @@ function BookmarkSelect (props) {
65
65
  title: createTitleWithTag(x)
66
66
  }
67
67
  }
68
- const level1 = cats.filter(d => d.level !== 2)
68
+ const level1 = cats.filter(d => d.level === 1 || !d.level)
69
69
  .map(d => {
70
70
  const r = {
71
71
  title: d.title,
@@ -28,7 +28,11 @@ export default class FileListTable extends Component {
28
28
  containerRef = createRef()
29
29
 
30
30
  componentDidUpdate (prevProps) {
31
- if (prevProps.fileList !== this.props.fileList) {
31
+ const prevList = prevProps.fileList
32
+ const nextList = this.props.fileList
33
+ const contentChanged = prevList.length !== nextList.length ||
34
+ prevList.some((f, i) => f.id !== nextList[i].id)
35
+ if (contentChanged) {
32
36
  if (this.containerRef.current) {
33
37
  this.containerRef.current.scrollTop = 0
34
38
  }
@@ -8,7 +8,8 @@ import { refsTabs } from '../common/ref'
8
8
  import {
9
9
  CloseOutlined,
10
10
  Loading3QuartersOutlined,
11
- BorderlessTableOutlined
11
+ BorderlessTableOutlined,
12
+ LockOutlined
12
13
  } from '@ant-design/icons'
13
14
  import {
14
15
  Tooltip,
@@ -33,7 +34,7 @@ class Tab extends Component {
33
34
  constructor (props) {
34
35
  super(props)
35
36
  this.state = {
36
- terminalOnData: false
37
+ terminalOnData: ''
37
38
  }
38
39
  this.id = 'tab-' + this.props.tab.id
39
40
  refsTabs.add(this.id, this)
@@ -48,19 +49,38 @@ class Tab extends Component {
48
49
  }
49
50
 
50
51
  notifyOnData = () => {
52
+ if (this.state.terminalOnData === 'password') {
53
+ return
54
+ }
51
55
  if (this.timer) {
52
56
  clearTimeout(this.timer)
53
57
  this.timer = null
54
58
  }
55
59
  this.setState({
56
- terminalOnData: true
60
+ terminalOnData: 'feed'
57
61
  })
58
62
  this.timer = setTimeout(this.clearTerminalOnData, 4000)
59
63
  }
60
64
 
61
65
  clearTerminalOnData = () => {
62
66
  this.setState({
63
- terminalOnData: false
67
+ terminalOnData: ''
68
+ })
69
+ }
70
+
71
+ notifyPasswordPrompt = () => {
72
+ if (this.timer) {
73
+ clearTimeout(this.timer)
74
+ this.timer = null
75
+ }
76
+ this.setState({
77
+ terminalOnData: 'password'
78
+ })
79
+ }
80
+
81
+ clearPasswordPrompt = () => {
82
+ this.setState({
83
+ terminalOnData: ''
64
84
  })
65
85
  }
66
86
 
@@ -424,13 +444,7 @@ class Tab extends Component {
424
444
  {
425
445
  'tab-last': isLast
426
446
  },
427
- status,
428
- {
429
- 'is-terminal-active': terminalOnData
430
- },
431
- {
432
- 'is-transporting': isTransporting
433
- }
447
+ status
434
448
  )
435
449
  const title = createName(tab)
436
450
  let tooltipTitle = title
@@ -477,15 +491,19 @@ class Tab extends Component {
477
491
  >
478
492
  <Dropdown {...dropdownProps}>
479
493
  <div
480
- className='tab-title elli pd1x'
494
+ className='tab-title elli'
481
495
  onClick={this.handleClick}
482
496
  onDoubleClick={this.handleDup}
483
497
  >
484
- <Loading3QuartersOutlined
485
- className='pointer tab-reload mg1r'
486
- onClick={this.handleReloadTab}
487
- title={e('reload')}
488
- />
498
+ {
499
+ status === 'error' && (
500
+ <Loading3QuartersOutlined
501
+ className='pointer tab-reload mg1r'
502
+ onClick={this.handleReloadTab}
503
+ title={e('reload')}
504
+ />
505
+ )
506
+ }
489
507
  <span className='tab-title'>
490
508
  <span className='iblock mg1r tab-count' style={styleTag}>{tabCount}</span>
491
509
  <span className='mg1r'>{title}</span>
@@ -493,8 +511,9 @@ class Tab extends Component {
493
511
  </div>
494
512
  </Dropdown>
495
513
  <div className={'tab-status ' + status} />
496
- <div className='tab-traffic' />
497
- <BorderlessTableOutlined className='tab-terminal-feed' />
514
+ {isTransporting && <div className='tab-traffic' />}
515
+ {terminalOnData === 'feed' && <BorderlessTableOutlined className='tab-terminal-feed' />}
516
+ {terminalOnData === 'password' && <LockOutlined className='tab-terminal-feed password' />}
498
517
  {
499
518
  this.renderCloseIcon()
500
519
  }
@@ -36,6 +36,7 @@
36
36
  vertical-align middle
37
37
  cursor pointer
38
38
  position relative
39
+ padding 0 14px
39
40
  min-width 100px
40
41
  max-width 200px
41
42
  line-height 36px
@@ -46,7 +47,6 @@
46
47
  color var(--text-dark)
47
48
  &.tab-last
48
49
  margin-right 5px
49
- .tab-reload
50
50
  .tab-close
51
51
  display none
52
52
  &.active
@@ -68,10 +68,6 @@
68
68
  width 1px
69
69
  border 1px dashed var(--text-dark)
70
70
  height 36px
71
- &.error
72
- .tab-reload
73
- display inline-block
74
- color var(--text-light)
75
71
  @keyframes blink
76
72
  0%
77
73
  background-color #e0e0e0
@@ -96,30 +92,25 @@
96
92
  background-color var(--error)
97
93
  &.processing
98
94
  background-color var(--primary)
99
- .is-transporting .tab-traffic
100
- display block
101
- animation blink 2s infinite
102
- /* Remove opacity animation, use background-color */
103
95
  .tab-traffic
104
- display none
105
96
  left 10px
106
97
  width 5px
107
98
  border-radius 0
108
99
  background-color var(--success)
109
- .is-terminal-active .tab-terminal-feed
110
- display block
111
100
  animation blink 2s infinite
112
- background-color transparent !important
113
- /* Remove opacity animation, use background-color */
114
101
  .tab-terminal-feed
115
- display none
116
- left 20px
102
+ width 12px
103
+ height 12px
117
104
  border-radius 0
118
105
  color var(--success)
119
- font-size 8px
106
+ font-size 12px
120
107
  left 2px
121
108
  top 24px
122
109
  background none
110
+ background-color transparent !important
111
+ animation blink 2s infinite
112
+ &.password
113
+ color var(--warn)
123
114
  .tab-close
124
115
  position absolute
125
116
  right 5px
@@ -223,6 +223,7 @@ export default class AttachAddonCustom {
223
223
  }
224
224
 
225
225
  if (typeof data === 'string') {
226
+ term?.parent?.notifyOnData()
226
227
  return term.write(data)
227
228
  }
228
229
  data = new Uint8Array(data)
@@ -242,13 +243,16 @@ export default class AttachAddonCustom {
242
243
  sendToServer = (data) => {
243
244
  this._lastInputTime = Date.now()
244
245
  // Start echo detection when password prompt is suspected
245
- if (this._passwordPromptDetected && !this._pendingEchoCheck && data !== '\r' && data !== '\n') {
246
+ if (this._passwordPromptDetected && !this._pendingEchoCheck && data !== '\r' && data !== '\n' && data !== '\x03') {
246
247
  this._pendingEchoCheck = { char: data, time: Date.now() }
247
248
  clearTimeout(this._echoCheckTimer)
248
249
  this._echoCheckTimer = setTimeout(this._onEchoCheckTimeout, 200)
249
250
  }
250
- // Reset password state on Enter
251
- if (data === '\r' || data === '\n') {
251
+ // Reset password state on Enter or Ctrl+C
252
+ if (data === '\r' || data === '\n' || data === '\x03') {
253
+ if (this._passwordPromptDetected) {
254
+ this.term?.parent?.onPasswordPromptCancelled?.()
255
+ }
252
256
  this._passwordPromptDetected = false
253
257
  this._lastOutputLine = ''
254
258
  this._pendingEchoCheck = null
@@ -26,21 +26,21 @@ export class DropFileModal extends Component {
26
26
  <button
27
27
  type='button'
28
28
  className='custom-modal-ok-btn'
29
- onClick={() => onSelect('trzUpload')}
29
+ onClick={() => onSelect('trz')}
30
30
  >
31
31
  trz
32
32
  </button>
33
33
  <button
34
34
  type='button'
35
35
  className='custom-modal-cancel-btn'
36
- onClick={() => onSelect('rzUpload')}
36
+ onClick={() => onSelect('rz')}
37
37
  >
38
38
  rz
39
39
  </button>
40
40
  <button
41
41
  type='button'
42
42
  className='custom-modal-cancel-btn'
43
- onClick={() => onSelect('inputPath')}
43
+ onClick={() => onSelect('inputOnly')}
44
44
  >
45
45
  {e('inputOnly')}
46
46
  </button>
@@ -41,3 +41,11 @@ export function toggleTerminalLogTimestamp (pid) {
41
41
  action: 'toggle-terminal-log-timestamp'
42
42
  })
43
43
  }
44
+
45
+ export function setTerminalLogPath (pid, logPath) {
46
+ return fetch({
47
+ pid,
48
+ logPath,
49
+ action: 'set-terminal-log-path'
50
+ })
51
+ }
@@ -278,7 +278,7 @@ export default class TerminalCmdSuggestions extends Component {
278
278
  id: uid(),
279
279
  command: b.password,
280
280
  type: 'PW',
281
- hint: [b.username, b.host].filter(Boolean).join('@')
281
+ hint: [b.username, [b.host, b.port].filter(Boolean).join(':')].filter(Boolean).join('@')
282
282
  })
283
283
  }
284
284
  }
@@ -48,6 +48,13 @@ export default function TermInteractive () {
48
48
  function onOk () {
49
49
  form.submit()
50
50
  }
51
+ function onConfirm () {
52
+ window.et.commonWs.s({
53
+ id: opts.id,
54
+ results: [opts.options.confirmResult || 'yes']
55
+ })
56
+ clear()
57
+ }
51
58
  function onIgnore () {
52
59
  window.et.commonWs.s({
53
60
  id: opts.id,
@@ -91,6 +98,32 @@ export default function TermInteractive () {
91
98
  </FormItem>
92
99
  )
93
100
  }
101
+ function renderConfirmBody () {
102
+ const instructions = opts.options.instructions || []
103
+ return (
104
+ <div>
105
+ {
106
+ instructions.map((note, index) => {
107
+ return <pre key={note + index}>{note}</pre>
108
+ })
109
+ }
110
+ <FormItem>
111
+ <Button
112
+ type='primary'
113
+ onClick={onConfirm}
114
+ >
115
+ {opts.options.submitText || e('submit')}
116
+ </Button>
117
+ <Button
118
+ className='mg1l'
119
+ onClick={onCancel}
120
+ >
121
+ {opts.options.cancelText || e('cancel')}
122
+ </Button>
123
+ </FormItem>
124
+ </div>
125
+ )
126
+ }
94
127
  async function initWatch () {
95
128
  let done = false
96
129
  while (!done) {
@@ -125,36 +158,42 @@ export default function TermInteractive () {
125
158
  <Modal
126
159
  {...props}
127
160
  >
128
- <Form
129
- form={form}
130
- layout='vertical'
131
- onFinish={onFinish}
132
- >
133
- {
134
- opts.options.prompts.map(renderFormItem)
135
- }
136
- <FormItem>
137
- <Button
138
- type='primary'
139
- htmlType='submit'
140
- >
141
- {e('submit')}
142
- </Button>
143
- <Button
144
- type='dashed'
145
- className='mg1l'
146
- onClick={onIgnore}
147
- >
148
- {e('ignore')}
149
- </Button>
150
- <Button
151
- className='mg1l'
152
- onClick={onCancel}
153
- >
154
- {e('cancel')}
155
- </Button>
156
- </FormItem>
157
- </Form>
161
+ {
162
+ opts.options?.mode === 'confirm'
163
+ ? renderConfirmBody()
164
+ : (
165
+ <Form
166
+ form={form}
167
+ layout='vertical'
168
+ onFinish={onFinish}
169
+ >
170
+ {
171
+ opts.options.prompts.map(renderFormItem)
172
+ }
173
+ <FormItem>
174
+ <Button
175
+ type='primary'
176
+ htmlType='submit'
177
+ >
178
+ {e('submit')}
179
+ </Button>
180
+ <Button
181
+ type='dashed'
182
+ className='mg1l'
183
+ onClick={onIgnore}
184
+ >
185
+ {e('ignore')}
186
+ </Button>
187
+ <Button
188
+ className='mg1l'
189
+ onClick={onCancel}
190
+ >
191
+ {e('cancel')}
192
+ </Button>
193
+ </FormItem>
194
+ </Form>
195
+ )
196
+ }
158
197
  </Modal>
159
198
  )
160
199
  }
@@ -70,6 +70,7 @@ class Term extends Component {
70
70
  hasSelection: false,
71
71
  saveTerminalLogToFile: !!this.props.config.saveTerminalLogToFile,
72
72
  addTimeStampToTermLog: !!this.props.config.addTimeStampToTermLog,
73
+ logPath: this.props.config.sessionLogPath || createDefaultLogPath(),
73
74
  passType: 'password',
74
75
  lines: [],
75
76
  searchResults: [],
@@ -400,10 +401,15 @@ class Term extends Component {
400
401
  return
401
402
  }
402
403
  if (isSshTerminal) {
403
- this.setState({
404
- dropFileModalVisible: true,
405
- droppedFiles: [{ path: filePath, isRemote: true }]
406
- })
404
+ const behavior = this.props.config.dragDropBehavior || 'ask'
405
+ if (behavior === 'ask') {
406
+ this.setState({
407
+ dropFileModalVisible: true,
408
+ droppedFiles: [{ path: filePath, isRemote: true }]
409
+ })
410
+ } else {
411
+ this.handleDropFileAction(behavior, [{ path: filePath, isRemote: true }])
412
+ }
407
413
  return
408
414
  }
409
415
  this.attachAddon._sendData(`"${filePath}" `)
@@ -425,10 +431,15 @@ class Term extends Component {
425
431
  }
426
432
 
427
433
  if (isSshTerminal) {
428
- this.setState({
429
- dropFileModalVisible: true,
430
- droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
431
- })
434
+ const behavior = this.props.config.dragDropBehavior || 'ask'
435
+ if (behavior === 'ask') {
436
+ this.setState({
437
+ dropFileModalVisible: true,
438
+ droppedFiles: filePaths.map(path => ({ path, isRemote: false }))
439
+ })
440
+ } else {
441
+ this.handleDropFileAction(behavior, filePaths.map(path => ({ path, isRemote: false })))
442
+ }
432
443
  return
433
444
  }
434
445
 
@@ -444,8 +455,8 @@ class Term extends Component {
444
455
  })
445
456
  }
446
457
 
447
- handleDropFileAction = (action) => {
448
- const { droppedFiles } = this.state
458
+ handleDropFileAction = (action, filesOverride) => {
459
+ const droppedFiles = filesOverride || this.state.droppedFiles
449
460
  if (!droppedFiles || !droppedFiles.length) {
450
461
  this.handleDropFileModalCancel()
451
462
  return
@@ -454,7 +465,7 @@ class Term extends Component {
454
465
  const filePaths = droppedFiles.map(f => f.path)
455
466
 
456
467
  switch (action) {
457
- case 'trzUpload': {
468
+ case 'trz': {
458
469
  if (this.trzszClient && this.trzszClient.isActive) {
459
470
  message.warning('A transfer is already in progress')
460
471
  this.handleDropFileModalCancel()
@@ -464,7 +475,7 @@ class Term extends Component {
464
475
  this.attachAddon._sendData('trz\r')
465
476
  break
466
477
  }
467
- case 'rzUpload': {
478
+ case 'rz':{
468
479
  if (this.zmodemClient && this.zmodemClient.isActive) {
469
480
  message.warning('A transfer is already in progress')
470
481
  this.handleDropFileModalCancel()
@@ -474,7 +485,7 @@ class Term extends Component {
474
485
  this.attachAddon._sendData('rz\r')
475
486
  break
476
487
  }
477
- case 'inputPath':
488
+ case 'inputOnly':
478
489
  default: {
479
490
  const filesAll = filePaths.map(path => `"${path}"`).join(' ')
480
491
  this.attachAddon._sendData(filesAll)
@@ -825,11 +836,15 @@ class Term extends Component {
825
836
  if (currentCmd && currentCmd.trim() && this.shouldUseManualHistory()) {
826
837
  window.store.addCmdHistory(currentCmd.trim())
827
838
  }
839
+ if (currentCmd && currentCmd.trim() === 'exit') {
840
+ this.userTypeExit = true
841
+ }
828
842
  this.closeSuggestions()
829
843
  }
830
844
  }
831
845
 
832
846
  onPasswordPromptDetected = () => {
847
+ window.store.notifyTabPasswordPrompt(this.props.tab.id)
833
848
  if (!this.props.config.showCmdSuggestions) {
834
849
  return
835
850
  }
@@ -842,6 +857,7 @@ class Term extends Component {
842
857
  }
843
858
 
844
859
  onPasswordPromptCancelled = () => {
860
+ window.store.clearTabPasswordPrompt(this.props.tab.id)
845
861
  const suggestions = refsStatic.get('terminal-suggestions')
846
862
  if (suggestions?.state?.passwordMode) {
847
863
  suggestions.closeSuggestions()
@@ -1221,7 +1237,7 @@ class Term extends Component {
1221
1237
  ...extra,
1222
1238
  ...execOpts,
1223
1239
  logName,
1224
- sessionLogPath: config.sessionLogPath || createDefaultLogPath(),
1240
+ sessionLogPath: this.state.logPath,
1225
1241
  ...pick(config, [
1226
1242
  'addTimeStampToTermLog',
1227
1243
  'keepaliveCountMax',
@@ -2,15 +2,13 @@
2
2
  * show base terminal info, id sessionID
3
3
  */
4
4
  import { Component } from 'react'
5
- import { osResolve } from '../../common/resolve'
6
5
  import {
7
6
  Switch,
8
7
  Space,
9
8
  Button
10
9
  } from 'antd'
11
- import ShowItem from '../common/show-item'
12
10
  import defaults from '../../common/default-setting'
13
- import { toggleTerminalLog, toggleTerminalLogTimestamp } from '../terminal/terminal-apis'
11
+ import { toggleTerminalLog, toggleTerminalLogTimestamp, setTerminalLogPath } from '../terminal/terminal-apis'
14
12
  import {
15
13
  ClockCircleOutlined,
16
14
  BorderlessTableOutlined,
@@ -19,8 +17,8 @@ import {
19
17
  ApiOutlined,
20
18
  PartitionOutlined
21
19
  } from '@ant-design/icons'
22
- import createDefaultSessionLogPath from '../../common/default-log-path'
23
20
  import { refs } from '../common/ref'
21
+ import LogPathEdit from './log-path-edit'
24
22
 
25
23
  const e = window.translate
26
24
 
@@ -36,7 +34,8 @@ const mapper = {
36
34
  export default class TerminalInfoBase extends Component {
37
35
  state = {
38
36
  saveTerminalLogToFile: false,
39
- addTimeStampToTermLog: false
37
+ addTimeStampToTermLog: false,
38
+ logPath: ''
40
39
  }
41
40
 
42
41
  componentDidMount () {
@@ -75,6 +74,17 @@ export default class TerminalInfoBase extends Component {
75
74
  })
76
75
  }
77
76
 
77
+ onLogPathChange = (v) => {
78
+ const { pid } = this.props
79
+ setTerminalLogPath(pid, v)
80
+ refs.get('term-' + pid)?.setState({
81
+ logPath: v
82
+ })
83
+ this.setState({
84
+ logPath: v
85
+ })
86
+ }
87
+
78
88
  handleToggle = () => {
79
89
  const { saveTerminalLogToFile, addTimeStampToTermLog } = this.state
80
90
  const {
@@ -101,7 +111,8 @@ export default class TerminalInfoBase extends Component {
101
111
  if (term) {
102
112
  this.setState({
103
113
  saveTerminalLogToFile: term.state.saveTerminalLogToFile,
104
- addTimeStampToTermLog: term.state.addTimeStampToTermLog
114
+ addTimeStampToTermLog: term.state.addTimeStampToTermLog,
115
+ logPath: term.state.logPath
105
116
  })
106
117
  } else {
107
118
  this.timer = setTimeout(this.getState, 100)
@@ -156,15 +167,10 @@ export default class TerminalInfoBase extends Component {
156
167
  const {
157
168
  id,
158
169
  logName,
159
- sessionLogPath
170
+ pid
160
171
  } = this.props
161
- const { saveTerminalLogToFile } = this.state
162
- const base = sessionLogPath || createDefaultSessionLogPath()
163
- const path = osResolve(base, logName + '.log')
172
+ const { saveTerminalLogToFile, logPath } = this.state
164
173
  const name = e('saveTerminalLogToFile')
165
- const to = saveTerminalLogToFile
166
- ? <ShowItem disabled={!saveTerminalLogToFile} to={path}>{path}</ShowItem>
167
- : path
168
174
  return (
169
175
  <div className='terminal-info-section terminal-info-base'>
170
176
  <div className='fix'>
@@ -187,7 +193,12 @@ export default class TerminalInfoBase extends Component {
187
193
  this.renderInfoSelection()
188
194
  }
189
195
  </div>
190
- <p><b>log:</b> {to}</p>
196
+ <LogPathEdit
197
+ pid={pid}
198
+ logPath={logPath}
199
+ logName={logName}
200
+ setLogPath={this.onLogPathChange}
201
+ />
191
202
  </div>
192
203
  )
193
204
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-terminal log path editor
3
+ * Reads/writes logPath on the terminal's own state via refs
4
+ */
5
+ import { Button } from 'antd'
6
+ import message from '../common/message'
7
+ import InputConfirm from '../common/input-confirm'
8
+ import ShowItem from '../common/show-item'
9
+ import { chooseSaveDirectory } from '../../common/choose-save-folder'
10
+ import createDefaultLogPath from '../../common/default-log-path'
11
+ import { osResolve } from '../../common/resolve'
12
+
13
+ const e = window.translate
14
+
15
+ export default function LogPathEdit ({ pid, logPath, logName, setLogPath }) {
16
+ const defaultPath = createDefaultLogPath()
17
+ const base = logPath || defaultPath
18
+ const fullPath = osResolve(base, logName + '.log')
19
+
20
+ const testAndSet = async (v) => {
21
+ if (v) {
22
+ try {
23
+ const { fs } = window
24
+ const uid = 'test-' + Date.now()
25
+ const testFile = osResolve(v, uid + '.test.log')
26
+ await fs.touch(testFile)
27
+ await fs.unlink(testFile)
28
+ } catch (err) {
29
+ console.log('log path test failed', err)
30
+ message.error('invalid log folder')
31
+ return
32
+ }
33
+ }
34
+ setLogPath(v)
35
+ }
36
+
37
+ const handleChange = (v) => {
38
+ testAndSet(v)
39
+ }
40
+
41
+ const handleChooseFolder = async () => {
42
+ const path = await chooseSaveDirectory()
43
+ if (path) {
44
+ handleChange(path)
45
+ }
46
+ }
47
+
48
+ const handleReset = () => {
49
+ setLogPath('')
50
+ }
51
+
52
+ const inputProps = {
53
+ value: logPath,
54
+ placeholder: defaultPath,
55
+ onChange: handleChange,
56
+ addonAfter: (
57
+ <>
58
+ <Button
59
+ onClick={handleChooseFolder}
60
+ className='mg1r'
61
+ type='text'
62
+ size='small'
63
+ >
64
+ {e('chooseFolder')}
65
+ </Button>
66
+ <Button
67
+ size='small'
68
+ type='text'
69
+ onClick={handleReset}
70
+ >
71
+ {e('reset')}
72
+ </Button>
73
+ </>
74
+ ),
75
+ prefix: e('terminalLogPath') + ': '
76
+ }
77
+
78
+ return (
79
+ <div className='pd1b'>
80
+ <InputConfirm {...inputProps} />
81
+ <div className='pd1t font-xs color-grey'>
82
+ {fullPath} <ShowItem to={fullPath} />
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -6,18 +6,30 @@ import { useState } from 'react'
6
6
  import { Button, Input, Space } from 'antd'
7
7
  import { safeGetItem, safeSetItem } from '../../common/safe-local-storage.js'
8
8
 
9
- const LS_KEY = 'customEditorCommand'
9
+ export const CUSTOM_EDITOR_COMMAND_LS_KEY = 'customEditorCommand'
10
+ export const CUSTOM_EDITOR_AUTO_OPEN_LS_KEY = 'customEditorAutoOpen'
10
11
  const e = window.translate
11
12
 
12
13
  export default function EditWithCustomEditor ({ loading, editWithCustom }) {
13
14
  const [editorCommand, setEditorCommand] = useState(
14
- () => safeGetItem(LS_KEY) || ''
15
+ () => safeGetItem(CUSTOM_EDITOR_COMMAND_LS_KEY) || ''
15
16
  )
17
+ const [autoOpen, setAutoOpen] = useState(
18
+ () => safeGetItem(CUSTOM_EDITOR_AUTO_OPEN_LS_KEY) === 'true'
19
+ )
20
+
21
+ const autoOpenLabel = e('autoOpen')
16
22
 
17
23
  function handleChange (ev) {
18
24
  const val = ev.target.value
19
25
  setEditorCommand(val)
20
- safeSetItem(LS_KEY, val)
26
+ safeSetItem(CUSTOM_EDITOR_COMMAND_LS_KEY, val)
27
+ }
28
+
29
+ function handleToggleAutoOpen () {
30
+ const next = !autoOpen
31
+ setAutoOpen(next)
32
+ safeSetItem(CUSTOM_EDITOR_AUTO_OPEN_LS_KEY, String(next))
21
33
  }
22
34
 
23
35
  function handleClick () {
@@ -45,6 +57,13 @@ export default function EditWithCustomEditor ({ loading, editWithCustom }) {
45
57
  onChange={handleChange}
46
58
  disabled={loading}
47
59
  />
60
+ <Button
61
+ type={autoOpen ? 'primary' : 'default'}
62
+ disabled={loading}
63
+ onClick={handleToggleAutoOpen}
64
+ >
65
+ {autoOpenLabel}: {autoOpen ? 'On' : 'Off'}
66
+ </Button>
48
67
  </Space.Compact>
49
68
  )
50
69
  }
@@ -4,9 +4,14 @@
4
4
 
5
5
  import { PureComponent } from 'react'
6
6
  import TextEditorForm from './text-editor-form'
7
+ import {
8
+ CUSTOM_EDITOR_AUTO_OPEN_LS_KEY,
9
+ CUSTOM_EDITOR_COMMAND_LS_KEY
10
+ } from './edit-with-custom-editor'
7
11
  import { Spin } from 'antd'
8
12
  import Modal from '../common/modal'
9
13
  import resolve from '../../common/resolve'
14
+ import { safeGetItem } from '../../common/safe-local-storage.js'
10
15
  import { refsStatic, refs } from '../common/ref'
11
16
 
12
17
  const e = window.translate
@@ -69,12 +74,28 @@ export default class TextEditor extends PureComponent {
69
74
  return
70
75
  }
71
76
  const text = await fileRef.fetchEditorText(p, type)
77
+ const editorCommand = this.getAutoOpenCustomEditorCommand()
72
78
  this.setStateProxy({
73
79
  text,
74
80
  loading: false
81
+ }, () => {
82
+ if (editorCommand) {
83
+ this.editWithCustom(editorCommand)
84
+ }
75
85
  })
76
86
  }
77
87
 
88
+ getAutoOpenCustomEditorCommand = () => {
89
+ if (window.et.isWebApp) {
90
+ return ''
91
+ }
92
+ const autoOpen = safeGetItem(CUSTOM_EDITOR_AUTO_OPEN_LS_KEY) === 'true'
93
+ if (!autoOpen) {
94
+ return ''
95
+ }
96
+ return safeGetItem(CUSTOM_EDITOR_COMMAND_LS_KEY).trim()
97
+ }
98
+
78
99
  doSubmit = () => {
79
100
  this.handleSubmit({
80
101
  text: this.state.text
@@ -3,17 +3,11 @@
3
3
  */
4
4
 
5
5
  import { memo } from 'react'
6
- import createName, { createTitleTag } from '../../common/create-title'
6
+ import { createTitleTag } from '../../common/create-title'
7
7
  import classnames from 'classnames'
8
8
  import highlight from '../common/highlight'
9
9
  import uid from '../../common/uid'
10
10
 
11
- function getItemLabel (item, isGroup) {
12
- return isGroup
13
- ? item?.title || ''
14
- : createName(item)
15
- }
16
-
17
11
  function areEqual (prevProps, nextProps) {
18
12
  const prevSelected = prevProps.selectedItemId === prevProps.item.id
19
13
  const nextSelected = nextProps.selectedItemId === nextProps.item.id
@@ -27,10 +21,10 @@ function areEqual (prevProps, nextProps) {
27
21
  prevSelected === nextSelected &&
28
22
  prevSearchSelected === nextSearchSelected &&
29
23
  prevProps.item.id === nextProps.item.id &&
30
- prevProps.item.level === nextProps.item.level &&
31
- prevProps.item.color === nextProps.item.color &&
32
- prevProps.item.description === nextProps.item.description &&
33
- getItemLabel(prevProps.item, prevProps.isGroup) === getItemLabel(nextProps.item, nextProps.isGroup)
24
+ prevProps.itemLevel === nextProps.itemLevel &&
25
+ prevProps.itemColor === nextProps.itemColor &&
26
+ prevProps.itemDescription === nextProps.itemDescription &&
27
+ prevProps.itemLabel === nextProps.itemLabel
34
28
  }
35
29
 
36
30
  function TreeListItem (props) {
@@ -87,7 +81,7 @@ function TreeListItem (props) {
87
81
  : null
88
82
  const title = isGroup
89
83
  ? item.title
90
- : createName(item)
84
+ : props.itemLabel
91
85
  const titleAll = title + (item.description ? ' - ' + item.description : '')
92
86
  const titleHighlight = isGroup
93
87
  ? item.title || 'no title'
@@ -2,6 +2,7 @@ import TreeExpander from './tree-expander'
2
2
  import TreeListItem from './tree-list-item'
3
3
  import TreeItemOp from './tree-item-op'
4
4
  import { treeLevelIndent } from './tree-list-layout'
5
+ import createName from '../../common/create-title'
5
6
 
6
7
  export default function TreeListRow (props) {
7
8
  const {
@@ -38,6 +39,10 @@ export default function TreeListRow (props) {
38
39
  item,
39
40
  isGroup,
40
41
  parentId,
42
+ itemLabel: isGroup ? (item?.title || '') : createName(item),
43
+ itemColor: item?.color,
44
+ itemDescription: item?.description,
45
+ itemLevel: item?.level,
41
46
  leftSidebarWidth,
42
47
  staticList,
43
48
  selectedItemId: activeItemId,
@@ -26,7 +26,9 @@ export function buildVisibleTreeRows ({
26
26
  const item = bookmarksMap.get(bookmarkId)
27
27
  const matched = Boolean(
28
28
  item &&
29
- (!lowerKeyword || createName(item).toLowerCase().includes(lowerKeyword))
29
+ (!lowerKeyword ||
30
+ createName(item).toLowerCase().includes(lowerKeyword) ||
31
+ (item.description || '').toLowerCase().includes(lowerKeyword))
30
32
  )
31
33
  bookmarkMatchCache.set(bookmarkId, matched)
32
34
  return matched
@@ -50,6 +50,9 @@ export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInsta
50
50
  case 'string':
51
51
  control = <Input placeholder={description} />
52
52
  break
53
+ case 'textarea':
54
+ control = <Input.TextArea autoSize={{ minRows: 3 }} placeholder={description} />
55
+ break
53
56
  case 'number':
54
57
  control = <InputNumber style={{ width: '100%' }} placeholder={description} />
55
58
  break
@@ -5,7 +5,7 @@
5
5
 
6
6
  import uid from '../common/uid'
7
7
  import { settingMap } from '../common/constants'
8
- import { refs } from '../components/common/ref'
8
+ import { refs, refsTabs } from '../components/common/ref'
9
9
  import deepCopy from 'json-deep-copy'
10
10
  import {
11
11
  getLocalFileInfo,
@@ -107,6 +107,9 @@ export default Store => {
107
107
  case 'get_terminal_output':
108
108
  result = store.mcpGetTerminalOutput(args)
109
109
  break
110
+ case 'wait_for_terminal_idle':
111
+ result = await store.mcpWaitForTerminalIdle(args)
112
+ break
110
113
 
111
114
  // SFTP operations
112
115
  case 'sftp_list':
@@ -331,15 +334,18 @@ export default Store => {
331
334
 
332
335
  Store.prototype.mcpListTabs = function () {
333
336
  const { store } = window
334
- return store.tabs.map(t => ({
335
- id: t.id,
336
- title: t.title,
337
- host: t.host,
338
- type: t.type || 'local',
339
- status: t.status,
340
- isTransporting: t.isTransporting,
341
- batch: t.batch
342
- }))
337
+ return store.tabs.map(t => {
338
+ return {
339
+ id: t.id,
340
+ title: t.title,
341
+ host: t.host,
342
+ type: t.type || 'local',
343
+ status: t.status,
344
+ isTransporting: t.isTransporting,
345
+ onData: refsTabs.get('tab-' + t.id)?.state.terminalOnData,
346
+ batch: t.batch
347
+ }
348
+ })
343
349
  }
344
350
 
345
351
  Store.prototype.mcpGetActiveTab = function () {
@@ -527,6 +533,73 @@ export default Store => {
527
533
  }
528
534
  }
529
535
 
536
+ Store.prototype.mcpWaitForTerminalIdle = async function (args) {
537
+ const { store } = window
538
+ const tabId = args.tabId || store.activeTabId
539
+ const timeout = Math.min(args.timeout || 30000, 120000)
540
+ const pollInterval = 500
541
+ const minWait = args.minWait !== undefined ? args.minWait : 1000
542
+ const lineCountToFetch = args.lines || 50
543
+
544
+ if (!tabId) {
545
+ throw new Error('No active terminal')
546
+ }
547
+
548
+ const start = Date.now()
549
+
550
+ // Brief initial wait so the command has time to start producing output
551
+ if (minWait > 0) {
552
+ await new Promise(resolve => setTimeout(resolve, minWait))
553
+ }
554
+
555
+ const collectOutput = () => {
556
+ const term = refs.get('term-' + tabId)
557
+ if (!term || !term.term) return { output: '', lineCount: 0 }
558
+ const buffer = term.term.buffer.active
559
+ if (!buffer) return { output: '', lineCount: 0 }
560
+ const cursorY = buffer.cursorY || 0
561
+ const baseY = buffer.baseY || 0
562
+ const totalLines = buffer.length || 0
563
+ const actualContentEnd = baseY + cursorY + 1
564
+ const startLine = Math.max(0, actualContentEnd - lineCountToFetch)
565
+ const endLine = Math.min(totalLines, actualContentEnd)
566
+ const lines = []
567
+ for (let i = startLine; i < endLine; i++) {
568
+ const line = buffer.getLine(i)
569
+ if (line) lines.push(line.translateToString(true))
570
+ }
571
+ return { output: lines.join('\n'), lineCount: lines.length }
572
+ }
573
+
574
+ // Poll until onData becomes false (4s idle debounce in tab.jsx)
575
+ while (Date.now() - start < timeout) {
576
+ const tabRef = refsTabs.get('tab-' + tabId)
577
+ const onData = tabRef?.state.terminalOnData
578
+ if (!onData) {
579
+ const { output, lineCount } = collectOutput()
580
+ return {
581
+ tabId,
582
+ elapsed: Date.now() - start,
583
+ timedOut: false,
584
+ output,
585
+ lineCount
586
+ }
587
+ }
588
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
589
+ }
590
+
591
+ // Timeout reached — return whatever is currently in the buffer
592
+ const { output, lineCount } = collectOutput()
593
+ return {
594
+ tabId,
595
+ elapsed: Date.now() - start,
596
+ timedOut: true,
597
+ message: `Terminal still active after ${timeout}ms`,
598
+ output,
599
+ lineCount
600
+ }
601
+ }
602
+
530
603
  // ==================== Settings APIs ====================
531
604
 
532
605
  Store.prototype.mcpGetSettings = function () {
@@ -622,6 +622,20 @@ export default Store => {
622
622
  }
623
623
  }
624
624
 
625
+ Store.prototype.notifyTabPasswordPrompt = function (tabId) {
626
+ const tab = refsTabs.get('tab-' + tabId)
627
+ if (tab) {
628
+ tab.notifyPasswordPrompt()
629
+ }
630
+ }
631
+
632
+ Store.prototype.clearTabPasswordPrompt = function (tabId) {
633
+ const tab = refsTabs.get('tab-' + tabId)
634
+ if (tab) {
635
+ tab.clearPasswordPrompt()
636
+ }
637
+ }
638
+
625
639
  Store.prototype.remoteList = function (tabId) {
626
640
  const sftp = refs.get('sftp-' + tabId)
627
641
  if (sftp) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.9.5",
3
+ "version": "3.10.0",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",