@electerm/electerm-react 5.5.56 → 5.5.76

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 (43) hide show
  1. package/client/common/constants.js +12 -0
  2. package/client/common/dangerous-session-fields.js +39 -0
  3. package/client/common/default-setting.js +1 -0
  4. package/client/common/distribute-tabs.js +42 -0
  5. package/client/common/import-task.js +46 -3
  6. package/client/common/parse-quick-connect.js +47 -6
  7. package/client/common/quick-connect-string.js +134 -0
  8. package/client/common/safe-join.js +33 -0
  9. package/client/common/sanitize-filename.js +8 -0
  10. package/client/common/ui-theme-color.mjs +136 -0
  11. package/client/common/update-check.js +14 -0
  12. package/client/components/ai/ai-output.jsx +5 -0
  13. package/client/components/ai/ai.styl +19 -0
  14. package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
  15. package/client/components/bookmark-form/form-renderer.jsx +13 -1
  16. package/client/components/bookmark-form/index.jsx +20 -1
  17. package/client/components/bookmark-form/render-form.jsx +2 -2
  18. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  19. package/client/components/main/ui-theme.jsx +1 -20
  20. package/client/components/main/upgrade.jsx +2 -0
  21. package/client/components/setting-panel/install-command.jsx +151 -0
  22. package/client/components/setting-panel/setting-common.jsx +5 -0
  23. package/client/components/setting-panel/setting-terminal.jsx +10 -1
  24. package/client/components/terminal/mixins/term-attach.js +10 -2
  25. package/client/components/terminal/mixins/term-init.js +1 -0
  26. package/client/components/terminal/mixins/term-socket.js +12 -2
  27. package/client/components/terminal/mixins/term-suggestions.js +16 -3
  28. package/client/components/terminal/osc52-addon.js +7 -2
  29. package/client/components/terminal/session-status-overlay.jsx +44 -0
  30. package/client/components/terminal/shell.js +11 -3
  31. package/client/components/terminal/shortcut-bar-press.js +192 -0
  32. package/client/components/terminal/shortcut-bar.jsx +199 -4
  33. package/client/components/terminal/shortcut-bar.styl +40 -21
  34. package/client/components/terminal/ssh-reload-state.js +41 -2
  35. package/client/components/terminal/startup-queue.js +6 -2
  36. package/client/components/terminal/terminal.jsx +17 -2
  37. package/client/components/terminal/terminal.styl +14 -5
  38. package/client/store/mcp-handler.js +8 -15
  39. package/client/store/sync.js +47 -18
  40. package/client/store/tab.js +47 -17
  41. package/client/store/watch.js +8 -0
  42. package/package.json +1 -1
  43. package/client/components/terminal/reconnect-overlay.jsx +0 -14
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Copy the bookmark form's current values: as a quick connect string, or as
3
+ * the raw JSON that lands in the config file.
4
+ */
5
+ import { useState } from 'react'
6
+ import { Dropdown } from 'antd'
7
+ import { CopyOutlined } from '@ant-design/icons'
8
+ import { copy } from '../../common/clipboard'
9
+ import { stringifyQuickConnect } from '../../common/quick-connect-string'
10
+
11
+ const e = window.translate
12
+
13
+ const maxLen = 50
14
+
15
+ function oneLine (str) {
16
+ const s = String(str || '').replace(/\s+/g, ' ').trim()
17
+ return s.length > maxLen ? s.slice(0, maxLen) + '...' : s
18
+ }
19
+
20
+ function label (name, value) {
21
+ return (
22
+ <span title={value}>
23
+ <b className='mg1r'>{name}</b>
24
+ <span>{oneLine(value)}</span>
25
+ <CopyOutlined className='mg1l' />
26
+ </span>
27
+ )
28
+ }
29
+
30
+ export default function CopyBookmark ({ getValues }) {
31
+ const [open, setOpen] = useState(false)
32
+ const [items, setItems] = useState([])
33
+
34
+ const getStrings = () => {
35
+ const values = getValues() || {}
36
+ return {
37
+ quickConnect: stringifyQuickConnect(values),
38
+ json: JSON.stringify(values, null, 2)
39
+ }
40
+ }
41
+
42
+ const onOpenChange = (next) => {
43
+ setOpen(next)
44
+ if (!next) {
45
+ return
46
+ }
47
+ const { quickConnect, json } = getStrings()
48
+ setItems([
49
+ {
50
+ key: 'quickConnect',
51
+ disabled: !quickConnect,
52
+ label: label(e('quickConnect'), quickConnect)
53
+ },
54
+ {
55
+ key: 'json',
56
+ label: label('JSON', json)
57
+ }
58
+ ])
59
+ }
60
+
61
+ const onClick = ({ key }) => {
62
+ const { quickConnect, json } = getStrings()
63
+ const value = key === 'json' ? json : quickConnect
64
+ if (value) {
65
+ copy(value)
66
+ }
67
+ setOpen(false)
68
+ }
69
+
70
+ return (
71
+ <Dropdown
72
+ menu={{ items, onClick }}
73
+ open={open}
74
+ onOpenChange={onOpenChange}
75
+ trigger={['click']}
76
+ placement='bottomRight'
77
+ >
78
+ <span className='pointer fright' title={e('copy')}>
79
+ <CopyOutlined />
80
+ </span>
81
+ </Dropdown>
82
+ )
83
+ }
@@ -22,7 +22,7 @@ import newTerm from '../../common/new-terminal'
22
22
  import { isValidIP } from '../../common/is-ip'
23
23
  import { action as manateAction } from 'manate'
24
24
 
25
- export default function FormRenderer ({ config, props }) {
25
+ export default function FormRenderer ({ config, props, onForm }) {
26
26
  const initialValues = config.initValues(props)
27
27
  const [form] = Form.useForm()
28
28
  const [ips, setIps] = useState([])
@@ -30,6 +30,18 @@ export default function FormRenderer ({ config, props }) {
30
30
  const [testing, setTesting] = useState(false)
31
31
  const action = useRef('submit')
32
32
 
33
+ // hand the form instance up so the header can read the current values
34
+ useEffect(() => {
35
+ if (onForm) {
36
+ onForm(form)
37
+ }
38
+ return () => {
39
+ if (onForm) {
40
+ onForm(null)
41
+ }
42
+ }
43
+ }, [onForm, form])
44
+
33
45
  useEffect(() => {
34
46
  const init = config.initValues(props)
35
47
  form.resetFields()
@@ -21,6 +21,7 @@ import { LoadingOutlined, BookOutlined, RobotOutlined } from '@ant-design/icons'
21
21
  import sessionConfig from './config/session-config'
22
22
  import renderForm from './render-form'
23
23
  import AIBookmarkForm from './ai-bookmark-form'
24
+ import CopyBookmark from './copy-bookmark'
24
25
  import { isAIDisabled } from '../../common/ai-feature'
25
26
  import './bookmark-form.styl'
26
27
 
@@ -80,6 +81,19 @@ export default class BookmarkIndex2 extends PureComponent {
80
81
  this.setState({ bookmarkType: e.target.value })
81
82
  }
82
83
 
84
+ // the form instance lives in FormRenderer, the copy menu lives in the header
85
+ handleFormReady = (form) => {
86
+ this.form = form
87
+ }
88
+
89
+ // same shape the form submits: saved bookmark + what is currently typed
90
+ getFormValues = () => {
91
+ return {
92
+ ...this.props.formData,
93
+ ...(this.form ? this.form.getFieldsValue() : {})
94
+ }
95
+ }
96
+
83
97
  handleCancelAiMode = () => {
84
98
  this.setState({ aiMode: false })
85
99
  }
@@ -147,12 +161,16 @@ export default class BookmarkIndex2 extends PureComponent {
147
161
  )
148
162
  }
149
163
 
164
+ renderCopy () {
165
+ return <CopyBookmark getValues={this.getFormValues} />
166
+ }
167
+
150
168
  renderForm () {
151
169
  const { bookmarkType, aiMode } = this.state
152
170
  if (aiMode) {
153
171
  return this.renderAiForm()
154
172
  }
155
- return renderForm(bookmarkType, this.props)
173
+ return renderForm(bookmarkType, this.props, this.handleFormReady)
156
174
  }
157
175
 
158
176
  render () {
@@ -180,6 +198,7 @@ export default class BookmarkIndex2 extends PureComponent {
180
198
  </span>
181
199
  {this.renderTitle(formData, isNew)}
182
200
  {this.renderAIButton(isNew)}
201
+ {this.renderCopy()}
183
202
  </p>
184
203
  {this.renderTypes(bookmarkType, isNew, keys)}
185
204
  </div>
@@ -5,7 +5,7 @@ import sessionConfig from './config/session-config'
5
5
  import { connectionMap } from '../../common/constants'
6
6
  import FormRenderer from './form-renderer'
7
7
 
8
- export default function renderForm (type, props) {
8
+ export default function renderForm (type, props, onForm) {
9
9
  const conf = sessionConfig[type] || sessionConfig[connectionMap.ssh]
10
- return <FormRenderer config={conf} props={props} />
10
+ return <FormRenderer config={conf} props={props} onForm={onForm} />
11
11
  }
@@ -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) {
@@ -5,29 +5,10 @@
5
5
  import { useEffect, useRef } from 'react'
6
6
  import eq from 'fast-deep-equal'
7
7
  import isColorDark from '../../common/is-color-dark'
8
+ import { darker } from '../../common/ui-theme-color.mjs'
8
9
 
9
10
  const themeDomId = 'theme-css'
10
11
 
11
- function darker (color, amount = 0.1) {
12
- let usePound = false
13
-
14
- if (color[0] === '#') {
15
- color = color.slice(1)
16
- usePound = true
17
- }
18
-
19
- const num = parseInt(color, 16)
20
-
21
- let r = (num >> 16) - Math.round(255 * amount)
22
- if (r < 0) r = 0
23
- let b = ((num >> 8) & 0x00FF) - Math.round(255 * amount)
24
- if (b < 0) b = 0
25
- let g = (num & 0x0000FF) - Math.round(255 * amount)
26
- if (g < 0) g = 0
27
-
28
- return (usePound ? '#' : '') + (g | (b << 8) | (r << 16)).toString(16)
29
- }
30
-
31
12
  function buildTheme (themeConfig) {
32
13
  const keys = Object.keys(themeConfig || {})
33
14
  const themeCss = keys.map(key => {
@@ -28,6 +28,7 @@ const {
28
28
  const downloadMirrorList = [
29
29
  'github',
30
30
  'gh-proxy',
31
+ 'atomgit',
31
32
  'sourceforge',
32
33
  'r2'
33
34
  ]
@@ -293,6 +294,7 @@ export default class Upgrade extends PureComponent {
293
294
  getPopupContainer={() => document.body}
294
295
  size='small'
295
296
  style={{ height: 32 }}
297
+ popupMatchSelectWidth={false}
296
298
  >
297
299
  {downloadMirrorList.map((opt) => (
298
300
  <Select.Option key={opt} value={opt}>{opt}</Select.Option>
@@ -0,0 +1,151 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { Button, Tag, Space, Tooltip } from 'antd'
3
+ import {
4
+ CheckCircleOutlined,
5
+ CloseCircleOutlined,
6
+ WarningOutlined
7
+ } from '@ant-design/icons'
8
+ import message from '../common/message'
9
+ import HelpIcon from '../common/help-icon'
10
+ import { installCommandHelpLink } from '../../common/constants'
11
+
12
+ // The row title and the two buttons come from electerm-locales
13
+ // (`command` / `install` / `uninstall`); the descriptive lines are still plain
14
+ // English and the maintainer adds keys for them as needed.
15
+ // window.translate is installed by entry/basic.js, so look it up at call time
16
+ // rather than capturing it when this module is evaluated.
17
+ const t = key => window.translate(key)
18
+
19
+ export default function InstallCommand () {
20
+ const [loading, setLoading] = useState(false)
21
+ const [status, setStatus] = useState(null)
22
+
23
+ const refresh = useCallback(async () => {
24
+ try {
25
+ const s = await window.pre.runGlobalAsync('getElectermCommandStatus')
26
+ setStatus(s)
27
+ } catch (err) {
28
+ console.error('Failed to read electerm command status', err)
29
+ }
30
+ }, [])
31
+
32
+ useEffect(() => {
33
+ refresh()
34
+ }, [refresh])
35
+
36
+ const run = async (name, okText) => {
37
+ setLoading(true)
38
+ try {
39
+ const res = await window.pre.runGlobalAsync(name)
40
+ if (res && res.ok) {
41
+ message.success(okText)
42
+ } else {
43
+ message.error((res && res.message) || 'Failed to update the electerm command')
44
+ }
45
+ await refresh()
46
+ } catch (err) {
47
+ message.error(err.message || String(err))
48
+ } finally {
49
+ setLoading(false)
50
+ }
51
+ }
52
+
53
+ const handleInstall = () =>
54
+ run('installElectermCommand', `electerm ${t('command')} installed`)
55
+ const handleUninstall = () =>
56
+ run('uninstallElectermCommand', `electerm ${t('command')} removed`)
57
+
58
+ if (!status) {
59
+ return null
60
+ }
61
+
62
+ const { installed, inPath, binDir, stale, blocked, unpackaged } = status
63
+ const needsPath = installed && inPath === false
64
+
65
+ // In a dev run process.execPath is Electron, so installing would point the
66
+ // command at Electron. Say so rather than offering a button that breaks it.
67
+ if (unpackaged) {
68
+ return null
69
+ }
70
+
71
+ return (
72
+ <div className='pd2b'>
73
+ <div className='pd1b'>
74
+ <Space size='small' wrap>
75
+ <span className='inline-title'>{t('install')} electerm {t('command')} to PATH</span>
76
+ <HelpIcon link={installCommandHelpLink} />
77
+ <Tag
78
+ variant='solid'
79
+ icon={installed ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
80
+ color={installed ? 'success' : 'default'}
81
+ >
82
+ {installed ? 'Installed' : (stale ? 'Outdated' : 'Not installed')}
83
+ </Tag>
84
+ {
85
+ blocked
86
+ ? (
87
+ <Tag variant='solid' color='error' icon={<WarningOutlined />}>
88
+ Blocked
89
+ </Tag>
90
+ )
91
+ : null
92
+ }
93
+ </Space>
94
+ </div>
95
+ {
96
+ stale
97
+ ? (
98
+ <div className='pd1b'>
99
+ <WarningOutlined className='mg1r' />
100
+ This command points somewhere else now. Install again to update it.
101
+ </div>
102
+ )
103
+ : null
104
+ }
105
+ {
106
+ binDir
107
+ ? (
108
+ <div className='pd1b'>
109
+ <Tooltip title={binDir}>
110
+ <span>{binDir}</span>
111
+ </Tooltip>
112
+ </div>
113
+ )
114
+ : null
115
+ }
116
+ {
117
+ needsPath
118
+ ? (
119
+ <div className='pd1b'>
120
+ <WarningOutlined className='mg1r' />
121
+ {binDir} is not in your PATH. Add it to PATH so the command can be found.
122
+ </div>
123
+ )
124
+ : null
125
+ }
126
+ <div className='deep-link-btns'>
127
+ <Button
128
+ type='primary'
129
+ onClick={handleInstall}
130
+ loading={loading}
131
+ >
132
+ {t('install')}
133
+ </Button>
134
+ {
135
+ installed || stale
136
+ ? (
137
+ <Button
138
+ color='danger'
139
+ variant='solid'
140
+ onClick={handleUninstall}
141
+ loading={loading}
142
+ >
143
+ {t('uninstall')}
144
+ </Button>
145
+ )
146
+ : null
147
+ }
148
+ </div>
149
+ </div>
150
+ )
151
+ }
@@ -33,6 +33,7 @@ import HelpIcon from '../common/help-icon'
33
33
  import delay from '../../common/wait.js'
34
34
  import isColorDark from '../../common/is-color-dark'
35
35
  import DeepLinkControl from './deep-link-control'
36
+ import InstallCommand from './install-command'
36
37
  import HotkeySetting from './hotkey'
37
38
  import SettingLeftSidebarIcons from './setting-left-sidebar-icons'
38
39
  import './setting.styl'
@@ -578,11 +579,15 @@ export default class SettingCommon extends Component {
578
579
  'disableTabIndex',
579
580
  'onlyShowTitleInTab',
580
581
  'disableShortcutBar',
582
+ 'autoDistributeTabsWhenLayoutChange',
581
583
  'debug'
582
584
  ]
583
585
  .filter(name => !isWebApp || !webAppHiddenSettings.includes(name))
584
586
  .map(this.renderToggle)
585
587
  }
588
+ {
589
+ window.et.isWebApp ? null : <InstallCommand />
590
+ }
586
591
  {
587
592
  window.et.isWebApp ? null : <DeepLinkControl />
588
593
  }
@@ -18,7 +18,8 @@ import {
18
18
  noTerminalBgValue,
19
19
  rendererTypes,
20
20
  regexHelpLink,
21
- terminalTypes
21
+ terminalTypes,
22
+ toggleWikiMap
22
23
  } from '../../common/constants'
23
24
  import defaultSettings from '../../common/default-setting'
24
25
  import ShowItem from '../common/show-item'
@@ -121,6 +122,8 @@ export default class SettingTerminal extends Component {
121
122
  renderToggle = (name, cls = 'pd2b', label) => {
122
123
  const checked = !!this.props.config[name]
123
124
  const txt = label || e(name)
125
+ // only toggles listed in toggleWikiMap get a help icon
126
+ const wiki = toggleWikiMap[name]
124
127
  return (
125
128
  <div className={cls} key={'rt' + name}>
126
129
  <SwitchLabel
@@ -128,6 +131,11 @@ export default class SettingTerminal extends Component {
128
131
  label={txt}
129
132
  onChange={v => this.onChangeValue(v, name)}
130
133
  />
134
+ {
135
+ wiki
136
+ ? <HelpIcon link={wiki} />
137
+ : null
138
+ }
131
139
  </div>
132
140
  )
133
141
  }
@@ -569,6 +577,7 @@ export default class SettingTerminal extends Component {
569
577
  [
570
578
  'cursorBlink',
571
579
  'rightClickSelectsWord',
580
+ 'mouseEventsRequireAlt',
572
581
  'pasteWhenContextMenu',
573
582
  'copyWhenSelect',
574
583
  'disableConfirmForLargeClipboardContent',
@@ -5,6 +5,7 @@ import AttachAddon from '../attach-addon-custom.js'
5
5
  import { createTriggerManager } from '../automation/index.js'
6
6
  import { isWin } from '../../../common/constants.js'
7
7
  import { isUnsafeFilename } from '../../../common/file-drop-utils.js'
8
+ import { createCdCommand } from '../ssh-reload-state.js'
8
9
 
9
10
  /**
10
11
  * Everything that writes to the session: the socket attach addon, encoding,
@@ -57,8 +58,15 @@ export const attachMixin = {
57
58
  if (isUnsafeFilename(p)) {
58
59
  return message.error('File name contains unsafe characters')
59
60
  }
60
- const isWinPath = /^[a-zA-Z]:\\/.test(p)
61
- this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
61
+ // Same shell resolution the session used: the bookmark/profile override
62
+ // first, then the global setting. `localShell` is what actually spawned.
63
+ const cmd = createCdCommand(p, {
64
+ remote: !!this.isRemote(),
65
+ shell: this.localShell || this.props.tab.execWindows || this.props.config.execWindows
66
+ })
67
+ if (cmd) {
68
+ this.runQuickCommand(cmd)
69
+ }
62
70
  },
63
71
 
64
72
  initTriggerManager () {
@@ -27,6 +27,7 @@ export const initMixin = {
27
27
  allowProposedApi: true,
28
28
  scrollback: config.scrollback,
29
29
  rightClickSelectsWord: config.rightClickSelectsWord || false,
30
+ mouseEventsRequireAlt: config.mouseEventsRequireAlt || false,
30
31
  fontFamily: tab.fontFamily || config.fontFamily,
31
32
  theme: tc,
32
33
  allowTransparency: true,
@@ -78,7 +78,8 @@ export const socketMixin = {
78
78
  async remoteInit (term = this.term) {
79
79
  this.setState({
80
80
  loading: true,
81
- terminalError: null
81
+ terminalError: null,
82
+ sessionStopped: false
82
83
  })
83
84
  const { cols, rows } = term
84
85
  const { config } = this.props
@@ -124,6 +125,10 @@ export const socketMixin = {
124
125
  }
125
126
  }
126
127
  const keepaliveInterval = tab.keepaliveInterval || config.keepaliveInterval
128
+ // Remember which executable this session actually spawned (profile- and
129
+ // bookmark-applied). `cd()` in term-attach needs the same answer to pick
130
+ // CMD vs PowerShell syntax; the global setting alone is not enough.
131
+ this.localShell = execOpts[execPropName] || ''
127
132
  const opts = clone({
128
133
  cols,
129
134
  rows,
@@ -188,6 +193,10 @@ export const socketMixin = {
188
193
  return
189
194
  }
190
195
  this.setStatus(statusMap.error)
196
+ // The pane is dead even though the socket never opened. The notice itself
197
+ // stays hidden while `terminalError` is set - the error alert is already
198
+ // the message.
199
+ this.setState({ sessionStopped: true })
191
200
  return
192
201
  }
193
202
  this.port = r.port
@@ -207,7 +216,7 @@ export const socketMixin = {
207
216
  // instead of leaving an unhandled rejection behind
208
217
  try {
209
218
  await this.initAttachAddon()
210
- this.startupQueue.runInitScript()
219
+ this.startupQueue.runInitScript(opts[execPropName])
211
220
  } catch (e) {
212
221
  console.error(e)
213
222
  this.handleError({ message: e.message })
@@ -319,6 +328,7 @@ export const socketMixin = {
319
328
  if (this.userTypeExit) {
320
329
  return this.props.delTab(this.props.tab.id)
321
330
  }
331
+ this.setState({ sessionStopped: true })
322
332
  const { autoReconnectTerminal } = this.props.config
323
333
  if (autoReconnectTerminal) {
324
334
  this.scheduleAutoReconnect(3000)
@@ -1,6 +1,13 @@
1
1
  import { debounce } from 'lodash-es'
2
2
  import { refsStatic } from '../../common/ref.js'
3
3
 
4
+ /**
5
+ * Lines that end the shell on purpose. `userTypeExit` decides whether a closed
6
+ * socket closes the tab straight away or shows the "session stopped" notice,
7
+ * so anything a user would type to leave has to be listed here.
8
+ */
9
+ const exitCommandReg = /^(?:exit|logout)(?:\s|$)/
10
+
4
11
  /**
5
12
  * Command suggestions: reading the current input straight from the terminal
6
13
  * buffer, positioning the dropdown at the cursor, and the password prompt
@@ -93,15 +100,21 @@ export const suggestionsMixin = {
93
100
  * The actual input reading is done via getCurrentInput from buffer
94
101
  */
95
102
  handleInputEvent (d) {
103
+ // Ctrl+D on an empty line is EOF, which ends the shell like `exit`. Typed
104
+ // anywhere else it is delete-char, so only an empty line counts.
105
+ if (d === '\x04') {
106
+ this.userTypeExit = !this.getCurrentInput().trim()
107
+ }
96
108
  // Handle Enter - add command to history
97
109
  if (d === '\r' || d === '\n') {
98
110
  const currentCmd = this.getCurrentInput()
99
111
  if (currentCmd && currentCmd.trim() && this.shouldUseManualHistory()) {
100
112
  window.store.addCmdHistory(currentCmd.trim())
101
113
  }
102
- if (currentCmd && currentCmd.trim() === 'exit') {
103
- this.userTypeExit = true
104
- }
114
+ // Recomputed on every submitted line rather than only ever set: an
115
+ // `exit` that did not actually end the shell must not silence the next
116
+ // real disconnect.
117
+ this.userTypeExit = !!currentCmd && exitCommandReg.test(currentCmd.trim())
105
118
  this.closeSuggestions()
106
119
  }
107
120
  },
@@ -64,8 +64,13 @@ export class Osc52Addon {
64
64
  const target = data.substring(0, semicolonIdx)
65
65
  const payload = data.substring(semicolonIdx + 1)
66
66
 
67
- // Only handle clipboard target ('c'), also accept 'c' among multiple targets
68
- if (!target.includes('c')) return false
67
+ // Only handle clipboard target ('c'), also accept 'c' among multiple targets.
68
+ // An EMPTY target is the spec's "default selection", i.e. the clipboard, and is
69
+ // what tmux sends: `set -g mouse on` + a mouse drag + wheel (to select across
70
+ // screens) makes tmux emit `ESC ] 52 ; ; <base64> BEL` on release. Rejecting an
71
+ // empty target silently drops that payload, so copying from tmux never reaches
72
+ // the system clipboard.
73
+ if (target && !target.includes('c')) return false
69
74
 
70
75
  if (payload === '?') {
71
76
  // Read request - send clipboard content back
@@ -0,0 +1,44 @@
1
+ import { memo } from 'react'
2
+
3
+ const e = window.translate
4
+
5
+ /**
6
+ * Shown over a dead terminal pane. Deliberately a DOM overlay and not
7
+ * `term.write()`: anything written into the buffer is captured by
8
+ * `socketMixin.getReloadState()` (serializeAddon + alternate-buffer snapshot)
9
+ * and replayed into the *next* session after a restart, and it would also land
10
+ * in the terminal log file and in copy selections.
11
+ *
12
+ * It only *names* shortcuts that already act on a dead session - reload (which
13
+ * restarts it) and close - so there is nothing new to learn, no key handling
14
+ * here, and no new locale strings. Both states share the component, so a user
15
+ * with auto reconnect on never sees a countdown and a notice at the same time.
16
+ */
17
+ export default memo(function SessionStatusOverlay ({
18
+ stopped,
19
+ countdown,
20
+ reloadShortcut,
21
+ closeShortcut
22
+ }) {
23
+ const reconnecting = countdown !== null && countdown !== undefined
24
+ if (!reconnecting && !stopped) {
25
+ return null
26
+ }
27
+ return (
28
+ <div className='terminal-session-stopped pd1'>
29
+ {
30
+ reconnecting && (
31
+ <div className='pd1'>
32
+ {e('autoReconnectTerminal')}: {countdown}s
33
+ </div>
34
+ )
35
+ }
36
+ <div className='pd1'>
37
+ {e('reload')}: {reloadShortcut}
38
+ </div>
39
+ <div className='pd1'>
40
+ {e('close')}: {closeShortcut}
41
+ </div>
42
+ </div>
43
+ )
44
+ })
@@ -79,13 +79,21 @@ function getShInlineIntegration () {
79
79
  return [
80
80
  'if [ -z "$ELECTERM_SHELL_INTEGRATION" ]',
81
81
  'then export ELECTERM_SHELL_INTEGRATION=1',
82
- '__e_esc() { printf \'%s\' "$1" | sed \'s/\\\\/\\\\\\\\/g; s/;/\\\\x3b/g\'; }',
83
82
  // sh/dash/ash re-evaluate $(...) inside PS1 every time the prompt is
84
83
  // shown, but only if the $() stays literal — so PS1 must be assigned
85
84
  // single-quoted. printf interprets \033/\a, unlike PS1 backslash
86
85
  // escapes which dash does not expand.
87
- '__e_ps1() { printf \'\\033]633;P;Cwd=%s\\a\\033]633;A\\a\' "$(__e_esc "$PWD")"; }',
88
- 'PS1=\'$(__e_ps1)\'"${PS1:-# }"',
86
+ //
87
+ // The prompt is deliberately self-contained: it must never call a shell
88
+ // function. On OpenWrt /etc/profile does `export PS1=...`, so PS1 is an
89
+ // environment variable and every child shell inherits it — while shell
90
+ // functions are not inherited. A `PS1='$(__e_ps1)'...` prompt therefore
91
+ // dies with "ash: __e_ps1: not found" on every prompt of any nested shell
92
+ // (ash, su, login, ...), and the exported ELECTERM_SHELL_INTEGRATION
93
+ // guard stops the child from ever defining the missing function.
94
+ // Inlining keeps it working there too, and printf is an ash builtin so
95
+ // this is cheaper than the old function + sed pair.
96
+ 'PS1=\'$(printf "\\033]633;P;Cwd=%s\\a\\033]633;A\\a" "$PWD")\'"${PS1:-# }"',
89
97
  'fi'
90
98
  ].join('; ')
91
99
  }