@electerm/electerm-react 5.5.35 → 5.5.66

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 (54) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/common/constants.js +1 -0
  3. package/client/common/dangerous-session-fields.js +39 -0
  4. package/client/common/parse-quick-connect.js +47 -6
  5. package/client/common/parse-widget-log.js +57 -0
  6. package/client/common/safe-join.js +33 -0
  7. package/client/common/sanitize-filename.js +8 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +7 -3
  9. package/client/components/ai/agent.js +176 -33
  10. package/client/components/ai/ai-auto-compress.js +119 -0
  11. package/client/components/ai/ai-chat-history-item.jsx +125 -40
  12. package/client/components/ai/ai-chat.jsx +67 -19
  13. package/client/components/ai/ai-compress.js +95 -0
  14. package/client/components/ai/ai-config.jsx +29 -16
  15. package/client/components/ai/ai-config.styl +20 -0
  16. package/client/components/ai/ai-context.js +1 -1
  17. package/client/components/ai/ai-output.jsx +1 -6
  18. package/client/components/ai/ai-presets.js +0 -15
  19. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  20. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  21. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  22. package/client/components/bookmark-form/config/ssh.js +1 -0
  23. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  24. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  25. package/client/components/file-transfer/transfer.jsx +3 -119
  26. package/client/components/main/main.jsx +1 -0
  27. package/client/components/session/session-control.jsx +35 -2
  28. package/client/components/session/session-control.styl +5 -0
  29. package/client/components/session/session.jsx +13 -0
  30. package/client/components/setting-panel/setting-terminal.jsx +1 -0
  31. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  32. package/client/components/sftp/file-item.jsx +0 -13
  33. package/client/components/terminal/mixins/term-init.js +1 -0
  34. package/client/components/terminal/mixins/term-touch.js +18 -0
  35. package/client/components/terminal/shell.js +11 -3
  36. package/client/components/terminal/shortcut-bar-press.js +192 -0
  37. package/client/components/terminal/shortcut-bar.jsx +199 -4
  38. package/client/components/terminal/shortcut-bar.styl +40 -21
  39. package/client/components/terminal/terminal.jsx +4 -0
  40. package/client/components/widgets/widget-control.jsx +4 -2
  41. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  42. package/client/components/widgets/widget-instance.jsx +30 -13
  43. package/client/components/widgets/widget-instances.jsx +3 -1
  44. package/client/components/widgets/widgets-list.jsx +8 -0
  45. package/client/components/widgets/widgets.styl +100 -0
  46. package/client/store/common.js +81 -23
  47. package/client/store/init-state.js +7 -0
  48. package/client/store/load-data.js +1 -0
  49. package/client/store/mcp-handler.js +8 -15
  50. package/client/store/sync.js +1 -0
  51. package/client/store/tab.js +2 -15
  52. package/client/store/widgets.js +23 -5
  53. package/package.json +1 -1
  54. package/client/components/file-transfer/zip.js +0 -42
@@ -5,11 +5,11 @@ import {
5
5
  Button,
6
6
  AutoComplete,
7
7
  Alert,
8
- Space,
9
- Dropdown
8
+ Select,
9
+ Space
10
10
  } from 'antd'
11
11
  import { useEffect, useMemo, useState } from 'react'
12
- import { DownOutlined, ReloadOutlined } from '@ant-design/icons'
12
+ import { ReloadOutlined } from '@ant-design/icons'
13
13
  import Link from '../common/external-link'
14
14
  import AiCache from './ai-cache'
15
15
  import {
@@ -20,6 +20,7 @@ import AiHistory, { addHistoryItem } from './ai-history'
20
20
  import message from '../common/message'
21
21
  import { getAIPresets } from './ai-presets'
22
22
  import { appendMandatoryGuardrails } from './ai-guardrails'
23
+ import './ai-config.styl'
23
24
 
24
25
  const STORAGE_KEY_CONFIG = 'ai_config_history'
25
26
  const EVENT_NAME_CONFIG = 'ai-config-history-update'
@@ -211,18 +212,32 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
211
212
  form.setFieldsValue(values)
212
213
  }
213
214
 
214
- function renderPresetMenu () {
215
- const items = presets.map(p => ({
216
- key: p.id,
217
- label: p.nameAI,
218
- onClick: () => handleSelectPreset(p)
215
+ // A plain antd Select, on purpose: it already scrolls its list inside the
216
+ // viewport (a Dropdown menu grows past the bottom of a phone screen, so the
217
+ // tail of the 18 presets is unreachable there) and `showSearch` filters it.
218
+ // `optionFilterProp` is load-bearing: without it rc-select filters on the
219
+ // option *value* (our id), so "gemini" or "llama" match nothing. The label
220
+ // carries name and model, so filtering on it finds either.
221
+ // The label/select sizing lives in `.ai-preset-row` (ai-config.styl), because
222
+ // Space's `.ant-space-item` wrapper is the flex item, not the child.
223
+ function renderPresetSelect () {
224
+ const options = presets.map(p => ({
225
+ value: p.id,
226
+ label: p.modelAI ? `${p.nameAI} · ${p.modelAI}` : p.nameAI
219
227
  }))
220
228
  return (
221
- <Dropdown menu={{ items }} trigger={['click']}>
222
- <Button>
223
- {e('presets')} <DownOutlined />
224
- </Button>
225
- </Dropdown>
229
+ <div className='alignright'>
230
+ <Space className='mg1b width-100 ai-preset-row'>
231
+ <span>{e('presets')}</span>
232
+ <Select
233
+ showSearch
234
+ optionFilterProp='label'
235
+ options={options}
236
+ value={currentPreset?.id}
237
+ onSelect={id => handleSelectPreset(presets.find(p => p.id === id))}
238
+ />
239
+ </Space>
240
+ </div>
226
241
  )
227
242
  }
228
243
 
@@ -294,9 +309,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
294
309
  type='warning'
295
310
  className='mg2b'
296
311
  />
297
- <div className='mg1b alignright'>
298
- {renderPresetMenu()}
299
- </div>
312
+ {renderPresetSelect()}
300
313
  <p>
301
314
  Full Url: {initialValues?.baseURLAI}{initialValues?.apiPathAI}
302
315
  </p>
@@ -0,0 +1,20 @@
1
+ // Preset row: the "Presets" label and the provider select share one line.
2
+ // antd's Space wraps every child in a `.ant-space-item`, and that wrapper — not
3
+ // the child — is the flex item, so the flex behaviour has to be set on the
4
+ // wrappers. Put `flex` on the Select itself and it is silently ignored, which
5
+ // makes the row's overflow land on the label: at 480px wide the label is
6
+ // squeezed into a 17px column and wraps onto four lines.
7
+ .ai-preset-row
8
+ max-width 400px
9
+ .ant-space-item:first-child
10
+ // the label keeps its full text width and never wraps or shrinks
11
+ flex none
12
+ white-space nowrap
13
+
14
+ .ant-space-item:last-child
15
+ // the select takes the rest of the line, shrinking to nothing if it must
16
+ flex 1
17
+ min-width 0
18
+
19
+ .ant-select
20
+ width 100%
@@ -23,7 +23,7 @@ const CJK_RE = /[\u2e80-\u2eff\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9f
23
23
 
24
24
  export const DEFAULT_CONTEXT_WINDOW = 128000
25
25
  export const CONTEXT_WARN_PERCENT = 70
26
- export const CONTEXT_DANGER_PERCENT = 90
26
+ export const CONTEXT_DANGER_PERCENT = 80
27
27
 
28
28
  // Rough token count of a string. Never exact, never wildly wrong. Called on
29
29
  // the whole conversation every time it changes, so it stays a single pass
@@ -98,12 +98,7 @@ export default function AIOutput ({ item }) {
98
98
  }
99
99
 
100
100
  function handleToggleFlag () {
101
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
102
- if (index === -1) {
103
- return
104
- }
105
- window.store.aiChatHistory[index].flagged = !window.store.aiChatHistory[index].flagged
106
- window.store.aiChatHistory = [...window.store.aiChatHistory]
101
+ window.store.updateAiHistoryEntry(item.id, { flagged: !item.flagged })
107
102
  }
108
103
 
109
104
  function renderFlag () {
@@ -67,21 +67,6 @@ export const defaultAIPresets = [
67
67
  authHeaderNameAI: 'Authorization: Bearer',
68
68
  siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
69
69
  },
70
- {
71
- id: 'openai-responses',
72
- nameAI: 'OpenAI (Responses API)',
73
- baseURLAI: 'https://api.openai.com/v1',
74
- apiPathAI: '/responses',
75
- modelAI: 'gpt-4o',
76
- authHeaderNameAI: 'Authorization: Bearer',
77
- modelAIs: [
78
- { value: 'gpt-4o' },
79
- { value: 'gpt-4o-mini' },
80
- { value: 'gpt-4.1' },
81
- { value: 'o4-mini' }
82
- ],
83
- siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
84
- },
85
70
  {
86
71
  id: 'anthropic',
87
72
  nameAI: 'Anthropic Claude',
@@ -172,6 +172,7 @@ export default class BatchOpRunner extends Component {
172
172
  enableSsh: p.enableSsh !== false,
173
173
  useSshAgent: p.useSshAgent !== false,
174
174
  sshAgent: p.sshAgent || '',
175
+ agentForward: p.agentForward || false,
175
176
  term: p.term || 'xterm-256color',
176
177
  encode: p.encode || 'utf8',
177
178
  envLang: p.envLang || 'en_US.UTF-8',
@@ -20,6 +20,7 @@ const bookmarkSchema = {
20
20
  connectionHoppings: 'array - connection hopping definitions',
21
21
  useSshAgent: 'boolean - use SSH agent, default is true',
22
22
  sshAgent: 'string - ssh agent path',
23
+ agentForward: 'boolean - enable SSH agent forwarding (like ssh -A, needed to hop through a jump/bastion host), requires SSH agent, default is false',
23
24
  serverHostKey: 'array - server host key algorithms',
24
25
  cipher: 'array - cipher list',
25
26
  compress: 'array - compression algorithms (zlib@openssh.com, zlib, none)',
@@ -9,26 +9,36 @@ const e = window.translate
9
9
 
10
10
  export default function SshAgent () {
11
11
  return (
12
- <FormItem
13
- {...formItemLayout}
14
- label={e('useSshAgent')}
15
- >
16
- <Space align='center'>
17
- <FormItem
18
- name='useSshAgent'
19
- valuePropName='checked'
20
- noStyle
21
- >
22
- <SwitchLabel />
23
- </FormItem>
24
- <FormItem
25
- name='sshAgent'
26
- noStyle
27
- >
28
- <Input placeholder={e('SSH Agent Path')} />
29
- </FormItem>
30
- <HelpIcon link='https://github.com/electerm/electerm/wiki/ssh-agent' />
31
- </Space>
32
- </FormItem>
12
+ <>
13
+ <FormItem
14
+ {...formItemLayout}
15
+ label={e('useSshAgent')}
16
+ >
17
+ <Space align='center'>
18
+ <FormItem
19
+ name='useSshAgent'
20
+ valuePropName='checked'
21
+ noStyle
22
+ >
23
+ <SwitchLabel />
24
+ </FormItem>
25
+ <FormItem
26
+ name='sshAgent'
27
+ noStyle
28
+ >
29
+ <Input placeholder={e('SSH Agent Path')} />
30
+ </FormItem>
31
+ <HelpIcon link='https://github.com/electerm/electerm/wiki/ssh-agent' />
32
+ </Space>
33
+ </FormItem>
34
+ <FormItem
35
+ {...formItemLayout}
36
+ label={e('agentForward')}
37
+ name='agentForward'
38
+ valuePropName='checked'
39
+ >
40
+ <SwitchLabel />
41
+ </FormItem>
42
+ </>
33
43
  )
34
44
  }
@@ -22,6 +22,7 @@ const sshConfig = {
22
22
  connectionHoppings: [],
23
23
  useSshAgent: true,
24
24
  sshAgent: '',
25
+ agentForward: false,
25
26
  serverHostKey: [],
26
27
  cipher: [],
27
28
  compress: [],
@@ -6,6 +6,7 @@ const defaultValues = {
6
6
  enableSsh: true,
7
7
  enableSftp: true,
8
8
  useSshAgent: true,
9
+ agentForward: false,
9
10
  x11: false,
10
11
  term: 'xterm-256color',
11
12
  displayRaw: false,
@@ -1,5 +1,5 @@
1
1
  import { Component } from 'react'
2
- import resolve from '../../common/resolve'
2
+ import safeJoin from '../../common/safe-join'
3
3
  import { typeMap } from '../../common/constants'
4
4
  import { refsStatic } from '../common/ref'
5
5
  import Remote2RemoteHandler from './remote2remote-handler'
@@ -35,7 +35,10 @@ export default class Remote2RemoteHandlers extends Component {
35
35
  createHandler = ({ fromFile, targetPathBase, targetTab }) => {
36
36
  const handler = new Remote2RemoteHandler({
37
37
  fromFile,
38
- toPath: resolve(targetPathBase, fromFile.name),
38
+ // `fromFile.name` comes straight off the source server's READDIR, so it
39
+ // is attacker-controlled. It is written to the target host as the victim
40
+ // below, so it must not be able to leave `targetPathBase`.
41
+ toPath: safeJoin(targetPathBase, fromFile.name),
39
42
  sourceHost: fromFile.host,
40
43
  sourceTabId: fromFile.tabId,
41
44
  title: fromFile.title,
@@ -58,7 +61,9 @@ export default class Remote2RemoteHandlers extends Component {
58
61
  }
59
62
 
60
63
  onRemote2RemoteDrop = ({ fromFiles, toFile, targetTab }) => {
61
- const targetPathBase = resolve(toFile.path, toFile.name)
64
+ // `toFile.name` is an entry from the target panel's listing. The victim
65
+ // chose the panel, but the name is still server-supplied.
66
+ const targetPathBase = safeJoin(toFile.path, toFile.name)
62
67
  const targetHost = targetTab?.host
63
68
  let handled = false
64
69
  for (const fromFile of fromFiles) {
@@ -12,13 +12,6 @@ import {
12
12
  import resolve from '../../common/resolve'
13
13
  import sanitizeFilename from '../../common/sanitize-filename'
14
14
  import { refsTransfers, refsStatic, refs } from '../common/ref'
15
- import {
16
- zipCmd,
17
- unzipCmd,
18
- rmCmd,
19
- mvCmd,
20
- mkdirCmd
21
- } from './zip'
22
15
  import './transfer.styl'
23
16
 
24
17
  const { assign } = Object
@@ -279,9 +272,7 @@ export default class TransportAction extends Component {
279
272
  typeFrom,
280
273
  toFile = {}
281
274
  } = transfer
282
- const toPath = transfer.zip
283
- ? transfer.toPath
284
- : this.newPath || transfer.toPath
275
+ const toPath = this.newPath || transfer.toPath
285
276
  const fromFile = transfer.fromFile || this.fromFile
286
277
  const fromMode = fromFile.mode
287
278
  const transferType = typeFrom === typeMap.local ? transferTypeMap.upload : transferTypeMap.download
@@ -408,12 +399,11 @@ export default class TransportAction extends Component {
408
399
  toPath
409
400
  } = this.props.transfer
410
401
  this.oldPath = toPath
411
- const { newPath, newName } = this.handleRename(toPath, typeTo === typeMap.remote)
402
+ const { newPath } = this.handleRename(toPath, typeTo === typeMap.remote)
412
403
  this.update({
413
404
  toPath: newPath
414
405
  })
415
406
  this.newPath = newPath
416
- this.newName = newName
417
407
  }
418
408
 
419
409
  const { typeFrom, typeTo } = this.props.transfer
@@ -423,120 +413,14 @@ export default class TransportAction extends Component {
423
413
  this.startTransfer()
424
414
  }
425
415
 
426
- zipTransferFolder = async () => {
427
- const {
428
- transfer
429
- } = this.props
430
- const {
431
- fromPath,
432
- typeFrom
433
- } = transfer
434
- const toPath = this.oldPath || transfer.toPath
435
- let p
436
- let isFromRemote
437
- if (typeFrom === typeMap.local) {
438
- isFromRemote = false
439
- p = await window.fs.zipFolder(fromPath)
440
- } else {
441
- isFromRemote = true
442
- const terminalId = refs.get('sftp-' + this.tabId)?.terminalId
443
- p = await zipCmd(terminalId, fromPath)
444
- }
445
- this.zipSrc = p
446
- const { name } = getFolderFromFilePath(p, isFromRemote)
447
- const { path } = getFolderFromFilePath(toPath, !isFromRemote)
448
- const nTo = resolve(path, name)
449
- this.zipPath = nTo
450
- const newTrans1 = {
451
- ...copy(transfer),
452
- toPath: nTo,
453
- fromPath: p
454
- }
455
- this.transferFile(newTrans1, this.unzipFile)
456
- }
457
-
458
- unzipFile = async () => {
459
- const { transfer } = this.props
460
- const {
461
- typeTo
462
- } = transfer
463
- const toPath = this.zipPath
464
- const fromPath = this.zipSrc
465
- const isToRemote = typeTo === typeMap.remote
466
- const {
467
- path,
468
- name,
469
- targetPath
470
- } = this.buildUnzipPath(transfer)
471
- const {
472
- newName,
473
- terminalId
474
- } = this
475
- if (isToRemote) {
476
- if (newName) {
477
- await mkdirCmd(terminalId, path)
478
- }
479
- await unzipCmd(terminalId, toPath, path)
480
- if (newName) {
481
- const mvFrom = resolve(path, name)
482
- const mvTo = resolve(targetPath, newName)
483
- await mvCmd(terminalId, mvFrom, mvTo)
484
- }
485
- } else {
486
- if (newName) {
487
- await window.fs.mkdir(path)
488
- }
489
- await window.fs.unzipFile(toPath, path)
490
- if (newName) {
491
- const mvFrom = resolve(path, name)
492
- const mvTo = resolve(targetPath, newName)
493
- await window.fs.mv(mvFrom, mvTo)
494
- }
495
- }
496
- await rmCmd(terminalId, !isToRemote ? fromPath : toPath)
497
- await window.fs.rmrf(!isToRemote ? toPath : fromPath)
498
- if (newName) {
499
- if (isToRemote) {
500
- await rmCmd(terminalId, path)
501
- } else {
502
- await window.fs.rmrf(path)
503
- }
504
- }
505
- this.onEnd()
506
- }
507
-
508
- buildUnzipPath = (transfer) => {
509
- const {
510
- typeTo
511
- } = transfer
512
- const isToRemote = typeTo === typeMap.remote
513
- const toPath = this.oldPath || transfer.toPath
514
- const {
515
- newName
516
- } = this
517
- const { path } = getFolderFromFilePath(toPath, isToRemote)
518
- const oldName = getFolderFromFilePath(toPath, isToRemote).name
519
- const np = newName
520
- ? resolve(path, 'temp-' + newName)
521
- : path
522
- return {
523
- targetPath: path,
524
- path: np,
525
- name: oldName
526
- }
527
- }
528
-
529
416
  startTransfer = async () => {
530
- const { fromFile = this.fromFile, zip } = this.props.transfer
417
+ const { fromFile = this.fromFile } = this.props.transfer
531
418
  if (!fromFile) {
532
419
  return
533
420
  }
534
421
  if (!fromFile.isDirectory) {
535
422
  return this.transferFile()
536
423
  }
537
- if (zip) {
538
- return this.zipTransferFolder()
539
- }
540
424
  if (!this.isFtp) {
541
425
  return this.transferFile()
542
426
  } else {
@@ -289,6 +289,7 @@ export default auto(function Index (props) {
289
289
  rightPanelTab,
290
290
  agentRunning: store.agentRunning,
291
291
  aiContextInfo: store.aiContextInfo,
292
+ aiAutoCompress: store.aiAutoCompress,
292
293
  currentChatSessionId: store.currentChatSessionId,
293
294
  showChatSessions: store.showChatSessions
294
295
  }
@@ -8,6 +8,7 @@ import {
8
8
  PaperClipOutlined,
9
9
  ApartmentOutlined,
10
10
  MoreOutlined,
11
+ MenuOutlined,
11
12
  ColumnWidthOutlined,
12
13
  LogoutOutlined,
13
14
  PlusOutlined,
@@ -39,6 +40,7 @@ export default function SessionControl (props) {
39
40
  keepaliveEnabled,
40
41
  broadcastInput,
41
42
  wrapDisabled,
43
+ pasteWhenContextMenu,
42
44
  onChangePane,
43
45
  toggleCheckSftpPathFollowSsh,
44
46
  onSshSftpSplitView,
@@ -47,6 +49,7 @@ export default function SessionControl (props) {
47
49
  toggleWrap,
48
50
  onFullscreen,
49
51
  onOpenSearch,
52
+ onOpenContextMenu,
50
53
  onExitGracefully,
51
54
  onZoomFontSize
52
55
  } = props
@@ -279,7 +282,35 @@ export default function SessionControl (props) {
279
282
  )
280
283
  }
281
284
 
282
- function renderTermControls () {
285
+ /**
286
+ * Touch devices have no right click, so the terminal's context menu needs a
287
+ * visible way in: this icon, next to the fullscreen icon on the desktop
288
+ * toolbar and next to the three-dot toggle in the mobile one (see below).
289
+ * Hidden when there is no menu to open — `pasteWhenContextMenu` turns both
290
+ * right click and the long press into a paste (see terminal.jsx), and it
291
+ * only ever acts on the terminal pane.
292
+ */
293
+ function renderContextMenuIcon (inMobileBar) {
294
+ if (
295
+ !isTouchDevice ||
296
+ pasteWhenContextMenu ||
297
+ tab.pane !== paneMap.terminal
298
+ ) {
299
+ return null
300
+ }
301
+ const cls = classnames(
302
+ 'iblock pointer spliter context-menu-control-icon',
303
+ inMobileBar ? 'mobile-context-menu-toggle' : 'mg1r icon-info'
304
+ )
305
+ return (
306
+ <MenuOutlined
307
+ className={cls}
308
+ onClick={onOpenContextMenu}
309
+ />
310
+ )
311
+ }
312
+
313
+ function renderTermControls (skipContextMenuIcon) {
283
314
  const { pane } = tab
284
315
  if (pane !== paneMap.terminal) {
285
316
  return null
@@ -287,6 +318,7 @@ export default function SessionControl (props) {
287
318
  return (
288
319
  <div className='fright term-controls'>
289
320
  {renderFontSizeIcons()}
321
+ {skipContextMenuIcon ? null : renderContextMenuIcon()}
290
322
  {renderFullscreenIcon()}
291
323
  {renderSearchIcon()}
292
324
  </div>
@@ -303,12 +335,13 @@ export default function SessionControl (props) {
303
335
  {renderBroadcastIcon()}
304
336
  {renderWrapIcon()}
305
337
  {renderExitGracefullyIcon()}
306
- {renderTermControls()}
338
+ {renderTermControls(true)}
307
339
  </div>
308
340
  )
309
341
  return (
310
342
  <div className='terminal-control mobile-session-control'>
311
343
  {renderPaneControl()}
344
+ {renderContextMenuIcon(true)}
312
345
  <Popover
313
346
  content={extraIcons}
314
347
  trigger='click'
@@ -14,6 +14,7 @@
14
14
  flex 1
15
15
  min-width 0
16
16
  .mobile-control-toggle
17
+ .mobile-context-menu-toggle
17
18
  font-size 16px
18
19
  line-height 32px
19
20
  color var(--text)
@@ -21,6 +22,10 @@
21
22
  &:hover
22
23
  color var(--text-light)
23
24
 
25
+ // touch-only terminal context menu icon, in the bar next to the toggle above
26
+ .mobile-context-menu-toggle
27
+ margin-right 10px
28
+
24
29
  // Popover content — icons laid out in a row, wrapping as needed
25
30
  .mobile-control-icons
26
31
  display flex
@@ -480,6 +480,17 @@ export default class SessionWrapper extends Component {
480
480
  refs.get('term-' + this.props.tab.id)?.toggleSearch()
481
481
  }
482
482
 
483
+ // touch-only: the toolbar menu icon opens the same context menu a right
484
+ // click (or a long press) does, anchored just below the icon itself
485
+ handleOpenContextMenu = (evt) => {
486
+ const term = refs.get('term-' + this.props.tab.id)
487
+ if (!term) {
488
+ return
489
+ }
490
+ const rect = evt.currentTarget.getBoundingClientRect()
491
+ term.openContextMenuAtPoint(rect.left, rect.bottom)
492
+ }
493
+
483
494
  handleZoomFontSize = (v) => {
484
495
  const term = refs.get('term-' + this.props.tab.id)
485
496
  if (!term?.term) {
@@ -625,6 +636,7 @@ export default class SessionWrapper extends Component {
625
636
  keepaliveEnabled={this.state.keepaliveEnabled}
626
637
  broadcastInput={this.state.broadcastInput}
627
638
  wrapDisabled={this.state.wrapDisabled}
639
+ pasteWhenContextMenu={!!this.props.config.pasteWhenContextMenu}
628
640
  onChangePane={(pane) => this.onChangePane(pane)}
629
641
  toggleCheckSftpPathFollowSsh={this.toggleCheckSftpPathFollowSsh}
630
642
  onSshSftpSplitView={this.handleSshSftpSplitView}
@@ -634,6 +646,7 @@ export default class SessionWrapper extends Component {
634
646
  onFullscreen={this.handleFullscreen}
635
647
  onZoomFontSize={this.handleZoomFontSize}
636
648
  onOpenSearch={this.handleOpenSearch}
649
+ onOpenContextMenu={this.handleOpenContextMenu}
637
650
  onExitGracefully={this.handleExitGracefully}
638
651
  />
639
652
  <Suspense fallback={null}>
@@ -569,6 +569,7 @@ export default class SettingTerminal extends Component {
569
569
  [
570
570
  'cursorBlink',
571
571
  'rightClickSelectsWord',
572
+ 'mouseEventsRequireAlt',
572
573
  'pasteWhenContextMenu',
573
574
  'copyWhenSelect',
574
575
  'disableConfirmForLargeClipboardContent',
@@ -1,11 +1,13 @@
1
1
  import SettingCol from './col'
2
2
  import WidgetControl from '../widgets/widget-control'
3
+ import WidgetInstanceDetail from '../widgets/widget-instance-detail'
3
4
  import WidgetList from '../widgets/widgets-list'
5
+ import { auto } from 'manate/react'
4
6
  import {
5
7
  settingMap
6
8
  } from '../../common/constants'
7
9
 
8
- export default function TabWidgets (props) {
10
+ export default auto(function TabWidgets (props) {
9
11
  const {
10
12
  settingTab
11
13
  } = props
@@ -15,8 +17,14 @@ export default function TabWidgets (props) {
15
17
  const {
16
18
  settingItem,
17
19
  listProps,
18
- formProps
20
+ formProps,
21
+ store
19
22
  } = props
23
+ // The right column shows whatever the list selected: a widget definition (run
24
+ // form) or a running instance (its detail + log). A definition has `info`, an
25
+ // instance has `widgetId` — and an instance that has been stopped stays the
26
+ // selected item on purpose, so its log is still readable after it dies.
27
+ const isInstance = !!(settingItem && settingItem.widgetId)
20
28
  return (
21
29
  <div
22
30
  className='setting-tabs-profile'
@@ -25,11 +33,23 @@ export default function TabWidgets (props) {
25
33
  <WidgetList
26
34
  {...listProps}
27
35
  />
28
- <WidgetControl
29
- {...formProps}
30
- key={settingItem.id}
31
- />
36
+ {
37
+ isInstance
38
+ ? (
39
+ <WidgetInstanceDetail
40
+ key={settingItem.id}
41
+ instance={settingItem}
42
+ store={store}
43
+ />
44
+ )
45
+ : (
46
+ <WidgetControl
47
+ {...formProps}
48
+ key={settingItem.id}
49
+ />
50
+ )
51
+ }
32
52
  </SettingCol>
33
53
  </div>
34
54
  )
35
- }
55
+ })
@@ -902,12 +902,6 @@ export default class FileSection extends React.Component {
902
902
  this.transfer()
903
903
  }
904
904
 
905
- zipAndTransfer = async () => {
906
- this.transfer(transfer => {
907
- transfer.zip = true
908
- })
909
- }
910
-
911
905
  newFile = () => {
912
906
  return this.newItem(false)
913
907
  }
@@ -1098,13 +1092,6 @@ export default class FileSection extends React.Component {
1098
1092
  icon: iconType,
1099
1093
  text: transferText
1100
1094
  })
1101
- // if (isDirectory && !this.props.isFtp) {
1102
- // res.push({
1103
- // func: 'zipAndTransfer',
1104
- // icon: 'FileZipOutlined',
1105
- // text: e('compressAndTransfer')
1106
- // })
1107
- // }
1108
1095
  }
1109
1096
  if (!isDirectory && isRealFile && isLocal) {
1110
1097
  res.push({