@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.
- package/client/common/constants.js +12 -0
- package/client/common/default-setting.js +1 -0
- package/client/common/distribute-tabs.js +42 -0
- package/client/common/import-task.js +46 -3
- package/client/common/parse-quick-connect.js +1 -0
- package/client/common/quick-connect-string.js +134 -0
- package/client/common/ui-theme-color.mjs +136 -0
- package/client/common/update-check.js +14 -0
- package/client/components/ai/ai-output.jsx +5 -0
- package/client/components/ai/ai.styl +19 -0
- package/client/components/bookmark-form/copy-bookmark.jsx +83 -0
- package/client/components/bookmark-form/form-renderer.jsx +13 -1
- package/client/components/bookmark-form/index.jsx +20 -1
- package/client/components/bookmark-form/render-form.jsx +2 -2
- package/client/components/main/ui-theme.jsx +1 -20
- package/client/components/main/upgrade.jsx +2 -0
- package/client/components/setting-panel/install-command.jsx +151 -0
- package/client/components/setting-panel/setting-common.jsx +5 -0
- package/client/components/setting-panel/setting-terminal.jsx +9 -1
- package/client/components/terminal/mixins/term-attach.js +10 -2
- package/client/components/terminal/mixins/term-socket.js +12 -2
- package/client/components/terminal/mixins/term-suggestions.js +16 -3
- package/client/components/terminal/osc52-addon.js +7 -2
- package/client/components/terminal/session-status-overlay.jsx +44 -0
- package/client/components/terminal/ssh-reload-state.js +41 -2
- package/client/components/terminal/startup-queue.js +6 -2
- package/client/components/terminal/terminal.jsx +13 -2
- package/client/components/terminal/terminal.styl +14 -5
- package/client/store/sync.js +46 -18
- package/client/store/tab.js +45 -2
- package/client/store/watch.js +8 -0
- package/package.json +1 -1
- 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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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-
|
|
225
|
+
.terminal-session-stopped
|
|
226
226
|
position absolute
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
package/client/store/sync.js
CHANGED
|
@@ -742,29 +742,57 @@ export default (Store) => {
|
|
|
742
742
|
}
|
|
743
743
|
fixed[n] = arr
|
|
744
744
|
}
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
//
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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) {
|