@electerm/electerm-react 5.3.26 → 5.5.9
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/README.md +246 -12
- package/client/common/choose-save-folder.js +21 -2
- package/client/common/constants.js +20 -6
- package/client/common/create-title.jsx +6 -0
- package/client/common/default-setting.js +15 -0
- package/client/common/init-setting-item.js +5 -0
- package/client/common/platform.js +24 -0
- package/client/components/ai/agent-tool-call-card.jsx +10 -2
- package/client/components/ai/agent.js +4 -1
- package/client/components/ai/ai-attachments.js +87 -0
- package/client/components/ai/ai-chat-empty.jsx +43 -0
- package/client/components/ai/ai-chat-entry.jsx +13 -4
- package/client/components/ai/ai-chat-history-item.jsx +28 -4
- package/client/components/ai/ai-chat-history.jsx +2 -1
- package/client/components/ai/ai-chat.jsx +104 -13
- package/client/components/ai/ai-config-props.js +4 -130
- package/client/components/ai/ai-config.jsx +135 -15
- package/client/components/ai/ai-presets.js +173 -0
- package/client/components/ai/ai.styl +83 -0
- package/client/components/bg/css-overwrite.jsx +11 -0
- package/client/components/bookmark-form/bookmark-schema.js +4 -0
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/config/common-fields.js +12 -0
- package/client/components/bookmark-form/config/local.js +3 -2
- package/client/components/bookmark-form/config/serial.js +3 -2
- package/client/components/bookmark-form/config/ssh.js +2 -1
- package/client/components/bookmark-form/config/telnet.js +3 -2
- package/client/components/common/item-filter.jsx +76 -0
- package/client/components/common/item-filter.styl +54 -0
- package/client/components/common/lazy-boundary.jsx +34 -0
- package/client/components/common/opacity.jsx +3 -1
- package/client/components/footer/cmd-history.jsx +132 -45
- package/client/components/footer/cmd-history.styl +42 -4
- package/client/components/footer/footer-entry.jsx +35 -5
- package/client/components/footer/footer.styl +8 -1
- package/client/components/footer/multi-tab-run-modal.jsx +83 -0
- package/client/components/footer/tab-select.jsx +34 -17
- package/client/components/layout/layout.jsx +23 -31
- package/client/components/layout/pixed.js +3 -0
- package/client/components/main/main.jsx +55 -28
- package/client/components/main/term-fullscreen.styl +9 -3
- package/client/components/quick-commands/qm.styl +3 -2
- package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
- package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
- package/client/components/rdp/file-transfer.js +4 -4
- package/client/components/remote-monitor/monitor-details.jsx +458 -0
- package/client/components/remote-monitor/monitor-details.styl +87 -0
- package/client/components/remote-monitor/monitor-model.js +764 -0
- package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
- package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
- package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
- package/client/components/remote-monitor/session-monitor.js +361 -0
- package/client/components/remote-monitor/use-monitor-details.js +10 -0
- package/client/components/remote-monitor/visibility.js +18 -0
- package/client/components/session/session-control.jsx +29 -2
- package/client/components/session/session.jsx +40 -18
- package/client/components/session/sessions.jsx +5 -2
- package/client/components/setting-panel/setting-common.jsx +25 -56
- package/client/components/setting-panel/setting-modal.jsx +57 -42
- package/client/components/setting-panel/setting-terminal.jsx +8 -2
- package/client/components/setting-panel/setting.styl +1 -1
- package/client/components/setting-panel/tab-triggers.jsx +71 -0
- package/client/components/sftp/file-compare-modal.jsx +6 -3
- package/client/components/sftp/file-item.jsx +5 -3
- package/client/components/sftp/owner-list.js +11 -2
- package/client/components/sftp/sftp-entry.jsx +4 -0
- package/client/components/side-panel-r/side-panel-r.jsx +2 -2
- package/client/components/sidebar/side-panel.jsx +1 -1
- package/client/components/sys-menu/menu-btn.jsx +30 -1
- package/client/components/tabs/index.jsx +2 -2
- package/client/components/tabs/tab-title.jsx +3 -2
- package/client/components/tabs/tab.jsx +5 -1
- package/client/components/terminal/attach-addon-custom.js +295 -49
- package/client/components/terminal/automation/index.js +64 -0
- package/client/components/terminal/automation/keys.js +49 -0
- package/client/components/terminal/automation/stream-matcher.js +82 -0
- package/client/components/terminal/automation/strip-ansi.js +19 -0
- package/client/components/terminal/automation/trigger-engine.js +233 -0
- package/client/components/terminal/automation/trigger-presets.js +110 -0
- package/client/components/terminal/command-tracker-addon.js +31 -1
- package/client/components/terminal/mixins/index.js +27 -0
- package/client/components/terminal/mixins/term-attach.js +123 -0
- package/client/components/terminal/mixins/term-config.jsx +84 -0
- package/client/components/terminal/mixins/term-content.js +126 -0
- package/client/components/terminal/mixins/term-context-menu.jsx +323 -0
- package/client/components/terminal/mixins/term-file-drop.js +149 -0
- package/client/components/terminal/mixins/term-init.js +122 -0
- package/client/components/terminal/mixins/term-log.jsx +96 -0
- package/client/components/terminal/mixins/term-resize.js +87 -0
- package/client/components/terminal/mixins/term-socket.js +433 -0
- package/client/components/terminal/mixins/term-suggestions.js +224 -0
- package/client/components/terminal/mixins/term-theme.js +190 -0
- package/client/components/terminal/mixins/term-touch.js +523 -0
- package/client/components/terminal/shell-detect.js +37 -0
- package/client/components/terminal/shell.js +0 -28
- package/client/components/terminal/shortcut-bar-defs.js +30 -4
- package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
- package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
- package/client/components/terminal/shortcut-bar.jsx +17 -10
- package/client/components/terminal/shortcut-bar.styl +4 -0
- package/client/components/terminal/ssh-reload-state.js +96 -0
- package/client/components/terminal/startup-queue.js +433 -0
- package/client/components/terminal/terminal-interactive.jsx +32 -0
- package/client/components/terminal/terminal-select-text.jsx +69 -0
- package/client/components/terminal/terminal.jsx +227 -2064
- package/client/components/terminal/terminal.styl +61 -0
- package/client/components/terminal/transfer-client-base.js +5 -29
- package/client/components/terminal/trzsz-client.js +1 -3
- package/client/components/terminal/xmodem-client.js +1 -3
- package/client/components/terminal/xterm-loader.js +7 -0
- package/client/components/terminal/zmodem-client.js +1 -3
- package/client/components/terminal-info/base.jsx +18 -70
- package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
- package/client/components/terminal-info/terminal-info.jsx +50 -59
- package/client/components/terminal-info/terminal-info.styl +6 -49
- package/client/components/text-editor/open-text-editor.js +30 -0
- package/client/components/text-editor/text-editor-entry.jsx +17 -6
- package/client/components/text-editor/text-editor.jsx +14 -0
- package/client/components/triggers/trigger-editor.jsx +246 -0
- package/client/components/triggers/trigger-form.jsx +200 -0
- package/client/components/triggers/trigger-session-modal.jsx +199 -0
- package/client/components/triggers/trigger-setting-list.jsx +89 -0
- package/client/components/triggers/trigger-transport.jsx +60 -0
- package/client/components/triggers/triggers-field.jsx +19 -0
- package/client/components/widgets/widgets-list.jsx +11 -1
- package/client/css/mobile.styl +29 -8
- package/client/store/common.js +12 -1
- package/client/store/init-state.js +7 -0
- package/client/store/mcp-handler.js +158 -30
- package/client/store/setting.js +9 -0
- package/client/store/store.js +2 -0
- package/client/store/sync.js +2 -0
- package/client/store/tab.js +42 -3
- package/client/store/trigger.js +118 -0
- package/client/store/watch.js +13 -5
- package/client/views/index.pug +3 -0
- package/package.json +1 -1
- package/client/components/terminal-info/activity.jsx +0 -57
- package/client/components/terminal-info/data-cols-parser.jsx +0 -54
- package/client/components/terminal-info/disk.jsx +0 -45
- package/client/components/terminal-info/format-disks.js +0 -45
- package/client/components/terminal-info/network.jsx +0 -121
- package/client/components/terminal-info/resource.jsx +0 -99
- package/client/components/terminal-info/run-cmd.jsx +0 -263
- package/client/components/terminal-info/sys-info.jsx +0 -56
- package/client/components/terminal-info/up.jsx +0 -17
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { pick } from 'lodash-es'
|
|
2
|
+
import clone from '../../../common/to-simple-obj.js'
|
|
3
|
+
import deepCopy from 'json-deep-copy'
|
|
4
|
+
import {
|
|
5
|
+
statusMap,
|
|
6
|
+
typeMap,
|
|
7
|
+
isWin,
|
|
8
|
+
isMac,
|
|
9
|
+
terminalSerialType,
|
|
10
|
+
paneMap
|
|
11
|
+
} from '../../../common/constants.js'
|
|
12
|
+
import getProxy from '../../../common/get-proxy.js'
|
|
13
|
+
import { refs } from '../../common/ref.js'
|
|
14
|
+
import { createTerm, resizeTerm } from '../terminal-apis.js'
|
|
15
|
+
import { ZmodemClient } from '../zmodem-client.js'
|
|
16
|
+
import { TrzszClient } from '../trzsz-client.js'
|
|
17
|
+
import { XmodemClient } from '../xmodem-client.js'
|
|
18
|
+
import { loadWebLinksAddon } from '../xterm-loader.js'
|
|
19
|
+
import { KeywordHighlighterAddon } from '../highlight-addon.js'
|
|
20
|
+
import {
|
|
21
|
+
createTerminalReloadState,
|
|
22
|
+
getAlternateBufferSnapshot,
|
|
23
|
+
shouldCaptureTerminalReloadState
|
|
24
|
+
} from '../ssh-reload-state.js'
|
|
25
|
+
|
|
26
|
+
// Expands \n \t \r \\ and \xHH hex byte escapes, used to let users type
|
|
27
|
+
// control bytes (e.g. \x01 = Ctrl+A) in the serial "closeSequence" field.
|
|
28
|
+
function expandCloseSequence (text) {
|
|
29
|
+
let result = ''
|
|
30
|
+
for (let i = 0; i < text.length; i++) {
|
|
31
|
+
if (text[i] === '\\' && i + 1 < text.length) {
|
|
32
|
+
const next = text[i + 1]
|
|
33
|
+
if (next === 'n') {
|
|
34
|
+
result += '\n'
|
|
35
|
+
i++
|
|
36
|
+
} else if (next === 't') {
|
|
37
|
+
result += '\t'
|
|
38
|
+
i++
|
|
39
|
+
} else if (next === 'r') {
|
|
40
|
+
result += '\r'
|
|
41
|
+
i++
|
|
42
|
+
} else if (next === '\\') {
|
|
43
|
+
result += '\\'
|
|
44
|
+
i++
|
|
45
|
+
} else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
|
|
46
|
+
result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
|
|
47
|
+
i += 3
|
|
48
|
+
} else {
|
|
49
|
+
result += text[i]
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
result += text[i]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Session lifecycle: opening the pty/ssh session over its websocket, error
|
|
60
|
+
* and reconnect handling, input broadcast, and the screen snapshot used to
|
|
61
|
+
* restore a shell session (ssh, local, telnet, serial) after a reload.
|
|
62
|
+
*/
|
|
63
|
+
export const socketMixin = {
|
|
64
|
+
buildWsUrl (port) {
|
|
65
|
+
const { host, tokenElecterm } = this.props.config
|
|
66
|
+
const { id } = this.props.tab
|
|
67
|
+
if (window.et.buildWsUrl) {
|
|
68
|
+
return window.et.buildWsUrl(
|
|
69
|
+
host,
|
|
70
|
+
port,
|
|
71
|
+
tokenElecterm,
|
|
72
|
+
id
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return `ws://${host}:${port}/terminals/${id}?token=${tokenElecterm}`
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async remoteInit (term = this.term) {
|
|
79
|
+
this.setState({
|
|
80
|
+
loading: true,
|
|
81
|
+
terminalError: null
|
|
82
|
+
})
|
|
83
|
+
const { cols, rows } = term
|
|
84
|
+
const { config } = this.props
|
|
85
|
+
const {
|
|
86
|
+
keywords = []
|
|
87
|
+
} = config
|
|
88
|
+
const { logName } = this.props
|
|
89
|
+
const tab = window.store.applyProfileToTabs(deepCopy(this.props.tab || {}))
|
|
90
|
+
const {
|
|
91
|
+
srcId, from = 'bookmarks',
|
|
92
|
+
type,
|
|
93
|
+
term: terminalType,
|
|
94
|
+
displayRaw,
|
|
95
|
+
id
|
|
96
|
+
} = tab
|
|
97
|
+
const { savePassword } = this.state
|
|
98
|
+
const termType = type
|
|
99
|
+
const extra = this.props.sessionOptions
|
|
100
|
+
// Determine if this is a local terminal (no host)
|
|
101
|
+
const isLocalType = !tab.host
|
|
102
|
+
// Build exec settings: only for local type, prefer tab settings over config
|
|
103
|
+
let execOpts = {}
|
|
104
|
+
let execPropName = 'execLinux'
|
|
105
|
+
if (isWin) {
|
|
106
|
+
execPropName = 'execWindows'
|
|
107
|
+
} else if (isMac) {
|
|
108
|
+
execPropName = 'execMac'
|
|
109
|
+
}
|
|
110
|
+
if (isLocalType) {
|
|
111
|
+
// Check flat properties on tab first (bookmark data), then fall back to config
|
|
112
|
+
if (tab[execPropName]) {
|
|
113
|
+
// Use bookmark's exec setting directly
|
|
114
|
+
execOpts = {
|
|
115
|
+
[execPropName]: tab[execPropName],
|
|
116
|
+
[`${execPropName}Args`]: tab[`${execPropName}Args`] || []
|
|
117
|
+
}
|
|
118
|
+
} else if (config[execPropName]) {
|
|
119
|
+
// Use global config exec settings
|
|
120
|
+
execOpts = {
|
|
121
|
+
[execPropName]: config[execPropName],
|
|
122
|
+
[`${execPropName}Args`]: config[`${execPropName}Args`] || []
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const keepaliveInterval = tab.keepaliveInterval || config.keepaliveInterval
|
|
127
|
+
const opts = clone({
|
|
128
|
+
cols,
|
|
129
|
+
rows,
|
|
130
|
+
term: terminalType || config.terminalType,
|
|
131
|
+
saveTerminalLogToFile: config.saveTerminalLogToFile,
|
|
132
|
+
...tab,
|
|
133
|
+
...extra,
|
|
134
|
+
...execOpts,
|
|
135
|
+
logName,
|
|
136
|
+
sessionLogPath: this.state.logPath,
|
|
137
|
+
...pick(config, [
|
|
138
|
+
'addTimeStampToTermLog',
|
|
139
|
+
'keepaliveCountMax',
|
|
140
|
+
'keyword2FA',
|
|
141
|
+
'debug'
|
|
142
|
+
]),
|
|
143
|
+
keepaliveInterval,
|
|
144
|
+
tabId: id,
|
|
145
|
+
uid: id,
|
|
146
|
+
srcTabId: tab.id,
|
|
147
|
+
termType,
|
|
148
|
+
readyTimeout: config.sshReadyTimeout,
|
|
149
|
+
proxy: getProxy(tab, config),
|
|
150
|
+
type: tab.host
|
|
151
|
+
? typeMap.remote
|
|
152
|
+
: typeMap.local
|
|
153
|
+
})
|
|
154
|
+
// Renderer-only state can contain a large serialized screen and must not
|
|
155
|
+
// cross the process boundary as part of the SSH connection options.
|
|
156
|
+
delete opts._reloadState
|
|
157
|
+
const isAutoReconnect = !!(tab.autoReConnect && this.props.config.autoReconnectTerminal)
|
|
158
|
+
const r = await createTerm(opts)
|
|
159
|
+
.catch(err => {
|
|
160
|
+
if (!isAutoReconnect) {
|
|
161
|
+
const text = err.message
|
|
162
|
+
this.handleError({ message: text, from, srcId })
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
// Guard: component was unmounted while createTerm was pending.
|
|
166
|
+
// The child process is already running; connect briefly to trigger its cleanup.
|
|
167
|
+
if (this.onClose) {
|
|
168
|
+
if (r && r.port) {
|
|
169
|
+
try {
|
|
170
|
+
const tmpSock = new WebSocket(this.buildWsUrl(r.port))
|
|
171
|
+
tmpSock.onopen = () => tmpSock.close()
|
|
172
|
+
} catch (_e) {}
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (typeof r === 'string' && r.includes('fail')) {
|
|
177
|
+
return this.promote()
|
|
178
|
+
}
|
|
179
|
+
if (savePassword) {
|
|
180
|
+
window.store.editItem(srcId, extra, from)
|
|
181
|
+
}
|
|
182
|
+
this.setState({
|
|
183
|
+
loading: false
|
|
184
|
+
})
|
|
185
|
+
if (!r) {
|
|
186
|
+
if (isAutoReconnect) {
|
|
187
|
+
this.scheduleAutoReconnect(3000)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
this.setStatus(statusMap.error)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
this.port = r.port
|
|
194
|
+
this.setStatus(statusMap.success)
|
|
195
|
+
refs.get('sftp-' + id)?.initData(id, r.port)
|
|
196
|
+
term.pid = id
|
|
197
|
+
this.pid = id
|
|
198
|
+
const wsUrl = this.buildWsUrl(r.port)
|
|
199
|
+
const socket = new WebSocket(wsUrl)
|
|
200
|
+
socket.onclose = this.oncloseSocket
|
|
201
|
+
socket.onerror = this.onerrorSocket
|
|
202
|
+
this.socket = socket
|
|
203
|
+
this.initSocketEvents()
|
|
204
|
+
this.term = term
|
|
205
|
+
socket.onopen = async () => {
|
|
206
|
+
// attach addon is a dynamic import too — report a failed chunk fetch
|
|
207
|
+
// instead of leaving an unhandled rejection behind
|
|
208
|
+
try {
|
|
209
|
+
await this.initAttachAddon()
|
|
210
|
+
this.startupQueue.runInitScript()
|
|
211
|
+
} catch (e) {
|
|
212
|
+
console.error(e)
|
|
213
|
+
this.handleError({ message: e.message })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// term.onRrefresh(this.onRefresh)
|
|
217
|
+
term.onResize(this.onResizeTerminal)
|
|
218
|
+
// xterm 6.x exposes buffer change as a public event (IBufferNamespace.onBufferChange).
|
|
219
|
+
// Previously this reached into the private _onBufferChange._listeners array.
|
|
220
|
+
term.buffer.onBufferChange(this.onBufferChange)
|
|
221
|
+
const WebLinksAddon = await loadWebLinksAddon()
|
|
222
|
+
term.loadAddon(new WebLinksAddon(this.webLinkHandler))
|
|
223
|
+
term.focus()
|
|
224
|
+
this.zmodemClient = new ZmodemClient(this)
|
|
225
|
+
this.zmodemClient.init(socket)
|
|
226
|
+
this.trzszClient = new TrzszClient(this)
|
|
227
|
+
this.trzszClient.init(socket)
|
|
228
|
+
this.xmodemClient = new XmodemClient(this)
|
|
229
|
+
this.xmodemClient.init(socket)
|
|
230
|
+
// 仅在可见时 fit,隐藏标签跳过,避免 0 列损坏提示符。
|
|
231
|
+
if (this.isElementVisible()) {
|
|
232
|
+
this.fitAddon.fit()
|
|
233
|
+
}
|
|
234
|
+
// Force-send the current size now that `this.pid` is finally valid.
|
|
235
|
+
// A resize may already have fired earlier (e.g. from the tab-becomes-
|
|
236
|
+
// visible fit in componentDidUpdate, which can run before createTerm()
|
|
237
|
+
// resolves) while this.pid was still undefined - the backend silently
|
|
238
|
+
// drops resize calls for an unknown pid. If that earlier call already
|
|
239
|
+
// updated term.cols/rows locally, xterm's resize() no-ops on the next
|
|
240
|
+
// fit() (dims unchanged) and never fires onResize again, so the
|
|
241
|
+
// backend pty would otherwise be stuck at its initial (possibly wrong)
|
|
242
|
+
// size forever. Sending explicitly here, keyed off the real pid,
|
|
243
|
+
// guarantees the remote pty/ssh channel is told the true current size.
|
|
244
|
+
resizeTerm(this.pid, term.cols, term.rows)
|
|
245
|
+
term.displayRaw = displayRaw
|
|
246
|
+
term.loadAddon(
|
|
247
|
+
new KeywordHighlighterAddon(keywords)
|
|
248
|
+
)
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
handleError ({ message: errorMessage, from, srcId }) {
|
|
252
|
+
this.setState({
|
|
253
|
+
terminalError: {
|
|
254
|
+
message: errorMessage || 'Failed to create terminal session',
|
|
255
|
+
from,
|
|
256
|
+
srcId
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
handleEditBookmarkFromError () {
|
|
262
|
+
const error = this.state.terminalError
|
|
263
|
+
if (!error || error.from !== 'bookmarks' || !error.srcId) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const item = window.store.bookmarksMap?.get(error.srcId) ||
|
|
267
|
+
window.store.bookmarks?.find(d => d.id === error.srcId)
|
|
268
|
+
if (!item) {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
window.store.openBookmarkEdit(item)
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
initSocketEvents () {
|
|
275
|
+
const originalSend = this.socket.send
|
|
276
|
+
this.socket.send = (data) => {
|
|
277
|
+
// Call original send first
|
|
278
|
+
originalSend.call(this.socket, data)
|
|
279
|
+
|
|
280
|
+
// Broadcast to other terminals
|
|
281
|
+
this.broadcastSocketData(data)
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
canReceiveBroadcast (termRef) {
|
|
286
|
+
return (
|
|
287
|
+
termRef.socket &&
|
|
288
|
+
termRef.props?.tab.pane === paneMap.terminal
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
broadcastSocketData (data) {
|
|
293
|
+
if (!this.isActiveTerminal() || !this.props.broadcastInput) {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
window.refs.forEach((termRef, refId) => {
|
|
298
|
+
if (
|
|
299
|
+
refId !== this.id &&
|
|
300
|
+
refId.startsWith('term-') &&
|
|
301
|
+
this.canReceiveBroadcast(termRef)
|
|
302
|
+
) {
|
|
303
|
+
termRef.socket.send(data)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
onerrorSocket (err) {
|
|
309
|
+
console.error('onerrorSocket', err)
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
oncloseSocket () {
|
|
313
|
+
if (this.onClose || this.props.tab.enableSsh === false) {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
this.setStatus(
|
|
317
|
+
statusMap.error
|
|
318
|
+
)
|
|
319
|
+
if (this.userTypeExit) {
|
|
320
|
+
return this.props.delTab(this.props.tab.id)
|
|
321
|
+
}
|
|
322
|
+
const { autoReconnectTerminal } = this.props.config
|
|
323
|
+
if (autoReconnectTerminal) {
|
|
324
|
+
this.scheduleAutoReconnect(3000)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
scheduleAutoReconnect (delay = 3000) {
|
|
329
|
+
clearTimeout(this.timers.reconnectTimer)
|
|
330
|
+
clearInterval(this.timers.reconnectCountdown)
|
|
331
|
+
const seconds = Math.round(delay / 1000)
|
|
332
|
+
this.setState({ reconnectCountdown: seconds })
|
|
333
|
+
let remaining = seconds
|
|
334
|
+
this.timers.reconnectCountdown = setInterval(() => {
|
|
335
|
+
remaining -= 1
|
|
336
|
+
if (remaining <= 0) {
|
|
337
|
+
clearInterval(this.timers.reconnectCountdown)
|
|
338
|
+
this.timers.reconnectCountdown = null
|
|
339
|
+
}
|
|
340
|
+
this.setState({ reconnectCountdown: remaining <= 0 ? null : remaining })
|
|
341
|
+
}, 1000)
|
|
342
|
+
this.timers.reconnectTimer = setTimeout(() => {
|
|
343
|
+
clearInterval(this.timers.reconnectCountdown)
|
|
344
|
+
this.timers.reconnectCountdown = null
|
|
345
|
+
this.setState({ reconnectCountdown: null })
|
|
346
|
+
if (this.onClose || !this.props.config.autoReconnectTerminal) {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
const reconnectCount = (this.props.tab.autoReConnect || 0) + 1
|
|
350
|
+
this.props.reloadTab({ ...this.props.tab, autoReConnect: reconnectCount })
|
|
351
|
+
}, delay)
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
handleCancelAutoReconnect () {
|
|
355
|
+
clearTimeout(this.timers.reconnectTimer)
|
|
356
|
+
clearInterval(this.timers.reconnectCountdown)
|
|
357
|
+
this.timers.reconnectTimer = null
|
|
358
|
+
this.timers.reconnectCountdown = null
|
|
359
|
+
this.setState({ reconnectCountdown: null })
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
handleCancel () {
|
|
363
|
+
const { id } = this.props.tab
|
|
364
|
+
this.props.delTab(id)
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Manually triggered from the "exit gracefully" control in
|
|
369
|
+
* session-control.jsx (serial tabs only). Writes the configured key
|
|
370
|
+
* sequence (default \x01ky = Ctrl+A, k, y to kill a GNU screen window) to
|
|
371
|
+
* the socket, waits a bit so the remote end (e.g. a Bluetooth console
|
|
372
|
+
* adapter) can release cleanly, then closes the tab.
|
|
373
|
+
*/
|
|
374
|
+
async exitGracefully () {
|
|
375
|
+
const { tab } = this.props
|
|
376
|
+
if (tab.type !== terminalSerialType) {
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (!this.onClose && this.attachAddon?._sendData) {
|
|
380
|
+
try {
|
|
381
|
+
const data = expandCloseSequence(tab.closeSequence || '\\x01ky')
|
|
382
|
+
if (data) {
|
|
383
|
+
this.attachAddon._sendData(data)
|
|
384
|
+
}
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.error('send close sequence failed', err)
|
|
387
|
+
}
|
|
388
|
+
const delay = Number(tab.closeSequenceDelay)
|
|
389
|
+
await new Promise(resolve => setTimeout(resolve, Number.isFinite(delay) && delay >= 0 ? delay : 500))
|
|
390
|
+
}
|
|
391
|
+
this.props.delTab(tab.id)
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
getReloadState () {
|
|
395
|
+
if (!shouldCaptureTerminalReloadState(this.props.tab, this.props.config)) {
|
|
396
|
+
return undefined
|
|
397
|
+
}
|
|
398
|
+
let screen = ''
|
|
399
|
+
try {
|
|
400
|
+
screen = this.serializeAddon?.serialize({
|
|
401
|
+
scrollback: this.props.config.scrollback,
|
|
402
|
+
excludeAltBuffer: true,
|
|
403
|
+
excludeModes: true
|
|
404
|
+
}) || ''
|
|
405
|
+
// Full-screen programs use the alternate buffer. Replaying that buffer
|
|
406
|
+
// as a terminal mode would trap the fresh shell in stale mouse/cursor
|
|
407
|
+
// state, so preserve its visible text as a safe normal-buffer snapshot.
|
|
408
|
+
const alternateScreen = getAlternateBufferSnapshot(this.term?.buffer.active)
|
|
409
|
+
if (alternateScreen) {
|
|
410
|
+
screen += `${screen ? '\r\n' : ''}${alternateScreen}`
|
|
411
|
+
}
|
|
412
|
+
} catch (e) {
|
|
413
|
+
console.warn('Failed to serialize terminal before reload', e)
|
|
414
|
+
}
|
|
415
|
+
const cwd = this.cmdAddon?.getCwd() || this.props.tab._reloadState?.cwd || ''
|
|
416
|
+
return createTerminalReloadState({ cwd, screen })
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
restoreReloadScreen (term) {
|
|
420
|
+
const screen = this.props.tab._reloadState?.screen
|
|
421
|
+
if (!screen) {
|
|
422
|
+
return Promise.resolve()
|
|
423
|
+
}
|
|
424
|
+
return new Promise(resolve => {
|
|
425
|
+
term.write(screen, () => {
|
|
426
|
+
// The framebuffer has been replayed; do not keep a potentially large
|
|
427
|
+
// duplicate string on the live tab. A later reload serializes afresh.
|
|
428
|
+
delete this.props.tab._reloadState.screen
|
|
429
|
+
resolve()
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { debounce } from 'lodash-es'
|
|
2
|
+
import { refsStatic } from '../../common/ref.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Command suggestions: reading the current input straight from the terminal
|
|
6
|
+
* buffer, positioning the dropdown at the cursor, and the password prompt
|
|
7
|
+
* variant triggered by the shell integration OSC sequences.
|
|
8
|
+
*/
|
|
9
|
+
export const suggestionsMixin = {
|
|
10
|
+
getCursorPosition () {
|
|
11
|
+
if (!this.term) return null
|
|
12
|
+
|
|
13
|
+
// Get the active buffer and cursor position
|
|
14
|
+
const buffer = this.term.buffer.active
|
|
15
|
+
const cursorRow = buffer.cursorY
|
|
16
|
+
const cursorCol = buffer.cursorX
|
|
17
|
+
|
|
18
|
+
// Get dimensions from term element
|
|
19
|
+
const termElement = this.term.element
|
|
20
|
+
if (!termElement) return null
|
|
21
|
+
|
|
22
|
+
// Get the exact position of the terminal element
|
|
23
|
+
const termRect = termElement.getBoundingClientRect()
|
|
24
|
+
|
|
25
|
+
// Calculate cell dimensions
|
|
26
|
+
const cellWidth = termRect.width / this.term.cols
|
|
27
|
+
const cellHeight = termRect.height / this.term.rows
|
|
28
|
+
|
|
29
|
+
// Calculate absolute position relative to terminal element
|
|
30
|
+
const left = Math.floor(termRect.left + (cursorCol * cellWidth))
|
|
31
|
+
const top = Math.floor(termRect.top + ((cursorRow + 1) * cellHeight))
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
cellWidth,
|
|
35
|
+
cellHeight,
|
|
36
|
+
left,
|
|
37
|
+
top
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
closeSuggestions () {
|
|
42
|
+
refsStatic
|
|
43
|
+
.get('terminal-suggestions')
|
|
44
|
+
?.closeSuggestions()
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
openSuggestions (cursorPos, data) {
|
|
48
|
+
refsStatic
|
|
49
|
+
.get('terminal-suggestions')
|
|
50
|
+
?.openSuggestions(cursorPos, data)
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read current input directly from terminal buffer
|
|
55
|
+
* This is more reliable than tracking character-by-character
|
|
56
|
+
*/
|
|
57
|
+
getCurrentInput () {
|
|
58
|
+
if (!this.term) return ''
|
|
59
|
+
|
|
60
|
+
const buffer = this.term.buffer.active
|
|
61
|
+
const cursorY = buffer.cursorY
|
|
62
|
+
const cursorX = buffer.cursorX
|
|
63
|
+
|
|
64
|
+
// Get the current line from buffer (baseY + cursorY gives absolute position)
|
|
65
|
+
const absoluteY = buffer.baseY + cursorY
|
|
66
|
+
const line = buffer.getLine(absoluteY)
|
|
67
|
+
if (!line) return ''
|
|
68
|
+
|
|
69
|
+
// Get text from start of line up to cursor position
|
|
70
|
+
const lineText = line.translateToString(true, 0, cursorX)
|
|
71
|
+
|
|
72
|
+
// Try to extract command after prompt
|
|
73
|
+
// Common prompt endings with trailing space
|
|
74
|
+
const promptEndings = ['$ ', '# ', '> ', '% ', '] ', ') ']
|
|
75
|
+
|
|
76
|
+
let commandStart = 0
|
|
77
|
+
for (const ending of promptEndings) {
|
|
78
|
+
const idx = lineText.lastIndexOf(ending)
|
|
79
|
+
if (idx !== -1 && idx + ending.length > commandStart) {
|
|
80
|
+
commandStart = idx + ending.length
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lineText.slice(commandStart)
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
setCurrentInput (value) {
|
|
88
|
+
this.currentInput = value
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handle special input events for command history tracking
|
|
93
|
+
* The actual input reading is done via getCurrentInput from buffer
|
|
94
|
+
*/
|
|
95
|
+
handleInputEvent (d) {
|
|
96
|
+
// Handle Enter - add command to history
|
|
97
|
+
if (d === '\r' || d === '\n') {
|
|
98
|
+
const currentCmd = this.getCurrentInput()
|
|
99
|
+
if (currentCmd && currentCmd.trim() && this.shouldUseManualHistory()) {
|
|
100
|
+
window.store.addCmdHistory(currentCmd.trim())
|
|
101
|
+
}
|
|
102
|
+
if (currentCmd && currentCmd.trim() === 'exit') {
|
|
103
|
+
this.userTypeExit = true
|
|
104
|
+
}
|
|
105
|
+
this.closeSuggestions()
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
onData (d) {
|
|
110
|
+
this.handleInputEvent(d)
|
|
111
|
+
// Skip normal suggestion logic when in password mode
|
|
112
|
+
const suggestions = refsStatic.get('terminal-suggestions')
|
|
113
|
+
if (suggestions?.state?.passwordMode) {
|
|
114
|
+
if (d === '\r' || d === '\n') {
|
|
115
|
+
this.closeSuggestions()
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (this.props.config.showCmdSuggestions) {
|
|
120
|
+
if (d === '\r' || d === '\n') {
|
|
121
|
+
this.closeSuggestions()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// Debounce the suggestion opening to avoid expensive work
|
|
125
|
+
// (buffer read + getBoundingClientRect + React re-render) on every keystroke
|
|
126
|
+
this._debouncedOpenSuggestions()
|
|
127
|
+
} else {
|
|
128
|
+
this.closeSuggestions()
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Called by AttachAddonCustom after data is written to the terminal buffer.
|
|
134
|
+
* This fires after server echo arrives, so getCurrentInput() reflects the
|
|
135
|
+
* latest state. We trigger a debounced suggestion refresh so the dropdown
|
|
136
|
+
* updates correctly after backspace, delete, and other edits that rely on
|
|
137
|
+
* server-side echo to update the buffer.
|
|
138
|
+
*/
|
|
139
|
+
onTerminalWrite () {
|
|
140
|
+
if (!this.props.config.showCmdSuggestions) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const suggestions = refsStatic.get('terminal-suggestions')
|
|
144
|
+
if (suggestions?.state?.showSuggestions && !suggestions?.state?.passwordMode) {
|
|
145
|
+
this._debouncedOpenSuggestions()
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// One debounced runner per terminal, so a keystroke does not trigger a
|
|
150
|
+
// buffer read + layout + re-render for every character.
|
|
151
|
+
_debouncedOpenSuggestions () {
|
|
152
|
+
if (!this._openSuggestionsDebounced) {
|
|
153
|
+
this._openSuggestionsDebounced = debounce(() => {
|
|
154
|
+
const data = this.getCurrentInput()
|
|
155
|
+
if (!data) {
|
|
156
|
+
this.closeSuggestions()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const cursorPos = this.getCursorPosition()
|
|
160
|
+
this.openSuggestions(cursorPos, data)
|
|
161
|
+
}, 80)
|
|
162
|
+
}
|
|
163
|
+
this._openSuggestionsDebounced()
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
onPasswordPromptDetected () {
|
|
167
|
+
window.store.notifyTabPasswordPrompt(this.props.tab.id)
|
|
168
|
+
if (!this.props.config.showCmdSuggestions) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const cursorPos = this.getCursorPosition()
|
|
172
|
+
if (cursorPos) {
|
|
173
|
+
refsStatic
|
|
174
|
+
.get('terminal-suggestions')
|
|
175
|
+
?.openPasswordSuggestions(cursorPos)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
onPasswordPromptCancelled () {
|
|
180
|
+
window.store.clearTabPasswordPrompt(this.props.tab.id)
|
|
181
|
+
const suggestions = refsStatic.get('terminal-suggestions')
|
|
182
|
+
if (suggestions?.state?.passwordMode) {
|
|
183
|
+
suggestions.closeSuggestions()
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
getCmd () {
|
|
188
|
+
return this.cmdAddon.getCurrentCommand()
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
getCwd () {
|
|
192
|
+
// Use shell integration CWD if available
|
|
193
|
+
if (this.cmdAddon && this.cmdAddon.hasShellIntegration()) {
|
|
194
|
+
const cwd = this.cmdAddon.getCwd()
|
|
195
|
+
if (cwd) {
|
|
196
|
+
this.setCwd(cwd)
|
|
197
|
+
return cwd
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Fallback: no longer needed with shell integration
|
|
201
|
+
return ''
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
setCwd (cwd) {
|
|
205
|
+
this.props.setCwd(cwd, this.state.id)
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
shouldUseManualHistory () {
|
|
209
|
+
return !this.cmdAddon || !this.cmdAddon.hasShellIntegration()
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
parse (rawText) {
|
|
213
|
+
let result = ''
|
|
214
|
+
const len = rawText.length
|
|
215
|
+
for (let i = 0; i < len; i++) {
|
|
216
|
+
if (rawText[i] === '\b') {
|
|
217
|
+
result = result.slice(0, -1)
|
|
218
|
+
} else {
|
|
219
|
+
result += rawText[i]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
}
|