dsh-gufa-code 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +96 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-editor.jpg +0 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +66 -4
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 30-terminal:内嵌真实终端面板(xterm + WebSocket 连接 Host PTY)。
|
|
3
|
+
*
|
|
4
|
+
* 能力:
|
|
5
|
+
* - 离线加载 vendor/xterm(UMD → window.Terminal、addon-fit → window.FitAddon.FitAddon);
|
|
6
|
+
* 加载失败自动降级为「纯文本输出 + 输入行」,仍然可交互。
|
|
7
|
+
* - 多标签(新建/切换/关闭)、ResizeObserver + fit() 自适应并上报 resize。
|
|
8
|
+
* - 键盘输入直接经 WS 发给 PTY;暗/亮主题跟随 props.theme。
|
|
9
|
+
* - 断线自动重连:用已收字节数(since)请求补发缺失输出,服务端缓冲滑出窗口时先清屏再回放。
|
|
10
|
+
* - 监听 G.bus 'terminal:send'(SSH 快捷连接)把文本写入当前终端;终端未创建或未连上时排队。
|
|
11
|
+
*
|
|
12
|
+
* props: { root, visible, height, theme, onToggleHeight }(高度由外部拖拽条控制,组件内不改)
|
|
13
|
+
*/
|
|
14
|
+
/** 客户端诊断日志:写入 Host 侧 /tmp/gufa-code-debug.log(排查桌面壳连接问题用)。 */
|
|
15
|
+
function gufaTermDebug(stage, detail) {
|
|
16
|
+
try {
|
|
17
|
+
const message = `${stage} | ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`
|
|
18
|
+
void G.api('dev.log', { message }).catch(() => {})
|
|
19
|
+
} catch {
|
|
20
|
+
/* 诊断失败不影响功能 */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ── xterm 离线资产加载(模块级、幂等、失败可重试) ─────────────────────── */
|
|
25
|
+
const GUFA_TERM_CSS_ID = 'gufa-code/xterm.css'
|
|
26
|
+
const GUFA_TERM_XTERM_ID = 'gufa-code/xterm.js'
|
|
27
|
+
const GUFA_TERM_FIT_ID = 'gufa-code/xterm-addon-fit.js'
|
|
28
|
+
/** @type {Promise<{ Terminal: Function, FitAddon: Function }> | null} */
|
|
29
|
+
let gufaTermAssetsPromise = null
|
|
30
|
+
|
|
31
|
+
/** 页面里可能被其他插件注入全局 `module` / `exports`,UMD 会误判成 Node 环境而不挂 window。 */
|
|
32
|
+
function gufaHideNodeGlobals() {
|
|
33
|
+
const g = globalThis
|
|
34
|
+
const hadModule = Object.prototype.hasOwnProperty.call(g, 'module')
|
|
35
|
+
const hadExports = Object.prototype.hasOwnProperty.call(g, 'exports')
|
|
36
|
+
const hadDefine = Object.prototype.hasOwnProperty.call(g, 'define')
|
|
37
|
+
const savedModule = g.module
|
|
38
|
+
const savedExports = g.exports
|
|
39
|
+
const savedDefine = g.define
|
|
40
|
+
try {
|
|
41
|
+
delete g.module
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
delete g.exports
|
|
47
|
+
} catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (typeof g.module !== 'undefined') g.module = undefined
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
if (typeof g.exports !== 'undefined') g.exports = undefined
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
/* 关键:Monaco 的 AMD loader 会挂全局 define,UMD 会优先走 AMD 分支而不挂 window */
|
|
61
|
+
try {
|
|
62
|
+
delete g.define
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (typeof g.define !== 'undefined') g.define = undefined
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
try {
|
|
73
|
+
if (hadModule) g.module = savedModule
|
|
74
|
+
else delete g.module
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (hadExports) g.exports = savedExports
|
|
80
|
+
else delete g.exports
|
|
81
|
+
} catch {
|
|
82
|
+
/* ignore */
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
if (hadDefine) g.define = savedDefine
|
|
86
|
+
else delete g.define
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 从可能被污染的全局 module.exports 里抢救 UMD 导出。 */
|
|
94
|
+
function gufaRecoverUmd(name) {
|
|
95
|
+
try {
|
|
96
|
+
const pollution = globalThis.module
|
|
97
|
+
if (pollution === null || pollution === undefined || typeof pollution !== 'object') return undefined
|
|
98
|
+
const exported = pollution.exports
|
|
99
|
+
if (exported === null || exported === undefined) return undefined
|
|
100
|
+
if (typeof exported === 'object') return exported[name]
|
|
101
|
+
return undefined
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 在「隐藏 module/exports」的环境里注入并执行一个 UMD 脚本。 */
|
|
108
|
+
function gufaTermLoadUmdScript(id, src) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const restore = gufaHideNodeGlobals()
|
|
111
|
+
gufaTermLoadScript(id, src).then(
|
|
112
|
+
() => {
|
|
113
|
+
restore()
|
|
114
|
+
resolve()
|
|
115
|
+
},
|
|
116
|
+
(error) => {
|
|
117
|
+
restore()
|
|
118
|
+
reject(error)
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 注入并等待一个 <script>;已成功加载过的直接复用。 */
|
|
125
|
+
function gufaTermLoadScript(id, src) {
|
|
126
|
+
const existing = document.querySelector(`script[data-gufa="${id}"]`)
|
|
127
|
+
if (existing !== null) {
|
|
128
|
+
if (existing.dataset.loaded === '1') return Promise.resolve()
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
existing.addEventListener('load', () => resolve(), { once: true })
|
|
131
|
+
existing.addEventListener('error', () => reject(new Error(`无法加载脚本:${src}`)), { once: true })
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const script = document.createElement('script')
|
|
136
|
+
script.src = src
|
|
137
|
+
script.async = true
|
|
138
|
+
script.dataset.gufa = id
|
|
139
|
+
script.addEventListener('load', () => {
|
|
140
|
+
script.dataset.loaded = '1'
|
|
141
|
+
resolve()
|
|
142
|
+
}, { once: true })
|
|
143
|
+
script.addEventListener('error', () => {
|
|
144
|
+
script.remove()
|
|
145
|
+
reject(new Error(`无法加载脚本:${src}`))
|
|
146
|
+
}, { once: true })
|
|
147
|
+
document.head.append(script)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 注入 xterm.css 并等待样式加载完成(失败也继续,xterm 仍可用)。 */
|
|
152
|
+
function gufaTermIncludeCss() {
|
|
153
|
+
const existing = document.querySelector(`link[data-gufa="${GUFA_TERM_CSS_ID}"]`)
|
|
154
|
+
if (existing !== null) return Promise.resolve()
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const link = document.createElement('link')
|
|
157
|
+
link.rel = 'stylesheet'
|
|
158
|
+
link.href = G.asset('xterm/xterm.css')
|
|
159
|
+
link.dataset.gufa = GUFA_TERM_CSS_ID
|
|
160
|
+
link.addEventListener('load', () => resolve(), { once: true })
|
|
161
|
+
link.addEventListener('error', () => resolve(), { once: true })
|
|
162
|
+
document.head.append(link)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 读取 window.Terminal(含从被污染 module.exports 抢救)。 */
|
|
167
|
+
function gufaReadTerminalCtor() {
|
|
168
|
+
if (typeof window.Terminal === 'function') return window.Terminal
|
|
169
|
+
const recovered = gufaRecoverUmd('Terminal')
|
|
170
|
+
if (typeof recovered === 'function') {
|
|
171
|
+
window.Terminal = recovered
|
|
172
|
+
return recovered
|
|
173
|
+
}
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 读取 window.FitAddon.FitAddon(含抢救)。 */
|
|
178
|
+
function gufaReadFitCtor() {
|
|
179
|
+
const fit = window.FitAddon
|
|
180
|
+
if (fit !== undefined && fit !== null) {
|
|
181
|
+
if (typeof fit.FitAddon === 'function') return fit.FitAddon
|
|
182
|
+
if (typeof fit === 'function') return fit
|
|
183
|
+
}
|
|
184
|
+
const recovered = gufaRecoverUmd('FitAddon')
|
|
185
|
+
if (recovered !== undefined && recovered !== null) {
|
|
186
|
+
if (typeof recovered.FitAddon === 'function') {
|
|
187
|
+
window.FitAddon = recovered
|
|
188
|
+
return recovered.FitAddon
|
|
189
|
+
}
|
|
190
|
+
if (typeof recovered === 'function') {
|
|
191
|
+
window.FitAddon = { FitAddon: recovered }
|
|
192
|
+
return recovered
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 首次需要时注入 xterm.css / xterm.js / addon-fit.js。 */
|
|
199
|
+
function gufaTermLoadAssets() {
|
|
200
|
+
if (gufaTermAssetsPromise !== null) return gufaTermAssetsPromise
|
|
201
|
+
gufaTermAssetsPromise = (async () => {
|
|
202
|
+
await gufaTermIncludeCss()
|
|
203
|
+
// 清掉旧实例可能残留/误加载的脚本节点,在隐藏 module/exports 的环境下重新执行一次。
|
|
204
|
+
for (const id of [GUFA_TERM_XTERM_ID, GUFA_TERM_FIT_ID]) {
|
|
205
|
+
for (const node of Array.from(document.querySelectorAll(`script[data-gufa="${id}"]`))) node.remove()
|
|
206
|
+
}
|
|
207
|
+
await gufaTermLoadUmdScript(GUFA_TERM_XTERM_ID, G.asset('xterm/xterm.js'))
|
|
208
|
+
await gufaTermLoadUmdScript(GUFA_TERM_FIT_ID, G.asset('xterm/addon-fit.js'))
|
|
209
|
+
let TerminalCtor = gufaReadTerminalCtor()
|
|
210
|
+
let FitCtor = gufaReadFitCtor()
|
|
211
|
+
if (typeof TerminalCtor !== 'function') {
|
|
212
|
+
// HMR/重复加载时偶发「脚本已执行但全局未挂上」:延迟后重新注入一次
|
|
213
|
+
for (const node of Array.from(document.querySelectorAll(`script[data-gufa="${GUFA_TERM_XTERM_ID}"]`))) node.remove()
|
|
214
|
+
await gufaTermLoadUmdScript(GUFA_TERM_XTERM_ID, G.asset('xterm/xterm.js'))
|
|
215
|
+
TerminalCtor = gufaReadTerminalCtor()
|
|
216
|
+
}
|
|
217
|
+
if (typeof FitCtor !== 'function') {
|
|
218
|
+
for (const node of Array.from(document.querySelectorAll(`script[data-gufa="${GUFA_TERM_FIT_ID}"]`))) node.remove()
|
|
219
|
+
await gufaTermLoadUmdScript(GUFA_TERM_FIT_ID, G.asset('xterm/addon-fit.js'))
|
|
220
|
+
FitCtor = gufaReadFitCtor()
|
|
221
|
+
}
|
|
222
|
+
if (typeof TerminalCtor !== 'function') throw new Error('xterm.js 已加载,但 window.Terminal 不可用')
|
|
223
|
+
if (typeof FitCtor !== 'function') throw new Error('addon-fit.js 已加载,但 window.FitAddon.FitAddon 不可用')
|
|
224
|
+
return { Terminal: TerminalCtor, FitAddon: FitCtor }
|
|
225
|
+
})().catch((error) => {
|
|
226
|
+
// 失败后允许下次挂载重试(脚本节点已在失败时移除)
|
|
227
|
+
gufaTermAssetsPromise = null
|
|
228
|
+
throw error
|
|
229
|
+
})
|
|
230
|
+
return gufaTermAssetsPromise
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ── 主题 / 文本工具 ────────────────────────────────────────────────────── */
|
|
234
|
+
/** 解析 'rgb(...)' / 'rgba(...)' → [r,g,b,a]。 */
|
|
235
|
+
function gufaTermParseColor(value) {
|
|
236
|
+
const match = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/.exec(String(value ?? '').trim())
|
|
237
|
+
if (match === null) return null
|
|
238
|
+
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === undefined ? 1 : Number(match[4])]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function gufaTermHex(color) {
|
|
242
|
+
const c = color ?? [0, 0, 0]
|
|
243
|
+
return `#${[c[0], c[1], c[2]].map((part) => Math.max(0, Math.min(255, Math.round(part))).toString(16).padStart(2, '0')).join('')}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function gufaTermHexAlpha(color, alpha) {
|
|
247
|
+
const a = Math.max(0, Math.min(1, alpha))
|
|
248
|
+
return `${gufaTermHex(color)}${Math.round(a * 255).toString(16).padStart(2, '0')}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 可选的皮肤调色板(跟随滑动变祖)。 */
|
|
252
|
+
function gufaTermPalette(props) {
|
|
253
|
+
const palette = props.palette
|
|
254
|
+
return palette !== null && palette !== undefined && typeof palette === 'object' ? palette : null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* xterm 的暗/亮主题调色板。
|
|
259
|
+
* @param {string} mode 'dark' | 'light'
|
|
260
|
+
* @param {object|null} palette 插件合成的实色调色板(优先于 mode)
|
|
261
|
+
*/
|
|
262
|
+
function gufaTermTheme(mode, palette) {
|
|
263
|
+
const dark = palette !== null && palette !== undefined && typeof palette.isDark === 'boolean' ? palette.isDark : mode === 'dark'
|
|
264
|
+
if (dark) {
|
|
265
|
+
const darkTheme = {
|
|
266
|
+
background: '#1e1e1e',
|
|
267
|
+
foreground: '#d4d4d4',
|
|
268
|
+
cursor: '#d4d4d4',
|
|
269
|
+
cursorAccent: '#1e1e1e',
|
|
270
|
+
selectionBackground: '#264f78',
|
|
271
|
+
black: '#000000',
|
|
272
|
+
red: '#cd3131',
|
|
273
|
+
green: '#0dbc79',
|
|
274
|
+
yellow: '#e5e510',
|
|
275
|
+
blue: '#2472c8',
|
|
276
|
+
magenta: '#bc3fbc',
|
|
277
|
+
cyan: '#11a8cd',
|
|
278
|
+
white: '#e5e5e5',
|
|
279
|
+
brightBlack: '#666666',
|
|
280
|
+
brightRed: '#f14c4c',
|
|
281
|
+
brightGreen: '#23d18b',
|
|
282
|
+
brightYellow: '#f5f543',
|
|
283
|
+
brightBlue: '#3b8eea',
|
|
284
|
+
brightMagenta: '#d670d6',
|
|
285
|
+
brightCyan: '#29b8db',
|
|
286
|
+
brightWhite: '#e5e5e5',
|
|
287
|
+
}
|
|
288
|
+
return gufaApplyPaletteToTermTheme(darkTheme, palette)
|
|
289
|
+
}
|
|
290
|
+
const light = {
|
|
291
|
+
background: '#ffffff',
|
|
292
|
+
foreground: '#333333',
|
|
293
|
+
cursor: '#333333',
|
|
294
|
+
cursorAccent: '#ffffff',
|
|
295
|
+
selectionBackground: '#add6ff',
|
|
296
|
+
black: '#000000',
|
|
297
|
+
red: '#cd3131',
|
|
298
|
+
green: '#00bc00',
|
|
299
|
+
yellow: '#949800',
|
|
300
|
+
blue: '#0451a5',
|
|
301
|
+
magenta: '#bc05bc',
|
|
302
|
+
cyan: '#0598bc',
|
|
303
|
+
white: '#555555',
|
|
304
|
+
brightBlack: '#666666',
|
|
305
|
+
brightRed: '#cd3131',
|
|
306
|
+
brightGreen: '#14ce14',
|
|
307
|
+
brightYellow: '#b5ba00',
|
|
308
|
+
brightBlue: '#0451a5',
|
|
309
|
+
brightMagenta: '#bc05bc',
|
|
310
|
+
brightCyan: '#0598bc',
|
|
311
|
+
brightWhite: '#a5a5a5',
|
|
312
|
+
}
|
|
313
|
+
return gufaApplyPaletteToTermTheme(light, palette)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 把皮肤实色调色板叠加到 xterm 主题(背景/前景/光标/选区跟随滑动变祖)。 */
|
|
317
|
+
function gufaApplyPaletteToTermTheme(theme, palette) {
|
|
318
|
+
if (palette === null || palette === undefined || typeof palette !== 'object') return theme
|
|
319
|
+
const bg = gufaTermParseColor(palette.bg)
|
|
320
|
+
const fg = gufaTermParseColor(palette.text)
|
|
321
|
+
const accent = gufaTermParseColor(palette.accent !== undefined ? palette.accent : palette.text)
|
|
322
|
+
if (bg !== null) {
|
|
323
|
+
theme.background = gufaTermHex(bg)
|
|
324
|
+
theme.cursorAccent = gufaTermHex(bg)
|
|
325
|
+
}
|
|
326
|
+
if (fg !== null) {
|
|
327
|
+
theme.foreground = gufaTermHex(fg)
|
|
328
|
+
theme.cursor = gufaTermHex(fg)
|
|
329
|
+
}
|
|
330
|
+
if (accent !== null) {
|
|
331
|
+
theme.cursor = gufaTermHex(accent)
|
|
332
|
+
theme.selectionBackground = gufaTermHexAlpha(accent, 0.34)
|
|
333
|
+
}
|
|
334
|
+
return theme
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 降级模式下去掉常见 ANSI 控制序列,保留可读文本。 */
|
|
338
|
+
function gufaTermStripEscapes(text) {
|
|
339
|
+
return String(text)
|
|
340
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
341
|
+
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '')
|
|
342
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
343
|
+
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
344
|
+
.replace(/\r/g, '')
|
|
345
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 文本的 UTF-8 字节数(用于 since 偏移计数;纯 ASCII 直接取长度,避免频繁分配)。 */
|
|
349
|
+
function gufaTermByteLength(text) {
|
|
350
|
+
if (!/[^\x00-\x7f]/.test(text)) return text.length
|
|
351
|
+
try {
|
|
352
|
+
return new TextEncoder().encode(text).length
|
|
353
|
+
} catch {
|
|
354
|
+
return text.length
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** xterm 在回放历史输出时可能自动应答查询序列;回放窗口内忽略这些应答,避免污染 shell 输入。 */
|
|
359
|
+
function gufaTermIsProtocolReply(data) {
|
|
360
|
+
return /^\x1b\](?:10|11|12);(?:rgb:[\da-f]{4}\/[\da-f]{4}\/[\da-f]{4}|#[\da-f]{6})(?:\x07|\x1b\\)$/i.test(data)
|
|
361
|
+
|| /^\x1b\[\??[\d;]*[cR]$/.test(data)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 根据容器像素尺寸估算初始 cols/rows(xterm 挂载前的创建请求用)。 */
|
|
365
|
+
function gufaTermEstimateSize(width, height) {
|
|
366
|
+
const cols = Math.floor((Math.max(0, width) - 12) / 7.5)
|
|
367
|
+
const rows = Math.floor((Math.max(0, height) - 12) / 17)
|
|
368
|
+
return {
|
|
369
|
+
cols: Math.max(20, Math.min(300, cols)),
|
|
370
|
+
rows: Math.max(5, Math.min(120, rows)),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 终端标签页。 */
|
|
375
|
+
function GufaTerminalTab(props) {
|
|
376
|
+
const session = props.session
|
|
377
|
+
return h(
|
|
378
|
+
'div',
|
|
379
|
+
{
|
|
380
|
+
role: 'tab',
|
|
381
|
+
'aria-selected': props.active ? 'true' : 'false',
|
|
382
|
+
className: 'gufa-tab',
|
|
383
|
+
'data-active': props.active ? 'true' : undefined,
|
|
384
|
+
title: session.title,
|
|
385
|
+
onClick: props.onSelect,
|
|
386
|
+
style: { flex: '0 0 auto', height: 30, maxWidth: 190, opacity: session.exited ? 0.62 : 1 },
|
|
387
|
+
},
|
|
388
|
+
h(G.Icon, { name: 'terminal', size: 12 }),
|
|
389
|
+
h('span', { className: 'gufa-tab-name', style: { fontSize: 11.5 } }, session.title || G.translate('terminal.session')),
|
|
390
|
+
h(
|
|
391
|
+
'button',
|
|
392
|
+
{
|
|
393
|
+
type: 'button',
|
|
394
|
+
className: 'gufa-tab-close',
|
|
395
|
+
title: G.translate('terminal.close'),
|
|
396
|
+
'aria-label': G.translate('terminal.close'),
|
|
397
|
+
onClick: (event) => {
|
|
398
|
+
event.stopPropagation()
|
|
399
|
+
props.onClose()
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
h(G.Icon, { name: 'close', size: 10 }),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 单个终端视图:xterm 或降级纯文本;每个视图自己持有 xterm 实例与 WS 连接。
|
|
409
|
+
* 父组件始终渲染所有标签,非活动标签 display:none,以保留各自滚动位置与状态。
|
|
410
|
+
*/
|
|
411
|
+
function GufaTerminalView(props) {
|
|
412
|
+
const t = G.useT()
|
|
413
|
+
const session = props.session
|
|
414
|
+
const hostRef = useRef(null)
|
|
415
|
+
const preRef = useRef(null)
|
|
416
|
+
const inputRef = useRef(null)
|
|
417
|
+
const termRef = useRef(null)
|
|
418
|
+
const fitRef = useRef(null)
|
|
419
|
+
const openerRef = useRef(null)
|
|
420
|
+
const channelRef = useRef(null)
|
|
421
|
+
const ticketRef = useRef(null)
|
|
422
|
+
const activeRef = useRef(props.active === true)
|
|
423
|
+
const themeRef = useRef(props.theme)
|
|
424
|
+
const palette = gufaTermPalette(props)
|
|
425
|
+
const paletteRef = useRef(palette)
|
|
426
|
+
paletteRef.current = palette
|
|
427
|
+
const paletteKey = palette === null
|
|
428
|
+
? ''
|
|
429
|
+
: [palette.bg, palette.text, palette.layer, palette.accent, palette.isDark].join('|')
|
|
430
|
+
const [fallback, setFallback] = useState(false)
|
|
431
|
+
const [output, setOutput] = useState('')
|
|
432
|
+
const [notice, setNotice] = useState('')
|
|
433
|
+
const [connected, setConnected] = useState(false)
|
|
434
|
+
const [exited, setExited] = useState(session.exited === true)
|
|
435
|
+
const [inputValue, setInputValue] = useState('')
|
|
436
|
+
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
themeRef.current = props.theme
|
|
439
|
+
const term = termRef.current
|
|
440
|
+
if (term === null) return
|
|
441
|
+
const theme = gufaTermTheme(props.theme, palette)
|
|
442
|
+
gufaTermDebug('terminal-theme', `apply palette=${palette !== null} bg=${String(theme.background)}`)
|
|
443
|
+
try {
|
|
444
|
+
term.options.theme = theme
|
|
445
|
+
} catch {
|
|
446
|
+
try {
|
|
447
|
+
if (typeof term.setOption === 'function') term.setOption('theme', theme)
|
|
448
|
+
} catch {
|
|
449
|
+
/* 老版本 xterm 不支持即时换主题时忽略 */
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}, [props.theme, paletteKey])
|
|
453
|
+
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
if (props.assets.status === 'loading') return undefined
|
|
456
|
+
const fallbackMode = props.assets.status !== 'ready'
|
|
457
|
+
setFallback(fallbackMode)
|
|
458
|
+
const root = props.root
|
|
459
|
+
const terminalId = session.id
|
|
460
|
+
let disposed = false
|
|
461
|
+
/** 'xterm' | 'fallback':运行期若 xterm 创建失败会切到 fallback。 */
|
|
462
|
+
let mode = fallbackMode ? 'fallback' : 'xterm'
|
|
463
|
+
let term = null
|
|
464
|
+
let fit = null
|
|
465
|
+
let streamAbort = null
|
|
466
|
+
let streamEnded = false
|
|
467
|
+
let inputChain = Promise.resolve()
|
|
468
|
+
let retryTimer = null
|
|
469
|
+
let openFrame = 0
|
|
470
|
+
let openRetries = 0
|
|
471
|
+
let fitFrame = 0
|
|
472
|
+
let observer = null
|
|
473
|
+
let replayTimer = null
|
|
474
|
+
let replaying = false
|
|
475
|
+
let attempts = 0
|
|
476
|
+
let settled = false
|
|
477
|
+
let fatal = false
|
|
478
|
+
let receivedBytes = 0
|
|
479
|
+
let pendingOutput = ''
|
|
480
|
+
let loggedFirstOutput = false
|
|
481
|
+
let loggedFirstInput = false
|
|
482
|
+
let loggedFirstMessage = false
|
|
483
|
+
let loggedRawMessages = 0
|
|
484
|
+
const outbox = []
|
|
485
|
+
|
|
486
|
+
const setNoticeSafe = (message) => {
|
|
487
|
+
if (!disposed) setNotice(message)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const postInput = (data) => {
|
|
491
|
+
if (typeof data !== 'string' || data === '') return
|
|
492
|
+
const body = { root: root === null ? '' : root, id: terminalId, data }
|
|
493
|
+
inputChain = inputChain
|
|
494
|
+
.then(() => G.api('terminal.input', body))
|
|
495
|
+
.catch((error) => {
|
|
496
|
+
gufaTermDebug('terminal-io', `input-failed ${G.errorText(error)}`)
|
|
497
|
+
scheduleReconnect()
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const postResize = (cols, rows) => {
|
|
502
|
+
if (typeof cols !== 'number' || typeof rows !== 'number') return
|
|
503
|
+
const body = { root: root === null ? '' : root, id: terminalId, cols, rows }
|
|
504
|
+
inputChain = inputChain.then(() => G.api('terminal.resize', body)).catch(() => {})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const writeTerm = (data) => {
|
|
508
|
+
if (term !== null) {
|
|
509
|
+
try {
|
|
510
|
+
term.write(data)
|
|
511
|
+
} catch {
|
|
512
|
+
/* xterm 可能正在销毁 */
|
|
513
|
+
}
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
pendingOutput += data
|
|
517
|
+
if (pendingOutput.length > 512 * 1024) pendingOutput = pendingOutput.slice(-512 * 1024)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const appendFallback = (data) => {
|
|
521
|
+
const text = gufaTermStripEscapes(data)
|
|
522
|
+
if (text === '') return
|
|
523
|
+
setOutput((current) => {
|
|
524
|
+
const next = current + text
|
|
525
|
+
return next.length > 200 * 1024 ? next.slice(-200 * 1024) : next
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const writeOutput = (data) => {
|
|
530
|
+
if (mode === 'xterm') writeTerm(data)
|
|
531
|
+
else appendFallback(data)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const writeNoticeLine = (message) => {
|
|
535
|
+
setNoticeSafe(message)
|
|
536
|
+
const line = `\r\n\x1b[31m${message}\x1b[0m\r\n`
|
|
537
|
+
if (mode === 'xterm') writeTerm(line)
|
|
538
|
+
else appendFallback(line)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const fitNow = () => {
|
|
542
|
+
if (fit === null || term === null || disposed) return
|
|
543
|
+
const host = hostRef.current
|
|
544
|
+
if (host === null || host.clientWidth <= 0 || host.clientHeight <= 0) return
|
|
545
|
+
try {
|
|
546
|
+
fit.fit()
|
|
547
|
+
} catch {
|
|
548
|
+
/* 布局切换的中间帧可能失败,等下一次 ResizeObserver */
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const sendCurrentSize = () => {
|
|
553
|
+
if (term !== null) postResize(term.cols, term.rows)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const handleValue = (value) => {
|
|
557
|
+
if (disposed) return
|
|
558
|
+
if (value === null || typeof value !== 'object') return
|
|
559
|
+
if (!loggedFirstMessage) {
|
|
560
|
+
loggedFirstMessage = true
|
|
561
|
+
gufaTermDebug('terminal-io', `first-message type=${value.type}`)
|
|
562
|
+
}
|
|
563
|
+
if (value.type === 'output') {
|
|
564
|
+
if (!loggedFirstOutput) {
|
|
565
|
+
loggedFirstOutput = true
|
|
566
|
+
gufaTermDebug('terminal-io', `first-output len=${typeof value.data === 'string' ? value.data.length : 0}`)
|
|
567
|
+
}
|
|
568
|
+
if (typeof value.data !== 'string' || value.data === '') return
|
|
569
|
+
const bytes = gufaTermByteLength(value.data)
|
|
570
|
+
if (typeof value.offset === 'number' && Number.isFinite(value.offset)) receivedBytes = value.offset + bytes
|
|
571
|
+
else receivedBytes += bytes
|
|
572
|
+
if (value.replay === true) {
|
|
573
|
+
replaying = true
|
|
574
|
+
if (replayTimer !== null) clearTimeout(replayTimer)
|
|
575
|
+
replayTimer = window.setTimeout(() => {
|
|
576
|
+
replayTimer = null
|
|
577
|
+
replaying = false
|
|
578
|
+
}, 600)
|
|
579
|
+
}
|
|
580
|
+
writeOutput(value.data)
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
if (value.type === 'reset') {
|
|
584
|
+
receivedBytes = 0
|
|
585
|
+
pendingOutput = ''
|
|
586
|
+
if (term !== null) {
|
|
587
|
+
try {
|
|
588
|
+
term.reset()
|
|
589
|
+
} catch {
|
|
590
|
+
/* ignore */
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
setOutput('')
|
|
594
|
+
}
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
if (value.type === 'hello' || value.type === 'hello2' || value.type === 'pong' || value.type === 'ready') {
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
if (value.type === 'exit') {
|
|
601
|
+
settled = true
|
|
602
|
+
const code = typeof value.code === 'number' ? value.code : null
|
|
603
|
+
const label = code === null ? t('terminal.exited') : `${t('terminal.exited')} (${code})`
|
|
604
|
+
writeOutput(`\r\n\x1b[2m[${label}]\x1b[0m\r\n`)
|
|
605
|
+
setExited(true)
|
|
606
|
+
if (term !== null) {
|
|
607
|
+
try {
|
|
608
|
+
term.options.disableStdin = true
|
|
609
|
+
} catch {
|
|
610
|
+
/* ignore */
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
props.onExit(terminalId, code)
|
|
614
|
+
if (streamAbort !== null) {
|
|
615
|
+
try {
|
|
616
|
+
streamAbort()
|
|
617
|
+
} catch {
|
|
618
|
+
/* ignore */
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
if (value.type === 'error') {
|
|
624
|
+
gufaTermDebug('terminal-ws', `server-error ${typeof value.message === 'string' ? value.message : 'unknown'}`)
|
|
625
|
+
fatal = true
|
|
626
|
+
settled = true
|
|
627
|
+
writeNoticeLine(typeof value.message === 'string' && value.message !== '' ? value.message : t('common.error'))
|
|
628
|
+
setExited(true)
|
|
629
|
+
if (term !== null) {
|
|
630
|
+
try {
|
|
631
|
+
term.options.disableStdin = true
|
|
632
|
+
} catch {
|
|
633
|
+
/* ignore */
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
props.onExit(terminalId, null)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const scheduleReconnect = () => {
|
|
641
|
+
if (disposed || settled || fatal || streamEnded) return
|
|
642
|
+
attempts += 1
|
|
643
|
+
if (attempts > 8) {
|
|
644
|
+
writeNoticeLine(t('common.error'))
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
const delay = Math.min(5000, 400 * attempts)
|
|
648
|
+
retryTimer = window.setTimeout(() => {
|
|
649
|
+
retryTimer = null
|
|
650
|
+
startStream()
|
|
651
|
+
}, delay)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* ── 传输:直连真实 Host 端口的 SSE(带票据)──────────────────────────
|
|
655
|
+
* 桌面壳页面源是 dsh-app://app:
|
|
656
|
+
* - 相对路径 fetch 会走自定义协议,响应只能整体缓冲 → 不适合流;
|
|
657
|
+
* - 直连 ws:// 时 Chromium 上行可发、下行帧不达;
|
|
658
|
+
* 于是输出用「真实 Host 基址 + 票据」的 SSE,输入/尺寸仍走相对路径 POST。 */
|
|
659
|
+
const handleStreamEvent = (event) => {
|
|
660
|
+
if (disposed) return
|
|
661
|
+
if (event === null || typeof event !== 'object') return
|
|
662
|
+
if (loggedRawMessages < 6) {
|
|
663
|
+
loggedRawMessages += 1
|
|
664
|
+
gufaTermDebug('terminal-io', `event#${loggedRawMessages} type=${event.type}`)
|
|
665
|
+
}
|
|
666
|
+
handleValue(event)
|
|
667
|
+
if (event.type === 'ready') {
|
|
668
|
+
attempts = 0
|
|
669
|
+
setConnected(true)
|
|
670
|
+
fitNow()
|
|
671
|
+
if (term !== null) postResize(term.cols, term.rows)
|
|
672
|
+
if (activeRef.current === true && term !== null) {
|
|
673
|
+
try {
|
|
674
|
+
term.focus()
|
|
675
|
+
} catch {
|
|
676
|
+
/* ignore */
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const openStream = (ticket, since) => {
|
|
683
|
+
if (disposed) return
|
|
684
|
+
let base = location.href
|
|
685
|
+
try {
|
|
686
|
+
base = typeof G.serverBase === 'function' ? G.serverBase() : location.href
|
|
687
|
+
const url = new URL('gufa-code/terminal-stream', base)
|
|
688
|
+
url.searchParams.set('ticket', ticket)
|
|
689
|
+
url.searchParams.set('root', root === null ? '' : root)
|
|
690
|
+
url.searchParams.set('id', terminalId)
|
|
691
|
+
if (since !== undefined) url.searchParams.set('since', String(since))
|
|
692
|
+
const controller = new AbortController()
|
|
693
|
+
streamAbort = () => controller.abort()
|
|
694
|
+
gufaTermDebug('terminal-stream', `open ${url.origin} since=${since === undefined ? 'none' : since}`)
|
|
695
|
+
void (async () => {
|
|
696
|
+
try {
|
|
697
|
+
const response = await fetch(url.href, { method: 'GET', mode: 'cors', signal: controller.signal })
|
|
698
|
+
if (!response.ok || response.body === null) {
|
|
699
|
+
gufaTermDebug('terminal-stream', `http ${response.status}`)
|
|
700
|
+
if (!controller.signal.aborted) {
|
|
701
|
+
setConnected(false)
|
|
702
|
+
scheduleReconnect()
|
|
703
|
+
}
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
const reader = response.body.getReader()
|
|
707
|
+
const decoder = new TextDecoder()
|
|
708
|
+
let buffer = ''
|
|
709
|
+
for (;;) {
|
|
710
|
+
const { value, done } = await reader.read()
|
|
711
|
+
if (done) break
|
|
712
|
+
buffer += decoder.decode(value, { stream: true })
|
|
713
|
+
let index
|
|
714
|
+
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
715
|
+
const chunk = buffer.slice(0, index)
|
|
716
|
+
buffer = buffer.slice(index + 2)
|
|
717
|
+
for (const line of chunk.split('\n')) {
|
|
718
|
+
if (!line.startsWith('data: ')) continue
|
|
719
|
+
let event
|
|
720
|
+
try {
|
|
721
|
+
event = JSON.parse(line.slice(6))
|
|
722
|
+
} catch {
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
handleStreamEvent(event)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!controller.signal.aborted) {
|
|
730
|
+
setConnected(false)
|
|
731
|
+
scheduleReconnect()
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (controller.signal.aborted) return
|
|
735
|
+
gufaTermDebug('terminal-stream', `failed ${G.errorText(error)}`)
|
|
736
|
+
setConnected(false)
|
|
737
|
+
scheduleReconnect()
|
|
738
|
+
}
|
|
739
|
+
})()
|
|
740
|
+
} catch (error) {
|
|
741
|
+
gufaTermDebug('terminal-stream', `url-failed ${G.errorText(error)}`)
|
|
742
|
+
scheduleReconnect()
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const startStream = () => {
|
|
747
|
+
if (disposed) return
|
|
748
|
+
if (streamAbort !== null) {
|
|
749
|
+
try {
|
|
750
|
+
streamAbort()
|
|
751
|
+
} catch {
|
|
752
|
+
/* ignore */
|
|
753
|
+
}
|
|
754
|
+
streamAbort = null
|
|
755
|
+
}
|
|
756
|
+
streamEnded = false
|
|
757
|
+
const since = receivedBytes > 0 ? receivedBytes : undefined
|
|
758
|
+
G.api('terminal.wsTicket', {})
|
|
759
|
+
.then((value) => {
|
|
760
|
+
if (disposed) return
|
|
761
|
+
const ticket = value !== null && value !== undefined && typeof value.ticket === 'string' ? value.ticket : ''
|
|
762
|
+
if (ticket === '') {
|
|
763
|
+
scheduleReconnect()
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
openStream(ticket, since)
|
|
767
|
+
})
|
|
768
|
+
.catch((error) => {
|
|
769
|
+
gufaTermDebug('terminal-stream', `ticket-failed ${G.errorText(error)}`)
|
|
770
|
+
scheduleReconnect()
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* xterm 容器可能因为父面板隐藏而暂时 0 尺寸:等布局可用后再 open。 */
|
|
775
|
+
const tryOpen = () => {
|
|
776
|
+
openFrame = 0
|
|
777
|
+
if (disposed || mode !== 'xterm' || term !== null) return
|
|
778
|
+
const host = hostRef.current
|
|
779
|
+
if (host === null) return
|
|
780
|
+
if (host.clientWidth <= 0 || host.clientHeight <= 0) {
|
|
781
|
+
if (openRetries < 600) {
|
|
782
|
+
openRetries += 1
|
|
783
|
+
openFrame = window.requestAnimationFrame(tryOpen)
|
|
784
|
+
}
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
gufaTermDebug('terminal-xterm', `open host=${host.clientWidth}x${host.clientHeight} palette=${palette !== null} isDark=${palette === null ? '?' : palette.isDark === true}`)
|
|
788
|
+
try {
|
|
789
|
+
const terminal = new props.assets.Terminal({
|
|
790
|
+
allowProposedApi: false,
|
|
791
|
+
convertEol: false,
|
|
792
|
+
cursorBlink: true,
|
|
793
|
+
cursorStyle: 'bar',
|
|
794
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
|
795
|
+
fontSize: 12.5,
|
|
796
|
+
lineHeight: 1.15,
|
|
797
|
+
scrollback: 5000,
|
|
798
|
+
theme: gufaTermTheme(themeRef.current, paletteRef.current),
|
|
799
|
+
})
|
|
800
|
+
term = terminal
|
|
801
|
+
termRef.current = terminal
|
|
802
|
+
try {
|
|
803
|
+
fit = new props.assets.FitAddon()
|
|
804
|
+
term.loadAddon(fit)
|
|
805
|
+
fitRef.current = fit
|
|
806
|
+
} catch {
|
|
807
|
+
fit = null
|
|
808
|
+
fitRef.current = null
|
|
809
|
+
}
|
|
810
|
+
term.open(host)
|
|
811
|
+
if (pendingOutput !== '') {
|
|
812
|
+
const data = pendingOutput
|
|
813
|
+
pendingOutput = ''
|
|
814
|
+
terminal.write(data)
|
|
815
|
+
}
|
|
816
|
+
terminal.onData((data) => {
|
|
817
|
+
if (replaying && gufaTermIsProtocolReply(data)) return
|
|
818
|
+
if (!loggedFirstInput) {
|
|
819
|
+
loggedFirstInput = true
|
|
820
|
+
gufaTermDebug('terminal-io', `first-key len=${data.length}`)
|
|
821
|
+
}
|
|
822
|
+
const channel = channelRef.current
|
|
823
|
+
if (channel !== null) channel.send(data)
|
|
824
|
+
})
|
|
825
|
+
terminal.onResize(({ cols, rows }) => {
|
|
826
|
+
if (replaying) return
|
|
827
|
+
postResize(cols, rows)
|
|
828
|
+
})
|
|
829
|
+
try {
|
|
830
|
+
if (typeof terminal.onTitleChange === 'function') {
|
|
831
|
+
terminal.onTitleChange((title) => {
|
|
832
|
+
if (typeof title === 'string' && title.trim() !== '') props.onTitle(terminalId, title.trim())
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
/* 标题同步失败不影响使用 */
|
|
837
|
+
}
|
|
838
|
+
const onPointerDown = () => {
|
|
839
|
+
try {
|
|
840
|
+
terminal.focus()
|
|
841
|
+
} catch {
|
|
842
|
+
/* ignore */
|
|
843
|
+
}
|
|
844
|
+
gufaTermDebug('terminal-focus', `pointerdown active=${document.activeElement === null ? 'null' : (document.activeElement.tagName ?? '?')}`)
|
|
845
|
+
}
|
|
846
|
+
host.addEventListener('pointerdown', onPointerDown, true)
|
|
847
|
+
try {
|
|
848
|
+
terminal.focus()
|
|
849
|
+
} catch {
|
|
850
|
+
/* ignore */
|
|
851
|
+
}
|
|
852
|
+
gufaTermDebug('terminal-focus', `focused active=${document.activeElement === null ? 'null' : (document.activeElement.tagName ?? '?')}`)
|
|
853
|
+
observer = new ResizeObserver(() => {
|
|
854
|
+
if (fitFrame !== 0) return
|
|
855
|
+
fitFrame = window.requestAnimationFrame(() => {
|
|
856
|
+
fitFrame = 0
|
|
857
|
+
fitNow()
|
|
858
|
+
})
|
|
859
|
+
})
|
|
860
|
+
observer.observe(host)
|
|
861
|
+
fitNow()
|
|
862
|
+
sendCurrentSize()
|
|
863
|
+
} catch (error) {
|
|
864
|
+
gufaTermDebug('terminal-xterm', `open-failed ${G.errorText(error)}`)
|
|
865
|
+
mode = 'fallback'
|
|
866
|
+
if (term !== null) {
|
|
867
|
+
try {
|
|
868
|
+
term.dispose()
|
|
869
|
+
} catch {
|
|
870
|
+
/* ignore */
|
|
871
|
+
}
|
|
872
|
+
term = null
|
|
873
|
+
termRef.current = null
|
|
874
|
+
fit = null
|
|
875
|
+
fitRef.current = null
|
|
876
|
+
}
|
|
877
|
+
if (observer !== null) {
|
|
878
|
+
try {
|
|
879
|
+
observer.disconnect()
|
|
880
|
+
} catch {
|
|
881
|
+
/* ignore */
|
|
882
|
+
}
|
|
883
|
+
observer = null
|
|
884
|
+
}
|
|
885
|
+
setFallback(true)
|
|
886
|
+
setNoticeSafe(G.errorText(error))
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const offClearRequest = G.bus === null || G.bus === undefined ? () => {} : G.bus.on(`terminal:clear:${terminalId}`, () => {
|
|
891
|
+
pendingOutput = ''
|
|
892
|
+
if (term !== null) {
|
|
893
|
+
try {
|
|
894
|
+
term.clear()
|
|
895
|
+
} catch {
|
|
896
|
+
try {
|
|
897
|
+
term.reset()
|
|
898
|
+
} catch {
|
|
899
|
+
/* ignore */
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (activeRef.current === true) {
|
|
903
|
+
try {
|
|
904
|
+
term.focus()
|
|
905
|
+
} catch {
|
|
906
|
+
/* ignore */
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
setOutput('')
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
const channel = {
|
|
914
|
+
/** 写入当前终端(WS 未连接时先排队)。 */
|
|
915
|
+
send(data) {
|
|
916
|
+
postInput(data)
|
|
917
|
+
},
|
|
918
|
+
fit() {
|
|
919
|
+
fitNow()
|
|
920
|
+
sendCurrentSize()
|
|
921
|
+
},
|
|
922
|
+
focus() {
|
|
923
|
+
if (term !== null) {
|
|
924
|
+
try {
|
|
925
|
+
term.focus()
|
|
926
|
+
} catch {
|
|
927
|
+
/* ignore */
|
|
928
|
+
}
|
|
929
|
+
} else if (inputRef.current !== null) {
|
|
930
|
+
inputRef.current.focus()
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
}
|
|
934
|
+
channelRef.current = channel
|
|
935
|
+
/** 隐藏标签首次挂载时容器为 0 尺寸,切为活动标签后由这里补一次 open 尝试。 */
|
|
936
|
+
openerRef.current = () => {
|
|
937
|
+
if (term !== null || mode !== 'xterm' || disposed) return
|
|
938
|
+
if (openFrame !== 0) return
|
|
939
|
+
openRetries = 0
|
|
940
|
+
openFrame = window.requestAnimationFrame(tryOpen)
|
|
941
|
+
}
|
|
942
|
+
props.register(terminalId, channel)
|
|
943
|
+
|
|
944
|
+
startStream()
|
|
945
|
+
if (mode === 'xterm') tryOpen()
|
|
946
|
+
|
|
947
|
+
return () => {
|
|
948
|
+
disposed = true
|
|
949
|
+
if (retryTimer !== null) clearTimeout(retryTimer)
|
|
950
|
+
if (replayTimer !== null) clearTimeout(replayTimer)
|
|
951
|
+
if (openFrame !== 0) window.cancelAnimationFrame(openFrame)
|
|
952
|
+
if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame)
|
|
953
|
+
if (observer !== null) {
|
|
954
|
+
try {
|
|
955
|
+
observer.disconnect()
|
|
956
|
+
} catch {
|
|
957
|
+
/* ignore */
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (streamAbort !== null) {
|
|
961
|
+
try {
|
|
962
|
+
streamAbort()
|
|
963
|
+
} catch {
|
|
964
|
+
/* ignore */
|
|
965
|
+
}
|
|
966
|
+
streamAbort = null
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
offClearRequest()
|
|
970
|
+
} catch {
|
|
971
|
+
/* ignore */
|
|
972
|
+
}
|
|
973
|
+
channelRef.current = null
|
|
974
|
+
openerRef.current = null
|
|
975
|
+
props.unregister(terminalId)
|
|
976
|
+
if (term !== null) {
|
|
977
|
+
try {
|
|
978
|
+
term.dispose()
|
|
979
|
+
} catch {
|
|
980
|
+
/* ignore */
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
term = null
|
|
984
|
+
termRef.current = null
|
|
985
|
+
fit = null
|
|
986
|
+
fitRef.current = null
|
|
987
|
+
}
|
|
988
|
+
// assets/root/session.id 变化时重建连接与终端;回调由父组件保证稳定
|
|
989
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
990
|
+
}, [props.assets.status, props.root, session.id])
|
|
991
|
+
|
|
992
|
+
/* 切到活动标签:重新 fit(父容器此时可见)并聚焦;隐藏时不打扰。 */
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
activeRef.current = props.active === true
|
|
995
|
+
if (props.active !== true || props.visible === false) return undefined
|
|
996
|
+
if (fallback) {
|
|
997
|
+
if (inputRef.current !== null) inputRef.current.focus()
|
|
998
|
+
return undefined
|
|
999
|
+
}
|
|
1000
|
+
const frame = window.requestAnimationFrame(() => {
|
|
1001
|
+
if (openerRef.current !== null) openerRef.current()
|
|
1002
|
+
const fit = fitRef.current
|
|
1003
|
+
if (fit !== null) {
|
|
1004
|
+
try {
|
|
1005
|
+
fit.fit()
|
|
1006
|
+
} catch {
|
|
1007
|
+
/* ignore */
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const term = termRef.current
|
|
1011
|
+
if (term !== null) {
|
|
1012
|
+
try {
|
|
1013
|
+
term.focus()
|
|
1014
|
+
} catch {
|
|
1015
|
+
/* ignore */
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
1019
|
+
return () => window.cancelAnimationFrame(frame)
|
|
1020
|
+
}, [props.active, props.visible, fallback, props.assets.status])
|
|
1021
|
+
|
|
1022
|
+
/* 降级模式输出自动滚到底部(用户向上翻阅时不打扰)。 */
|
|
1023
|
+
useEffect(() => {
|
|
1024
|
+
const node = preRef.current
|
|
1025
|
+
if (node === null) return
|
|
1026
|
+
const pinned = node.scrollTop + node.clientHeight >= node.scrollHeight - 48
|
|
1027
|
+
if (pinned) node.scrollTop = node.scrollHeight
|
|
1028
|
+
}, [output])
|
|
1029
|
+
|
|
1030
|
+
const monoFont = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'
|
|
1031
|
+
const fallbackTheme = gufaTermTheme(props.theme, palette)
|
|
1032
|
+
const background = fallbackTheme.background
|
|
1033
|
+
const foreground = fallbackTheme.foreground
|
|
1034
|
+
|
|
1035
|
+
const body = fallback
|
|
1036
|
+
? h(
|
|
1037
|
+
React.Fragment,
|
|
1038
|
+
null,
|
|
1039
|
+
h(
|
|
1040
|
+
'pre',
|
|
1041
|
+
{
|
|
1042
|
+
ref: preRef,
|
|
1043
|
+
className: 'gufa-scroll',
|
|
1044
|
+
style: {
|
|
1045
|
+
flex: '1 1 auto',
|
|
1046
|
+
minHeight: 0,
|
|
1047
|
+
margin: 0,
|
|
1048
|
+
padding: '8px 10px',
|
|
1049
|
+
overflow: 'auto',
|
|
1050
|
+
whiteSpace: 'pre-wrap',
|
|
1051
|
+
wordBreak: 'break-word',
|
|
1052
|
+
fontFamily: monoFont,
|
|
1053
|
+
fontSize: 12.5,
|
|
1054
|
+
lineHeight: 1.5,
|
|
1055
|
+
color: foreground,
|
|
1056
|
+
background,
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
output,
|
|
1060
|
+
),
|
|
1061
|
+
h(
|
|
1062
|
+
'div',
|
|
1063
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px', borderTop: '1px solid var(--gufa-border)', background } },
|
|
1064
|
+
h('span', { style: { color: foreground, opacity: 0.6, fontFamily: monoFont, fontSize: 12 } }, '$'),
|
|
1065
|
+
h('input', {
|
|
1066
|
+
ref: inputRef,
|
|
1067
|
+
className: 'gufa-input',
|
|
1068
|
+
value: inputValue,
|
|
1069
|
+
spellCheck: false,
|
|
1070
|
+
autoComplete: 'off',
|
|
1071
|
+
autoCapitalize: 'off',
|
|
1072
|
+
placeholder: t('terminal.session'),
|
|
1073
|
+
onChange: (event) => setInputValue(event.target.value),
|
|
1074
|
+
onKeyDown: (event) => {
|
|
1075
|
+
if (event.key !== 'Enter') return
|
|
1076
|
+
event.preventDefault()
|
|
1077
|
+
const value = inputValue
|
|
1078
|
+
setInputValue('')
|
|
1079
|
+
const channel = channelRef.current
|
|
1080
|
+
if (channel !== null) channel.send(value === '' ? '\r' : `${value}\r`)
|
|
1081
|
+
},
|
|
1082
|
+
style: { flex: '1 1 auto', fontFamily: monoFont, fontSize: 12, height: 26 },
|
|
1083
|
+
}),
|
|
1084
|
+
h(
|
|
1085
|
+
G.IconButton,
|
|
1086
|
+
{
|
|
1087
|
+
title: t('chat.send'),
|
|
1088
|
+
onClick: () => {
|
|
1089
|
+
const value = inputValue
|
|
1090
|
+
setInputValue('')
|
|
1091
|
+
const channel = channelRef.current
|
|
1092
|
+
if (channel !== null) channel.send(value === '' ? '\r' : `${value}\r`)
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
h(G.Icon, { name: 'arrow-right', size: 13 }),
|
|
1096
|
+
),
|
|
1097
|
+
),
|
|
1098
|
+
)
|
|
1099
|
+
: h('div', { ref: hostRef, style: { position: 'absolute', top: 4, right: 2, bottom: 2, left: 6 } })
|
|
1100
|
+
|
|
1101
|
+
return h(
|
|
1102
|
+
'div',
|
|
1103
|
+
{
|
|
1104
|
+
style: {
|
|
1105
|
+
display: props.active === true ? 'flex' : 'none',
|
|
1106
|
+
flexDirection: 'column',
|
|
1107
|
+
flex: '1 1 auto',
|
|
1108
|
+
minHeight: 0,
|
|
1109
|
+
minWidth: 0,
|
|
1110
|
+
position: 'relative',
|
|
1111
|
+
background,
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
body,
|
|
1115
|
+
notice !== ''
|
|
1116
|
+
? h(
|
|
1117
|
+
'div',
|
|
1118
|
+
{
|
|
1119
|
+
style: {
|
|
1120
|
+
position: 'absolute',
|
|
1121
|
+
top: 6,
|
|
1122
|
+
left: 8,
|
|
1123
|
+
right: 8,
|
|
1124
|
+
zIndex: 2,
|
|
1125
|
+
display: 'flex',
|
|
1126
|
+
alignItems: 'center',
|
|
1127
|
+
gap: 6,
|
|
1128
|
+
padding: '4px 8px',
|
|
1129
|
+
borderRadius: 6,
|
|
1130
|
+
fontSize: 11.5,
|
|
1131
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
1132
|
+
background: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, var(--dsw-alias-bg-overlay, #fff))',
|
|
1133
|
+
border: '1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 38%, transparent)',
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
h(G.Icon, { name: 'warn', size: 12 }),
|
|
1137
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, notice),
|
|
1138
|
+
h(
|
|
1139
|
+
G.IconButton,
|
|
1140
|
+
{ title: t('common.close'), onClick: () => setNotice(''), style: { width: 20, height: 20 } },
|
|
1141
|
+
h(G.Icon, { name: 'close', size: 11 }),
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
: null,
|
|
1145
|
+
!connected && !fallback && !exited && notice === ''
|
|
1146
|
+
? h(
|
|
1147
|
+
'div',
|
|
1148
|
+
{ style: { position: 'absolute', right: 10, bottom: 8, display: 'flex', alignItems: 'center', gap: 6, color: foreground, opacity: 0.55, fontSize: 11, pointerEvents: 'none' } },
|
|
1149
|
+
h(G.Spinner),
|
|
1150
|
+
t('common.loading'),
|
|
1151
|
+
)
|
|
1152
|
+
: null,
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/* ── 终端面板 ───────────────────────────────────────────────────────────── */
|
|
1157
|
+
G.TerminalPane = function TerminalPane(props) {
|
|
1158
|
+
const t = G.useT()
|
|
1159
|
+
const root = typeof props.root === 'string' && props.root !== '' ? props.root : null
|
|
1160
|
+
const theme = props.theme === 'light' ? 'light' : 'dark'
|
|
1161
|
+
const visible = props.visible !== false
|
|
1162
|
+
const [assets, setAssets] = useState({ status: 'loading', Terminal: null, FitAddon: null, error: '' })
|
|
1163
|
+
const [sessions, setSessions] = useState([])
|
|
1164
|
+
const [activeId, setActiveId] = useState(null)
|
|
1165
|
+
const [error, setError] = useState('')
|
|
1166
|
+
const [creating, setCreating] = useState(false)
|
|
1167
|
+
const rootRef = useRef(root)
|
|
1168
|
+
const sizeRef = useRef(null)
|
|
1169
|
+
const tabStripRef = useRef(null)
|
|
1170
|
+
const channelsRef = useRef(new Map())
|
|
1171
|
+
const pendingInputRef = useRef([])
|
|
1172
|
+
const sessionsRef = useRef(sessions)
|
|
1173
|
+
const activeRef = useRef(activeId)
|
|
1174
|
+
const creatingRef = useRef(false)
|
|
1175
|
+
const bootRef = useRef(0)
|
|
1176
|
+
const aliveRef = useRef(true)
|
|
1177
|
+
rootRef.current = root
|
|
1178
|
+
|
|
1179
|
+
useEffect(() => {
|
|
1180
|
+
sessionsRef.current = sessions
|
|
1181
|
+
}, [sessions])
|
|
1182
|
+
useEffect(() => {
|
|
1183
|
+
activeRef.current = activeId
|
|
1184
|
+
}, [activeId])
|
|
1185
|
+
useEffect(() => () => {
|
|
1186
|
+
aliveRef.current = false
|
|
1187
|
+
}, [])
|
|
1188
|
+
|
|
1189
|
+
/* xterm 资产:首次挂载按需加载,失败降级。 */
|
|
1190
|
+
useEffect(() => {
|
|
1191
|
+
let cancelled = false
|
|
1192
|
+
gufaTermLoadAssets()
|
|
1193
|
+
.then((value) => {
|
|
1194
|
+
if (cancelled) return
|
|
1195
|
+
setAssets({ status: 'ready', Terminal: value.Terminal, FitAddon: value.FitAddon, error: '' })
|
|
1196
|
+
})
|
|
1197
|
+
.catch((loadError) => {
|
|
1198
|
+
gufaTermDebug('terminal-assets', `failed ${G.errorText(loadError)}`)
|
|
1199
|
+
if (cancelled) return
|
|
1200
|
+
setAssets({ status: 'failed', Terminal: null, FitAddon: null, error: G.errorText(loadError) })
|
|
1201
|
+
})
|
|
1202
|
+
return () => {
|
|
1203
|
+
cancelled = true
|
|
1204
|
+
}
|
|
1205
|
+
}, [])
|
|
1206
|
+
|
|
1207
|
+
/** 把队列里的输入写入当前活动终端。 */
|
|
1208
|
+
const drainPending = () => {
|
|
1209
|
+
if (pendingInputRef.current.length === 0) return
|
|
1210
|
+
const id = activeRef.current
|
|
1211
|
+
const channel = id === null ? undefined : channelsRef.current.get(id)
|
|
1212
|
+
if (channel === undefined) return
|
|
1213
|
+
const queue = pendingInputRef.current
|
|
1214
|
+
pendingInputRef.current = []
|
|
1215
|
+
for (const text of queue) channel.send(text)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const createSession = useCallback(async (generation = bootRef.current) => {
|
|
1219
|
+
const targetRoot = rootRef.current
|
|
1220
|
+
if (targetRoot === null || creatingRef.current) return null
|
|
1221
|
+
creatingRef.current = true
|
|
1222
|
+
setCreating(true)
|
|
1223
|
+
try {
|
|
1224
|
+
const box = sizeRef.current
|
|
1225
|
+
const dims = gufaTermEstimateSize(box === null ? 0 : box.clientWidth, box === null ? 0 : box.clientHeight)
|
|
1226
|
+
const value = await G.api('terminal.create', { root: targetRoot, cols: dims.cols, rows: dims.rows })
|
|
1227
|
+
// 组件已卸载 / 创建期间项目或挂载代次已变:回收这个孤儿终端
|
|
1228
|
+
if (!aliveRef.current || bootRef.current !== generation || rootRef.current !== targetRoot) {
|
|
1229
|
+
void G.api('terminal.close', { root: targetRoot, id: value.id }).catch(() => {})
|
|
1230
|
+
return null
|
|
1231
|
+
}
|
|
1232
|
+
const created = {
|
|
1233
|
+
id: value.id,
|
|
1234
|
+
title: typeof value.title === 'string' && value.title !== '' ? value.title : G.translate('terminal.session'),
|
|
1235
|
+
exited: false,
|
|
1236
|
+
exitCode: null,
|
|
1237
|
+
}
|
|
1238
|
+
setSessions((current) => (current.some((item) => item.id === created.id) ? current : [...current, created]))
|
|
1239
|
+
setActiveId((current) => current ?? created.id)
|
|
1240
|
+
setError('')
|
|
1241
|
+
return created
|
|
1242
|
+
} catch (createError) {
|
|
1243
|
+
gufaTermDebug('terminal-create', `failed ${G.errorText(createError)}`)
|
|
1244
|
+
if (aliveRef.current) setError(G.errorText(createError))
|
|
1245
|
+
return null
|
|
1246
|
+
} finally {
|
|
1247
|
+
creatingRef.current = false
|
|
1248
|
+
if (aliveRef.current) setCreating(false)
|
|
1249
|
+
}
|
|
1250
|
+
}, [])
|
|
1251
|
+
|
|
1252
|
+
/* 根目录变化:恢复该项目的终端列表,没有则建第一个。 */
|
|
1253
|
+
useEffect(() => {
|
|
1254
|
+
const generation = ++bootRef.current
|
|
1255
|
+
const stale = () => !aliveRef.current || bootRef.current !== generation || rootRef.current !== root
|
|
1256
|
+
setError('')
|
|
1257
|
+
if (root === null) {
|
|
1258
|
+
setSessions([])
|
|
1259
|
+
setActiveId(null)
|
|
1260
|
+
return undefined
|
|
1261
|
+
}
|
|
1262
|
+
setSessions([])
|
|
1263
|
+
setActiveId(null)
|
|
1264
|
+
;(async () => {
|
|
1265
|
+
try {
|
|
1266
|
+
const value = await G.api('terminal.list', { root })
|
|
1267
|
+
if (stale()) return
|
|
1268
|
+
const list = Array.isArray(value?.terminals) ? value.terminals : []
|
|
1269
|
+
if (list.length === 0) {
|
|
1270
|
+
await createSession(generation)
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1273
|
+
const restored = []
|
|
1274
|
+
for (const item of list) {
|
|
1275
|
+
if (item === null || typeof item !== 'object' || typeof item.id !== 'string') continue
|
|
1276
|
+
restored.push({
|
|
1277
|
+
id: item.id,
|
|
1278
|
+
title: typeof item.title === 'string' && item.title !== '' ? item.title : G.translate('terminal.session'),
|
|
1279
|
+
exited: item.alive !== true,
|
|
1280
|
+
exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
|
|
1281
|
+
})
|
|
1282
|
+
}
|
|
1283
|
+
setSessions(restored)
|
|
1284
|
+
if (restored.length > 0) setActiveId(restored[0].id)
|
|
1285
|
+
} catch (listError) {
|
|
1286
|
+
if (stale()) return
|
|
1287
|
+
setError(G.errorText(listError))
|
|
1288
|
+
}
|
|
1289
|
+
})()
|
|
1290
|
+
return undefined
|
|
1291
|
+
}, [root, createSession])
|
|
1292
|
+
|
|
1293
|
+
/* 快捷键:清除当前终端内容 / 新建终端。 */
|
|
1294
|
+
useEffect(() => {
|
|
1295
|
+
if (G.bus === null || G.bus === undefined) return undefined
|
|
1296
|
+
const offClear = G.bus.on('terminal:clear', () => {
|
|
1297
|
+
const id = activeRef.current
|
|
1298
|
+
if (id === null) return
|
|
1299
|
+
G.bus.emit(`terminal:clear:${id}`)
|
|
1300
|
+
})
|
|
1301
|
+
const offNew = G.bus.on('terminal:new', () => {
|
|
1302
|
+
void createSession()
|
|
1303
|
+
})
|
|
1304
|
+
return () => {
|
|
1305
|
+
try {
|
|
1306
|
+
offClear()
|
|
1307
|
+
} catch {
|
|
1308
|
+
/* ignore */
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
offNew()
|
|
1312
|
+
} catch {
|
|
1313
|
+
/* ignore */
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}, [createSession])
|
|
1317
|
+
|
|
1318
|
+
/* G.bus 'terminal:send'(SSH 快捷连接等):写入当前终端;没有终端时先建一个。 */
|
|
1319
|
+
useEffect(() => {
|
|
1320
|
+
return G.bus.on('terminal:send', (detail) => {
|
|
1321
|
+
const text = detail !== null && typeof detail === 'object' && typeof detail.text === 'string' ? detail.text : ''
|
|
1322
|
+
if (text === '') return
|
|
1323
|
+
pendingInputRef.current.push(text)
|
|
1324
|
+
drainPending()
|
|
1325
|
+
if (rootRef.current !== null && sessionsRef.current.length === 0 && !creatingRef.current) void createSession()
|
|
1326
|
+
})
|
|
1327
|
+
// drainPending 只依赖 ref,无需列为依赖
|
|
1328
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1329
|
+
}, [createSession])
|
|
1330
|
+
|
|
1331
|
+
/* 标签过多时把活动标签滚入可视区。 */
|
|
1332
|
+
useEffect(() => {
|
|
1333
|
+
const node = tabStripRef.current
|
|
1334
|
+
if (node === null || typeof node.querySelector !== 'function') return
|
|
1335
|
+
const activeTab = node.querySelector('[data-active="true"]')
|
|
1336
|
+
if (activeTab !== null && typeof activeTab.scrollIntoView === 'function') {
|
|
1337
|
+
try {
|
|
1338
|
+
activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
1339
|
+
} catch {
|
|
1340
|
+
/* 老浏览器忽略 */
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}, [activeId, sessions.length])
|
|
1344
|
+
|
|
1345
|
+
/* 活动终端变化后补发排队输入。 */
|
|
1346
|
+
useEffect(() => {
|
|
1347
|
+
activeRef.current = activeId
|
|
1348
|
+
drainPending()
|
|
1349
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1350
|
+
}, [activeId, sessions.length])
|
|
1351
|
+
|
|
1352
|
+
const registerChannel = useCallback((id, channel) => {
|
|
1353
|
+
channelsRef.current.set(id, channel)
|
|
1354
|
+
if (activeRef.current === null || activeRef.current === id) drainPending()
|
|
1355
|
+
}, [])
|
|
1356
|
+
const unregisterChannel = useCallback((id) => {
|
|
1357
|
+
channelsRef.current.delete(id)
|
|
1358
|
+
}, [])
|
|
1359
|
+
|
|
1360
|
+
const closeSession = useCallback((id) => {
|
|
1361
|
+
const targetRoot = rootRef.current
|
|
1362
|
+
const remaining = sessionsRef.current.filter((item) => item.id !== id)
|
|
1363
|
+
setSessions(remaining)
|
|
1364
|
+
setActiveId((current) => {
|
|
1365
|
+
if (current !== id) return current
|
|
1366
|
+
return remaining.length === 0 ? null : remaining[remaining.length - 1].id
|
|
1367
|
+
})
|
|
1368
|
+
if (targetRoot !== null) {
|
|
1369
|
+
void G.api('terminal.close', { root: targetRoot, id }).catch((closeError) => {
|
|
1370
|
+
if (aliveRef.current) setError(G.errorText(closeError))
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
}, [])
|
|
1374
|
+
|
|
1375
|
+
const handleExit = useCallback((id, code) => {
|
|
1376
|
+
setSessions((current) => current.map((item) => (item.id === id ? { ...item, exited: true, exitCode: typeof code === 'number' ? code : null } : item)))
|
|
1377
|
+
}, [])
|
|
1378
|
+
|
|
1379
|
+
const handleTitle = useCallback((id, title) => {
|
|
1380
|
+
setSessions((current) => {
|
|
1381
|
+
let changed = false
|
|
1382
|
+
const next = current.map((item) => {
|
|
1383
|
+
if (item.id !== id) return item
|
|
1384
|
+
if (item.title === title) return item
|
|
1385
|
+
changed = true
|
|
1386
|
+
return { ...item, title }
|
|
1387
|
+
})
|
|
1388
|
+
return changed ? next : current
|
|
1389
|
+
})
|
|
1390
|
+
}, [])
|
|
1391
|
+
|
|
1392
|
+
const height = typeof props.height === 'number' && props.height > 0 ? props.height : 230
|
|
1393
|
+
const tabStrip = []
|
|
1394
|
+
for (const session of sessions) {
|
|
1395
|
+
tabStrip.push(
|
|
1396
|
+
h(GufaTerminalTab, {
|
|
1397
|
+
key: session.id,
|
|
1398
|
+
session,
|
|
1399
|
+
active: session.id === activeId,
|
|
1400
|
+
onSelect: () => setActiveId(session.id),
|
|
1401
|
+
onClose: () => closeSession(session.id),
|
|
1402
|
+
}),
|
|
1403
|
+
)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const body = (() => {
|
|
1407
|
+
if (root === null) {
|
|
1408
|
+
return h(G.Empty, { icon: 'terminal', title: t('explorer.openProjectFirst'), hint: t('app.noProjectHint') })
|
|
1409
|
+
}
|
|
1410
|
+
if (assets.status === 'loading') {
|
|
1411
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, height: '100%', color: 'var(--gufa-text-dim)', fontSize: 12 } }, h(G.Spinner), t('common.loading'))
|
|
1412
|
+
}
|
|
1413
|
+
if (sessions.length === 0) {
|
|
1414
|
+
return h(
|
|
1415
|
+
'div',
|
|
1416
|
+
{ style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, height: '100%' } },
|
|
1417
|
+
h(G.Button, { icon: 'plus', disabled: creating, onClick: () => void createSession() }, t('terminal.new')),
|
|
1418
|
+
)
|
|
1419
|
+
}
|
|
1420
|
+
const views = []
|
|
1421
|
+
for (const session of sessions) {
|
|
1422
|
+
views.push(
|
|
1423
|
+
h(GufaTerminalView, {
|
|
1424
|
+
key: session.id,
|
|
1425
|
+
session,
|
|
1426
|
+
root,
|
|
1427
|
+
active: session.id === activeId,
|
|
1428
|
+
visible,
|
|
1429
|
+
theme,
|
|
1430
|
+
palette: props.palette,
|
|
1431
|
+
assets,
|
|
1432
|
+
register: registerChannel,
|
|
1433
|
+
unregister: unregisterChannel,
|
|
1434
|
+
onExit: handleExit,
|
|
1435
|
+
onTitle: handleTitle,
|
|
1436
|
+
}),
|
|
1437
|
+
)
|
|
1438
|
+
}
|
|
1439
|
+
return views
|
|
1440
|
+
})()
|
|
1441
|
+
|
|
1442
|
+
const canCreate = root !== null && assets.status !== 'loading' && sessions.length < 8 && creating !== true
|
|
1443
|
+
|
|
1444
|
+
return h(
|
|
1445
|
+
'div',
|
|
1446
|
+
{
|
|
1447
|
+
style: {
|
|
1448
|
+
height,
|
|
1449
|
+
flex: '0 0 auto',
|
|
1450
|
+
display: 'flex',
|
|
1451
|
+
flexDirection: 'column',
|
|
1452
|
+
minHeight: 0,
|
|
1453
|
+
minWidth: 0,
|
|
1454
|
+
borderTop: '1px solid var(--gufa-border)',
|
|
1455
|
+
background: 'var(--gufa-bg)',
|
|
1456
|
+
},
|
|
1457
|
+
},
|
|
1458
|
+
h(
|
|
1459
|
+
'div',
|
|
1460
|
+
{
|
|
1461
|
+
style: {
|
|
1462
|
+
display: 'flex',
|
|
1463
|
+
alignItems: 'center',
|
|
1464
|
+
gap: 4,
|
|
1465
|
+
height: 32,
|
|
1466
|
+
padding: '0 6px 0 8px',
|
|
1467
|
+
flex: '0 0 auto',
|
|
1468
|
+
borderBottom: '1px solid var(--gufa-border)',
|
|
1469
|
+
background: 'var(--gufa-layer)',
|
|
1470
|
+
minWidth: 0,
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
h(G.Icon, { name: 'terminal', size: 13 }),
|
|
1474
|
+
h('span', { style: { fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', color: 'var(--gufa-text-dim)', whiteSpace: 'nowrap', marginRight: 2 } }, t('terminal.title')),
|
|
1475
|
+
h('div', { ref: tabStripRef, style: { display: 'flex', alignItems: 'stretch', gap: 2, flex: '1 1 auto', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarWidth: 'none' } }, tabStrip),
|
|
1476
|
+
h(
|
|
1477
|
+
G.IconButton,
|
|
1478
|
+
{ title: t('terminal.new'), disabled: canCreate !== true, onClick: () => void createSession() },
|
|
1479
|
+
h(G.Icon, { name: 'plus', size: 13 }),
|
|
1480
|
+
),
|
|
1481
|
+
h(
|
|
1482
|
+
G.IconButton,
|
|
1483
|
+
{ title: t('common.close'), onClick: () => props.onToggleHeight?.() },
|
|
1484
|
+
h(G.Icon, { name: 'chevron-down', size: 12 }),
|
|
1485
|
+
),
|
|
1486
|
+
),
|
|
1487
|
+
error !== ''
|
|
1488
|
+
? h(
|
|
1489
|
+
'div',
|
|
1490
|
+
{ className: 'gufa-error-banner', style: { margin: '4px 8px', flex: '0 0 auto' } },
|
|
1491
|
+
h(G.Icon, { name: 'warn', size: 12 }),
|
|
1492
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0 } }, error),
|
|
1493
|
+
h(G.IconButton, { title: t('common.close'), onClick: () => setError('') }, h(G.Icon, { name: 'close', size: 11 })),
|
|
1494
|
+
)
|
|
1495
|
+
: null,
|
|
1496
|
+
assets.status === 'failed'
|
|
1497
|
+
? h(
|
|
1498
|
+
'div',
|
|
1499
|
+
{ className: 'gufa-error-banner', style: { margin: '4px 8px', flex: '0 0 auto' } },
|
|
1500
|
+
h(G.Icon, { name: 'warn', size: 12 }),
|
|
1501
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0 } }, `${t('common.error')}: ${assets.error}`),
|
|
1502
|
+
)
|
|
1503
|
+
: null,
|
|
1504
|
+
h(
|
|
1505
|
+
'div',
|
|
1506
|
+
{ ref: sizeRef, style: { position: 'relative', flex: '1 1 auto', display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: theme === 'dark' ? '#1e1e1e' : '#ffffff' } },
|
|
1507
|
+
body,
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1510
|
+
}
|