@electerm/electerm-react 5.5.66 → 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 (33) hide show
  1. package/client/common/constants.js +12 -0
  2. package/client/common/default-setting.js +1 -0
  3. package/client/common/distribute-tabs.js +42 -0
  4. package/client/common/import-task.js +46 -3
  5. package/client/common/parse-quick-connect.js +1 -0
  6. package/client/common/quick-connect-string.js +134 -0
  7. package/client/common/ui-theme-color.mjs +136 -0
  8. package/client/common/update-check.js +14 -0
  9. package/client/components/ai/ai-output.jsx +5 -0
  10. package/client/components/ai/ai.styl +19 -0
  11. package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
  12. package/client/components/bookmark-form/form-renderer.jsx +13 -1
  13. package/client/components/bookmark-form/index.jsx +20 -1
  14. package/client/components/bookmark-form/render-form.jsx +2 -2
  15. package/client/components/main/ui-theme.jsx +1 -20
  16. package/client/components/main/upgrade.jsx +2 -0
  17. package/client/components/setting-panel/install-command.jsx +151 -0
  18. package/client/components/setting-panel/setting-common.jsx +5 -0
  19. package/client/components/setting-panel/setting-terminal.jsx +9 -1
  20. package/client/components/terminal/mixins/term-attach.js +10 -2
  21. package/client/components/terminal/mixins/term-socket.js +12 -2
  22. package/client/components/terminal/mixins/term-suggestions.js +16 -3
  23. package/client/components/terminal/osc52-addon.js +7 -2
  24. package/client/components/terminal/session-status-overlay.jsx +44 -0
  25. package/client/components/terminal/ssh-reload-state.js +41 -2
  26. package/client/components/terminal/startup-queue.js +6 -2
  27. package/client/components/terminal/terminal.jsx +13 -2
  28. package/client/components/terminal/terminal.styl +14 -5
  29. package/client/store/sync.js +46 -18
  30. package/client/store/tab.js +45 -2
  31. package/client/store/watch.js +8 -0
  32. package/package.json +1 -1
  33. package/client/components/terminal/reconnect-overlay.jsx +0 -14
@@ -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
  }
@@ -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 () {
@@ -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
+ })
@@ -46,17 +46,56 @@ export function createRestoreCwdCommand (cwd) {
46
46
  return safeCwd ? `cd -- ${quotePosixShellArg(safeCwd)}` : ''
47
47
  }
48
48
 
49
- export function createWindowsRestoreCwdCommand (cwd) {
49
+ // The shell setting is hand-entered config, so tolerate a quoted or padded
50
+ // executable path before deciding which shell we are looking at.
51
+ export function isPowerShellExecutable (shell = '') {
52
+ const shellPath = String(shell || '').trim().replace(/^"|"$/g, '')
53
+ return /(?:^|[/\\])(?:powershell|pwsh)(?:\.exe)?$/i.test(shellPath)
54
+ }
55
+
56
+ export function createWindowsRestoreCwdCommand (cwd, shell = '') {
50
57
  const safeCwd = sanitizeSshCwd(cwd)
51
58
  if (!safeCwd) {
52
59
  return ''
53
60
  }
61
+ if (isPowerShellExecutable(shell)) {
62
+ // -LiteralPath keeps `[`, `]` and `*` in a directory name from being read
63
+ // as a wildcard; '' is PowerShell's escape for a literal single quote.
64
+ return `Set-Location -LiteralPath '${safeCwd.replace(/'/g, "''")}'`
65
+ }
54
66
  // Strip control chars already handled by sanitize; escape embedded double
55
- // quotes for cmd/powershell (`""` is accepted by both as a literal quote).
67
+ // quotes for CMD.
56
68
  const escaped = safeCwd.replace(/"/g, '""')
57
69
  return `cd /d "${escaped}"`
58
70
  }
59
71
 
72
+ /**
73
+ * The `cd <dir>` command for a terminal session, used when the user sends a
74
+ * directory from the file panel to the terminal.
75
+ *
76
+ * A local session knows which shell it spawned, so it can emit syntax that
77
+ * shell actually understands (PowerShell rejects CMD's `cd /d`). A remote tab
78
+ * cannot: the far side's shell is unknown from here, so it keeps the CMD form.
79
+ * Every branch quotes the path with the target shell's own rules - a directory
80
+ * name is attacker-influenced (it can come out of an unpacked archive), so a
81
+ * bare `cd "..."` would let `$(...)` and backticks run.
82
+ *
83
+ * Returns '' when the path is unusable. Callers must not fall back to
84
+ * interpolating the raw path into a command.
85
+ */
86
+ export function createCdCommand (dir, { remote = false, shell = '' } = {}) {
87
+ const safeDir = sanitizeSshCwd(dir)
88
+ if (!safeDir) {
89
+ return ''
90
+ }
91
+ if (!/^[a-zA-Z]:\\/.test(safeDir)) {
92
+ return `cd ${quotePosixShellArg(safeDir)}`
93
+ }
94
+ return remote
95
+ ? `cd /d "${safeDir.replace(/"/g, '""')}"`
96
+ : createWindowsRestoreCwdCommand(safeDir, shell)
97
+ }
98
+
60
99
  export function shouldCaptureTerminalReloadState (tab, config = {}) {
61
100
  return !!config.restoreTerminalSessionOnReload && isRestorableTerminalTab(tab)
62
101
  }
@@ -141,7 +141,7 @@ export class StartupQueue {
141
141
 
142
142
  // ---- queue ----
143
143
 
144
- runInitScript = () => {
144
+ runInitScript = (localShell) => {
145
145
  const { term } = this
146
146
  window.store.triggerResize()
147
147
  const {
@@ -156,9 +156,13 @@ export class StartupQueue {
156
156
  const startFolder = reloadCwd || startDirectory || window.initFolder
157
157
  let cwdCommand = ''
158
158
  if (startFolder) {
159
+ // No `|| cd "<raw>"` fallback: these helpers return '' for a path they
160
+ // refuse (control characters, over-long), and interpolating the raw value
161
+ // instead would hand `startDirectory` - which a quick-connect link can
162
+ // set - straight to the shell as a command.
159
163
  cwdCommand = this.isPosixShell()
160
164
  ? createRestoreCwdCommand(startFolder)
161
- : createWindowsRestoreCwdCommand(startFolder) || `cd "${startFolder}"`
165
+ : createWindowsRestoreCwdCommand(startFolder, localShell || term.props.config.execWindows)
162
166
  }
163
167
  if (cwdCommand) {
164
168
  scripts.unshift({ script: cwdCommand, delay: 0 })
@@ -19,7 +19,7 @@ import NormalBuffer from './normal-buffer.jsx'
19
19
  import createDefaultLogPath from '../../common/default-log-path.js'
20
20
  import SearchResultBar from './terminal-search-bar'
21
21
  import RemoteFloatControl from '../common/remote-float-control'
22
- import ReconnectOverlay from './reconnect-overlay.jsx'
22
+ import SessionStatusOverlay from './session-status-overlay.jsx'
23
23
  import TerminalErrorHandle from './terminal-error-handle.jsx'
24
24
  import DropFileModal from './drop-file-modal.jsx'
25
25
  import TerminalSelectText from './terminal-select-text.jsx'
@@ -77,6 +77,12 @@ class Term extends Component {
77
77
  matchIndex: -1,
78
78
  totalLines: 0,
79
79
  reconnectCountdown: null,
80
+ // True once this pane has no live session (socket closed, or the session
81
+ // failed to start). Not derivable from tab.status: store.updateTab mutates
82
+ // the tab in place, and nothing in this component's ancestor chain reads
83
+ // tab.status during render, so a status change does not re-render the
84
+ // pane. Setting it here is also what triggers that render.
85
+ sessionStopped: false,
80
86
  terminalError: null,
81
87
  dropFileModalVisible: false,
82
88
  droppedFiles: [],
@@ -429,8 +435,13 @@ class Term extends Component {
429
435
  showEditBookmarkButton={this.state.terminalError?.from === 'bookmarks' && !!this.state.terminalError?.srcId}
430
436
  onEditBookmark={this.handleEditBookmarkFromError}
431
437
  />
432
- <ReconnectOverlay
438
+ <SessionStatusOverlay
439
+ // A failed start already has the error alert as its message; the
440
+ // notice would just stack on top of it.
441
+ stopped={this.state.sessionStopped && !this.state.terminalError}
433
442
  countdown={this.state.reconnectCountdown}
443
+ reloadShortcut={this.getShortcut('app_reloadCurrentTab')}
444
+ closeShortcut={this.getShortcut('app_closeCurrentTab')}
434
445
  />
435
446
  {this.renderResetFontSizeButton()}
436
447
  <DropFileModal
@@ -222,12 +222,21 @@
222
222
  max-height 200px
223
223
  overflow-y scroll
224
224
 
225
- .terminal-reconnect-overlay
225
+ .terminal-session-stopped
226
226
  position absolute
227
- bottom 6px
228
- right 12px
229
- font-size 11px
230
- color rgba(255, 255, 255, 0.55)
227
+ left 50%
228
+ top 50%
229
+ transform translate(-50%, -50%)
230
+ padding 8px 14px
231
+ background var(--main)
232
+ color var(--text)
233
+ border 1px solid var(--main-lighter)
234
+ border-radius 4px
235
+ box-shadow 0 2px 8px rgba(0, 0, 0, 0.35)
236
+ font-size 12px
237
+ line-height 1.7
238
+ white-space nowrap
239
+ // keyboard-only affordance: never intercept a click meant for the terminal
231
240
  pointer-events none
232
241
  z-index 110
233
242
 
@@ -742,29 +742,57 @@ export default (Store) => {
742
742
  }
743
743
  fixed[n] = arr
744
744
  }
745
- // clear all targets first - importAll replaces data sets, not appends.
746
- // watchers are stopped inside runImportTask, so this is silent until restart
747
- action(() => {
748
- for (const n of names) {
749
- store.setItems(n, [])
750
- }
751
- })()
745
+ // Import replaces data sets, so the store has to end up holding exactly
746
+ // `fixed`. Keep a copy of what it holds now: if the import is cancelled or
747
+ // throws, this goes back, so the store and the watcher snapshots agree
748
+ // again and the watcher restart cannot be mistaken for a mass deletion.
749
+ const backup = new Map(
750
+ names.map(n => [n, copy(store.getItems(n))])
751
+ )
752
752
  await runImportTask({
753
753
  title: e('import'),
754
754
  batch: 200,
755
755
  stopWatchers: names,
756
- // one chunked step per data set (bookmarks, groups, themes, ...),
757
- // so the progress bar advances per batch instead of per data set
758
- steps: names.map((n) => {
759
- const arr = fixed[n]
760
- return {
761
- label: n,
762
- items: arr,
763
- process: (chunk) => {
764
- store[n].push(...chunk)
756
+ // Clearing happens *inside* the task, i.e. while the watchers are
757
+ // stopped, rather than before it. Doing it earlier meant the empty store
758
+ // was already in place while the snapshots still described the full
759
+ // table, so any path that restarted a watcher without the import having
760
+ // finished (cancel, throw) deleted every record on disk.
761
+ steps: [
762
+ {
763
+ label: e('import'),
764
+ weight: 1,
765
+ run: () => {
766
+ action(() => {
767
+ for (const n of names) {
768
+ store.setItems(n, [])
769
+ }
770
+ })()
765
771
  }
766
- }
767
- })
772
+ },
773
+ // one chunked step per data set (bookmarks, groups, themes, ...),
774
+ // so the progress bar advances per batch instead of per data set
775
+ ...names.map((n) => {
776
+ const arr = fixed[n]
777
+ return {
778
+ label: n,
779
+ items: arr,
780
+ process: (chunk) => {
781
+ store[n].push(...chunk)
782
+ }
783
+ }
784
+ })
785
+ ],
786
+ // The import did not complete: put the store back the way it was so the
787
+ // watcher restart below is a no-op instead of deleting the untouched
788
+ // records. (import-task.js also reseeds each watcher before restarting.)
789
+ onAbort: () => {
790
+ action(() => {
791
+ for (const [n, arr] of backup) {
792
+ store.setItems(n, copy(arr))
793
+ }
794
+ })()
795
+ }
768
796
  })
769
797
  store.updateConfig(stripServerManagedKeys(objs.config))
770
798
  if (objs.config?.theme) {