@zhangzhian/dsh-devtools-terminal 0.0.0-stage → 0.3.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.en.md +89 -0
- package/README.md +160 -2
- package/client/chunk-terminal.js +8025 -0
- package/client/client.js +1380 -0
- package/client/client.js.map +1 -0
- package/lib/index.js +11 -0
- package/lib/link-target.js +73 -0
- package/lib/links.js +222 -0
- package/lib/routes.js +115 -0
- package/lib/types/index.d.ts +29 -0
- package/lib/types/link-target.d.ts +50 -0
- package/lib/types/links.d.ts +116 -0
- package/lib/types/routes.d.ts +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -3
package/client/client.js
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@zhangzhian/dsh-devtools-terminal",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
8
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
let react = require("react");
|
|
10
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
11
|
+
//#region src/client/locale/index.ts
|
|
12
|
+
/** 本插件在 locale 服务里占的命名空间。 */
|
|
13
|
+
const NS = "devtools-terminal";
|
|
14
|
+
/** 全部中文文案。 */
|
|
15
|
+
const zh = {
|
|
16
|
+
title: "终端",
|
|
17
|
+
new: "新建终端",
|
|
18
|
+
description: "在会话工作区运行命令",
|
|
19
|
+
rename: "终端名称",
|
|
20
|
+
shell: "选择 Shell",
|
|
21
|
+
shellLoading: "正在读取 Shell…",
|
|
22
|
+
shellEmpty: "没有可用的 Shell",
|
|
23
|
+
loading: "正在读取终端环境…",
|
|
24
|
+
creating: "正在启动…",
|
|
25
|
+
connecting: "正在连接…",
|
|
26
|
+
disconnected: "连接已断开。",
|
|
27
|
+
reconnect: "重新连接",
|
|
28
|
+
readonly: "此页面当前只读。",
|
|
29
|
+
control: "接管输入",
|
|
30
|
+
closed: "终端已关闭。",
|
|
31
|
+
exited: "进程已退出({code})",
|
|
32
|
+
failed: "终端错误:{message}",
|
|
33
|
+
unavailable: "不可用",
|
|
34
|
+
retry: "重试",
|
|
35
|
+
missingTerminal: "此终端已不存在,请新建终端。",
|
|
36
|
+
terminalLimit: "终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。"
|
|
37
|
+
};
|
|
38
|
+
/** 全部英文文案。 */
|
|
39
|
+
const en = {
|
|
40
|
+
title: "Terminal",
|
|
41
|
+
new: "New terminal",
|
|
42
|
+
description: "Run commands in the session workspace",
|
|
43
|
+
rename: "Terminal name",
|
|
44
|
+
shell: "Select shell",
|
|
45
|
+
shellLoading: "Reading shells…",
|
|
46
|
+
shellEmpty: "No shell available",
|
|
47
|
+
loading: "Reading the terminal environment…",
|
|
48
|
+
creating: "Starting…",
|
|
49
|
+
connecting: "Connecting…",
|
|
50
|
+
disconnected: "Connection lost.",
|
|
51
|
+
reconnect: "Reconnect",
|
|
52
|
+
readonly: "This page is read-only.",
|
|
53
|
+
control: "Take control",
|
|
54
|
+
closed: "Terminal closed.",
|
|
55
|
+
exited: "Process exited ({code})",
|
|
56
|
+
failed: "Terminal error: {message}",
|
|
57
|
+
unavailable: "Unavailable",
|
|
58
|
+
retry: "Retry",
|
|
59
|
+
missingTerminal: "This terminal no longer exists. Open a new one.",
|
|
60
|
+
terminalLimit: "The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit."
|
|
61
|
+
};
|
|
62
|
+
/** 当前绑定的 locale 运行时(未绑定前回退中文)。 */
|
|
63
|
+
let bound;
|
|
64
|
+
/**
|
|
65
|
+
* 绑定 locale 服务。
|
|
66
|
+
*
|
|
67
|
+
* @param runtime - 客户端 locale 服务。
|
|
68
|
+
*/
|
|
69
|
+
function installLocale(runtime) {
|
|
70
|
+
bound = runtime.bind(NS);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 取一条文案。
|
|
74
|
+
*
|
|
75
|
+
* @param key - 字典键。
|
|
76
|
+
* @param params - `{name}` 模板参数。
|
|
77
|
+
* @returns 当前语言下的文案。
|
|
78
|
+
*/
|
|
79
|
+
function t(key, params) {
|
|
80
|
+
if (bound === void 0) return zh[key];
|
|
81
|
+
return bound(key, params);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/client/styles.ts
|
|
85
|
+
/**
|
|
86
|
+
* 客户端样式的幂等注入。
|
|
87
|
+
*
|
|
88
|
+
* DSH 的客户端 bundle 是**一个脚本**,没有 CSS 文件的概念(插件只能往
|
|
89
|
+
* `<head>` 里插 `<style>`)。这里给一个按 id 幂等的小工具:同一段样式重复
|
|
90
|
+
* `injectStyle` 不会插第二遍,`data-plugin` 标记方便在 DevTools 里认出是谁的。
|
|
91
|
+
*
|
|
92
|
+
* 为什么不直接在每个模块里 `document.head.appendChild`:插件会热重载,
|
|
93
|
+
* 不按 id 去重就会在同一页里叠出好几份同 id 的样式表。
|
|
94
|
+
*/
|
|
95
|
+
/** 已经注入过的样式 id 集合。 */
|
|
96
|
+
const injected = /* @__PURE__ */ new Set();
|
|
97
|
+
/**
|
|
98
|
+
* 注入一段样式(幂等)。
|
|
99
|
+
*
|
|
100
|
+
* @param id - 样式 id(建议用 `${包名}/${文件名}`)。
|
|
101
|
+
* @param css - 样式文本。
|
|
102
|
+
*/
|
|
103
|
+
function injectStyle(id, css) {
|
|
104
|
+
if (injected.has(id) || typeof document === "undefined") return;
|
|
105
|
+
if (document.querySelector(`style[data-devtools-terminal-style=${JSON.stringify(id)}]`) !== null) {
|
|
106
|
+
injected.add(id);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const style = document.createElement("style");
|
|
110
|
+
style.dataset.plugin = "dsh-devtools-terminal";
|
|
111
|
+
style.dataset.devtoolsTerminalStyle = id;
|
|
112
|
+
style.textContent = css;
|
|
113
|
+
document.head.appendChild(style);
|
|
114
|
+
injected.add(id);
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/client/guide.tsx
|
|
118
|
+
/**
|
|
119
|
+
* 右侧栏引导页上的「终端」卡片。
|
|
120
|
+
*
|
|
121
|
+
* ## 为什么不能只给一张"能点就行"的卡片
|
|
122
|
+
*
|
|
123
|
+
* 引导页的卡片是**两层**组成的:终端页签类型声明一条 `guide` 条目(标题 / 描述 /
|
|
124
|
+
* 图标),渲染时平台按 `providerId` 去 `sidebar.right.tab.guide.entry` 槽里找同
|
|
125
|
+
* id 的组件;找不到才退回平台自带的 `EntryBox`。
|
|
126
|
+
*
|
|
127
|
+
* 我们接管了 `terminal` kind(`priority: 'extension'`),于是**生效的类型是我们**,
|
|
128
|
+
* 平台那条「新建终端 + Shell 下拉菜单」卡片(它注册在平台自己的类型 id 上)不再
|
|
129
|
+
* 参与渲染。所以这一版必须把平台卡片的两个既有行为都复刻出来:
|
|
130
|
+
*
|
|
131
|
+
* 1. **图标用平台自己的插画**(`PluginArtworkTerminal`,26px)—— 「文件」「Git」
|
|
132
|
+
* 那几张卡用的都是平台插画;自己画一个灰色 `>_` 会又小又灰,夹在同一列里
|
|
133
|
+
* 一眼就不是一套(用户 2026-10-24 的反馈就是这一条)。
|
|
134
|
+
* 2. **标题右边的 Shell 下拉菜单** —— 点卡片用「上次选的 shell」直接开终端;点
|
|
135
|
+
* chevron 列出宿主发现的 shell(`webTerminals.launchShells`),选一个就记成
|
|
136
|
+
* 下次默认(`selectShell`)并带着 `shellPath` 打开。
|
|
137
|
+
*
|
|
138
|
+
* 尺寸也照平台卡片来(14px 标题 / 11px 描述 / 14px 20px 内边距 / 56px 最小高度 /
|
|
139
|
+
* `--dsw-radius-xl`),不然同一列里只有这一张"瘦"一点。
|
|
140
|
+
*
|
|
141
|
+
* ## 为什么是手写而不是直接 import 平台组件
|
|
142
|
+
*
|
|
143
|
+
* 平台的 `TerminalGuide` 没有从 `@deepseek-ai/dsh-client-ui-sidebar-terminal/client`
|
|
144
|
+
* 导出,而客户端 bundle 在浏览器里只能 require 9 个**种子模块** —— 直接 import
|
|
145
|
+
* 平台组件就是运行时找不到模块。所以这里用同为种子模块的
|
|
146
|
+
* `@deepseek-ai/dsh-client-ui-primitives` 里的 `Menu` / `Button` /
|
|
147
|
+
* `PluginArtworkTerminal` 拼一张等价卡片。
|
|
148
|
+
*/
|
|
149
|
+
/** 卡片样式。 */
|
|
150
|
+
injectStyle("dsh-devtools-terminal/guide", ".dsh-devtools-terminal-guide{box-sizing:border-box;position:relative;display:flex;align-items:center;gap:14px;width:100%;min-height:56px;padding:14px 20px;border:0.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);text-align:left;overflow:hidden}.dsh-devtools-terminal-guide:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsh-devtools-terminal-guide-main{position:absolute;inset:0;width:100%;height:100%;padding:0;border-radius:0}.dsh-devtools-terminal-guide-main:focus-visible{outline-offset:-2px}.dsh-devtools-terminal-guide-icon{flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;color:var(--dsw-alias-label-secondary);pointer-events:none;position:relative}.dsh-devtools-terminal-guide-text{display:flex;flex-direction:column;flex:1;gap:3px;min-width:0;pointer-events:none;position:relative}.dsh-devtools-terminal-guide-title-row{display:flex;align-items:center;gap:4px;min-width:0}.dsh-devtools-terminal-guide-title{font-size:14px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-devtools-terminal-guide-description{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-devtools-terminal-guide-menu{flex:none;display:flex;pointer-events:auto}.dsh-devtools-terminal-guide-trigger{flex:none;width:20px;height:20px;padding:0;border-radius:4px}.dsh-devtools-terminal-guide-trigger svg{width:14px;height:14px;color:var(--dsw-alias-label-tertiary)}.dsh-devtools-terminal-guide-shortcut{pointer-events:none;position:relative}");
|
|
151
|
+
/** 平台「新建终端」命令的 id(快捷键提示读它)。 */
|
|
152
|
+
const TERMINAL_NEW = "terminal.new";
|
|
153
|
+
/**
|
|
154
|
+
* 「终端」引导卡片。
|
|
155
|
+
*
|
|
156
|
+
* @param props - 槽运行时 props(标题 / 描述 / 页签动作)与 shell 注入面。
|
|
157
|
+
* @returns 一张可点的卡片;宿主提供 shell 服务时标题右边还带下拉菜单。
|
|
158
|
+
*/
|
|
159
|
+
function TerminalGuide(props) {
|
|
160
|
+
const { useTabInfo, useShortcuts, title, description, loadShells, selectShell } = props;
|
|
161
|
+
const { tab } = useTabInfo();
|
|
162
|
+
const shortcut = useShortcuts((entries) => entries.find((entry) => entry.id === TERMINAL_NEW));
|
|
163
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
164
|
+
const [attempt, setAttempt] = (0, react.useState)(0);
|
|
165
|
+
const [state, setState] = (0, react.useState)({ phase: "loading" });
|
|
166
|
+
(0, react.useEffect)(() => {
|
|
167
|
+
if (!open || loadShells === void 0) return void 0;
|
|
168
|
+
const lifetime = new AbortController();
|
|
169
|
+
loadShells(lifetime.signal).then((choices) => {
|
|
170
|
+
if (lifetime.signal.aborted) return;
|
|
171
|
+
setState(choices.selectedShell === void 0 ? {
|
|
172
|
+
phase: "ready",
|
|
173
|
+
shells: choices.shells
|
|
174
|
+
} : {
|
|
175
|
+
phase: "ready",
|
|
176
|
+
shells: choices.shells,
|
|
177
|
+
selected: choices.selectedShell
|
|
178
|
+
});
|
|
179
|
+
}, (error) => {
|
|
180
|
+
if (lifetime.signal.aborted) return;
|
|
181
|
+
setState({
|
|
182
|
+
phase: "failed",
|
|
183
|
+
message: error instanceof Error ? error.message : String(error)
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
return () => {
|
|
187
|
+
lifetime.abort();
|
|
188
|
+
};
|
|
189
|
+
}, [
|
|
190
|
+
open,
|
|
191
|
+
attempt,
|
|
192
|
+
loadShells
|
|
193
|
+
]);
|
|
194
|
+
const items = (() => {
|
|
195
|
+
if (state.phase === "loading") return [{
|
|
196
|
+
id: "loading",
|
|
197
|
+
label: t("shellLoading"),
|
|
198
|
+
disabled: true
|
|
199
|
+
}];
|
|
200
|
+
if (state.phase === "failed") return [{
|
|
201
|
+
id: "error",
|
|
202
|
+
label: t("failed", { message: state.message }),
|
|
203
|
+
disabled: true
|
|
204
|
+
}, {
|
|
205
|
+
id: "retry",
|
|
206
|
+
label: t("retry")
|
|
207
|
+
}];
|
|
208
|
+
if (state.shells.length === 0) return [{
|
|
209
|
+
id: "empty",
|
|
210
|
+
label: t("shellEmpty"),
|
|
211
|
+
disabled: true
|
|
212
|
+
}];
|
|
213
|
+
return state.shells.map((shell) => ({
|
|
214
|
+
id: shell.path,
|
|
215
|
+
label: shell.name
|
|
216
|
+
}));
|
|
217
|
+
})();
|
|
218
|
+
/** 菜单选择:失败项是「重试」,其余是一项 shell。 */
|
|
219
|
+
const choose = (path) => {
|
|
220
|
+
if (state.phase === "failed") {
|
|
221
|
+
setState({ phase: "loading" });
|
|
222
|
+
setAttempt((value) => value + 1);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
selectShell?.(path);
|
|
226
|
+
setOpen(false);
|
|
227
|
+
tab.actions.openTab("terminal", {
|
|
228
|
+
replaceTab: true,
|
|
229
|
+
params: { shellPath: path }
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
const shellMenu = loadShells === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
|
|
233
|
+
open,
|
|
234
|
+
portal: true,
|
|
235
|
+
autoFocus: true,
|
|
236
|
+
align: "end",
|
|
237
|
+
className: "dsh-devtools-terminal-guide-menu",
|
|
238
|
+
items,
|
|
239
|
+
selectedId: state.phase === "ready" ? state.selected : void 0,
|
|
240
|
+
onClose: () => {
|
|
241
|
+
setOpen(false);
|
|
242
|
+
},
|
|
243
|
+
onSelect: choose,
|
|
244
|
+
anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
245
|
+
variant: "ghost",
|
|
246
|
+
className: "dsh-devtools-terminal-guide-trigger",
|
|
247
|
+
"aria-label": t("shell"),
|
|
248
|
+
"aria-haspopup": "menu",
|
|
249
|
+
"aria-expanded": open,
|
|
250
|
+
onClick: () => {
|
|
251
|
+
setState({ phase: "loading" });
|
|
252
|
+
setOpen((value) => !value);
|
|
253
|
+
},
|
|
254
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {})
|
|
255
|
+
})
|
|
256
|
+
});
|
|
257
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
258
|
+
className: "dsh-devtools-terminal-guide",
|
|
259
|
+
children: [
|
|
260
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
261
|
+
variant: "ghost",
|
|
262
|
+
className: "dsh-devtools-terminal-guide-main",
|
|
263
|
+
"aria-label": description === void 0 ? title : `${title} ${description}`,
|
|
264
|
+
"aria-keyshortcuts": shortcut?.aria,
|
|
265
|
+
onClick: () => {
|
|
266
|
+
tab.actions.openTab("terminal", { replaceTab: true });
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
270
|
+
className: "dsh-devtools-terminal-guide-icon",
|
|
271
|
+
"aria-hidden": "true",
|
|
272
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.PluginArtworkTerminal, { size: description === void 0 ? 22 : 26 })
|
|
273
|
+
}),
|
|
274
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
275
|
+
className: "dsh-devtools-terminal-guide-text",
|
|
276
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
277
|
+
className: "dsh-devtools-terminal-guide-title-row",
|
|
278
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
279
|
+
className: "dsh-devtools-terminal-guide-title",
|
|
280
|
+
"aria-hidden": "true",
|
|
281
|
+
children: title
|
|
282
|
+
}), shellMenu]
|
|
283
|
+
}), description !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
284
|
+
className: "dsh-devtools-terminal-guide-description",
|
|
285
|
+
"aria-hidden": "true",
|
|
286
|
+
children: description
|
|
287
|
+
})]
|
|
288
|
+
}),
|
|
289
|
+
shortcut !== void 0 && shortcut.keys.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.ShortcutKeys, {
|
|
290
|
+
keys: shortcut.keys,
|
|
291
|
+
className: "dsh-devtools-terminal-guide-shortcut"
|
|
292
|
+
})
|
|
293
|
+
]
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/links.ts
|
|
298
|
+
/**
|
|
299
|
+
* 判断字符串是不是可打开的 http(s) 地址。
|
|
300
|
+
*
|
|
301
|
+
* @param text - 候选文本。
|
|
302
|
+
* @returns http/https 且能被 URL 解析时为 true。
|
|
303
|
+
*/
|
|
304
|
+
function isHttpUrl(text) {
|
|
305
|
+
try {
|
|
306
|
+
const parsed = new URL(text);
|
|
307
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/link-target.ts
|
|
314
|
+
/**
|
|
315
|
+
* 终端链接的**落点决策**:Ctrl/Cmd+点击输出里的 URL,交给谁打开。
|
|
316
|
+
*
|
|
317
|
+
* ## 为什么单独一个纯模块
|
|
318
|
+
*
|
|
319
|
+
* 「面板优先、不可用回退系统浏览器」这条规则本身只有几行,但它是本功能唯一的
|
|
320
|
+
* 行为契约:面板临时抛错要能回退、非 http(s) 一律不碰、普通点击根本走不到这里
|
|
321
|
+
* (修饰键判断在 `link-provider.ts`)。这些分支在浏览器里验证成本高(要真 xterm +
|
|
322
|
+
* 真鼠标 + 真右栏),放到 host 编译(`lib/link-target.js`)就能用 `node --test`
|
|
323
|
+
* 把每条分支钉死 —— 与 `links.ts` 同一个理由。
|
|
324
|
+
*
|
|
325
|
+
* ## 落点只有两个
|
|
326
|
+
*
|
|
327
|
+
* - `panel`:DSH 右栏的**浏览器插件页签**(`@deepseek-ai/dsh-client-ui-sidebar-browser`,
|
|
328
|
+
* web 是 iframe、桌面端 carrier 是原生 `<webview>`、移动端在 WebView 里同样是
|
|
329
|
+
* iframe)。打开的 URL 由平台校验,非法地址它自己会拒绝。
|
|
330
|
+
* - `external`:`window.open(..., 'noopener,noreferrer')` —— 浏览器里是新标签页,
|
|
331
|
+
* 桌面端经 carrier 的 `linkBridge` 交给系统浏览器。这是**回退**:profile 没启用
|
|
332
|
+
* 右栏浏览器页签(`sidebarRightTabs.get('browser')` 为 undefined)、或者面板这次
|
|
333
|
+
* 真的没打开成功时走它。
|
|
334
|
+
*
|
|
335
|
+
* 模块本身**不碰 DOM / cordis**:`window.open` 与 `ctx.sidebarRight` 都由调用方
|
|
336
|
+
* 以端口({@link LinkOpenPorts})注入,于是这里只剩判断。
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* 决定一次打开该落到哪儿。
|
|
340
|
+
*
|
|
341
|
+
* 面板可用就优先面板 —— 这是本插件要的产品行为(用户要的是"在 DSH 插件浏览器
|
|
342
|
+
* 里打开",而不是跳出去)。不可用(profile 关掉了那一行 / 平台版本没有这个页签
|
|
343
|
+
* 类型)才回退系统浏览器。
|
|
344
|
+
*
|
|
345
|
+
* @param panelAvailable - 右栏浏览器页签当前是否可用。
|
|
346
|
+
* @returns 落点。
|
|
347
|
+
*/
|
|
348
|
+
function linkDestination(panelAvailable) {
|
|
349
|
+
return panelAvailable ? "panel" : "external";
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* 造一个「打开终端链接」的动作。
|
|
353
|
+
*
|
|
354
|
+
* 语义(与 VSCode 终端一致):只认 http(s);面板优先;面板这次没开成功就回退
|
|
355
|
+
* 系统浏览器 —— **不能因为面板抛错就什么都不做**,那等于点击没反应。
|
|
356
|
+
*
|
|
357
|
+
* @param ports - 外部动作。
|
|
358
|
+
* @returns 打开动作(非 http(s) 静默忽略)。
|
|
359
|
+
*/
|
|
360
|
+
function createLinkOpener(ports) {
|
|
361
|
+
return (url) => {
|
|
362
|
+
if (!isHttpUrl(url)) return;
|
|
363
|
+
let destination = "external";
|
|
364
|
+
try {
|
|
365
|
+
destination = linkDestination(ports.panelAvailable());
|
|
366
|
+
} catch {
|
|
367
|
+
destination = "external";
|
|
368
|
+
}
|
|
369
|
+
if (destination === "panel") try {
|
|
370
|
+
ports.openInPanel(url);
|
|
371
|
+
return;
|
|
372
|
+
} catch {}
|
|
373
|
+
ports.openExternal(url);
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/client/injection.ts
|
|
378
|
+
/**
|
|
379
|
+
* 探测终端服务在不在。
|
|
380
|
+
*
|
|
381
|
+
* 不能 `inject`:终端控制器可能没加载(用户关掉了那一行),插件仍应正常加载,
|
|
382
|
+
* 只是正文显示「这个环境没有终端」。也不能读到一次 `undefined` 就放弃 —— 平台
|
|
383
|
+
* 那个插件和我们同批加载,谁先跑完没有保证,所以正文在注册时再探一次。
|
|
384
|
+
*
|
|
385
|
+
* @param ctx - 客户端根上下文。
|
|
386
|
+
* @returns 终端服务,或 undefined。
|
|
387
|
+
*/
|
|
388
|
+
function getTerminals(ctx) {
|
|
389
|
+
const service = ctx.get("webTerminals");
|
|
390
|
+
return service !== void 0 && typeof service.view === "function" ? service : void 0;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* 探测主题服务。
|
|
394
|
+
*
|
|
395
|
+
* @param ctx - 客户端根上下文。
|
|
396
|
+
* @returns 主题服务,或 undefined。
|
|
397
|
+
*/
|
|
398
|
+
function getTheme(ctx) {
|
|
399
|
+
const service = ctx.get("theme");
|
|
400
|
+
return service !== void 0 && typeof service.getTheme === "function" ? service : void 0;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 取一个页签 occurrence 的导航参数。
|
|
404
|
+
*
|
|
405
|
+
* @param ctx - 客户端根上下文。
|
|
406
|
+
* @param sessionId - 会话 id。
|
|
407
|
+
* @param key - 页签 occurrence key。
|
|
408
|
+
* @returns 内容地址与参数。
|
|
409
|
+
*/
|
|
410
|
+
function occurrenceOf(ctx, sessionId, key) {
|
|
411
|
+
const navigation = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key }).navigation.getSnapshot();
|
|
412
|
+
return {
|
|
413
|
+
contentId: navigation.address,
|
|
414
|
+
params: navigation.params
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* 造正文的 `inject` 回调。
|
|
419
|
+
*
|
|
420
|
+
* @param ctx - 客户端根上下文。
|
|
421
|
+
* @returns 槽注册时的 `inject(sessionId)`。
|
|
422
|
+
*/
|
|
423
|
+
function createTerminalInjection(ctx) {
|
|
424
|
+
return (sessionId) => ({
|
|
425
|
+
view: (key) => {
|
|
426
|
+
const terminals = getTerminals(ctx);
|
|
427
|
+
if (terminals === void 0) throw new Error("devtools-terminal: 终端服务不在");
|
|
428
|
+
const { contentId, params } = occurrenceOf(ctx, sessionId, key);
|
|
429
|
+
const terminalId = params !== void 0 && params !== null && typeof params === "object" && "terminalId" in params ? params.terminalId : void 0;
|
|
430
|
+
const shellPath = params !== void 0 && params !== null && typeof params === "object" && "shellPath" in params ? params.shellPath : void 0;
|
|
431
|
+
return terminals.view(sessionId, key, contentId, terminalId, shellPath);
|
|
432
|
+
},
|
|
433
|
+
keyedHooks: { terminal: (key) => {
|
|
434
|
+
const terminals = getTerminals(ctx);
|
|
435
|
+
if (terminals === void 0) throw new Error("devtools-terminal: 终端服务不在");
|
|
436
|
+
const { contentId, params } = occurrenceOf(ctx, sessionId, key);
|
|
437
|
+
const terminalId = params !== void 0 && params !== null && typeof params === "object" && "terminalId" in params ? params.terminalId : void 0;
|
|
438
|
+
const shellPath = params !== void 0 && params !== null && typeof params === "object" && "shellPath" in params ? params.shellPath : void 0;
|
|
439
|
+
return terminals.view(sessionId, key, contentId, terminalId, shellPath).state;
|
|
440
|
+
} },
|
|
441
|
+
hooks: { theme: {
|
|
442
|
+
getSnapshot: () => getTheme(ctx)?.getTheme() ?? DEFAULT_THEME,
|
|
443
|
+
subscribe: (listener) => ctx.on("theme/change", listener)
|
|
444
|
+
} },
|
|
445
|
+
openUrl: createUrlOpener(ctx)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 造正文用的「打开链接」动作。
|
|
450
|
+
*
|
|
451
|
+
* 落点规则全部在纯模块 `src/link-target.ts` 里(可 `node --test`),这里只把
|
|
452
|
+
* 平台服务接成端口:
|
|
453
|
+
*
|
|
454
|
+
* - 面板可用性:`sidebarRightTabs.get('browser')` —— 页签类型注册了就可用;
|
|
455
|
+
* profile 里 `disabled: true` / 安装没有这个包时是 `undefined`,自动回退;
|
|
456
|
+
* - 面板:`sidebarRight.openTab('browser', { params: { url } })` —— 展开右栏并把
|
|
457
|
+
* 浏览器页签切到前台。**它不收地址、也不去重**:参数只记进 navigation.params,
|
|
458
|
+
* 由页签首次挂载时自己去校验/加载(非法地址是面板里的一条报错提示,不抛错),
|
|
459
|
+
* 而 `browser` kind 是 `multiple: true`,所以每点一次是一个**新页签**
|
|
460
|
+
* (与平台自己的聊天链接行为一致;平台没有"按 URL 找页签"的公开接口);
|
|
461
|
+
* - 回退:`window.open(..., 'noopener,noreferrer')` —— 浏览器里是新标签页,
|
|
462
|
+
* 桌面端 carrier 会把它交给系统浏览器(`dsh-electron` 的 `linkBridge`)。
|
|
463
|
+
*
|
|
464
|
+
* @param ctx - 客户端根上下文(`sidebarRight` / `sidebarRightTabs` 在静态 inject 里)。
|
|
465
|
+
* @returns 打开动作。
|
|
466
|
+
*/
|
|
467
|
+
function createUrlOpener(ctx) {
|
|
468
|
+
return createLinkOpener({
|
|
469
|
+
panelAvailable: () => ctx.sidebarRightTabs.get("browser") !== void 0,
|
|
470
|
+
openInPanel: (url) => {
|
|
471
|
+
ctx.sidebarRight.openTab("browser", { params: { url } });
|
|
472
|
+
},
|
|
473
|
+
openExternal: (url) => {
|
|
474
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* 快捷键服务缺席时的空目录。
|
|
480
|
+
*
|
|
481
|
+
* ⚠️ 不能"没有就不注入 hooks":`hooks` 是**注入面形状**的一部分,少一个成员会让
|
|
482
|
+
* `useShortcuts` 这个 prop 从有到无,而组件里的 Hook 调用不能跟着变。所以统一注入
|
|
483
|
+
* 一个空目录 —— 卡片照常渲染,只是不显示快捷键提示。
|
|
484
|
+
*/
|
|
485
|
+
const EMPTY_SHORTCUTS = {
|
|
486
|
+
getSnapshot: () => [],
|
|
487
|
+
subscribe: () => () => void 0
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* 探测快捷键服务。
|
|
491
|
+
*
|
|
492
|
+
* `shortcuts` 是平台自带的浏览器命令服务(引导页本身的快捷键提示也用它);它不在
|
|
493
|
+
* 我们的静态 `inject` 里 —— 缺了不该让插件加载失败,只是少一行快捷键提示。
|
|
494
|
+
*
|
|
495
|
+
* @param ctx - 客户端根上下文。
|
|
496
|
+
* @returns 快捷键服务,或 undefined。
|
|
497
|
+
*/
|
|
498
|
+
function getShortcuts(ctx) {
|
|
499
|
+
const service = ctx.get("shortcuts");
|
|
500
|
+
return service !== void 0 && service.catalog !== void 0 ? service : void 0;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* 造引导卡的 `inject` 回调。
|
|
504
|
+
*
|
|
505
|
+
* 引导卡是 `sidebar.right.tab.guide.entry`(session 作用域 keyed 槽):注入面在
|
|
506
|
+
* **渲染时**按会话求值,所以这里可以现探终端服务 —— 用户看到引导页时平台早就加载完了。
|
|
507
|
+
* 能力探测也在这里做:宿主没有 `launchShells` 就不给 `loadShells`。
|
|
508
|
+
*
|
|
509
|
+
* @param ctx - 客户端根上下文。
|
|
510
|
+
* @param sessionId - 当前会话 id(宿主 `launchShells` 按会话解析默认 shell)。
|
|
511
|
+
* @returns 引导卡注入面。
|
|
512
|
+
*/
|
|
513
|
+
function createGuideInjection(ctx, sessionId) {
|
|
514
|
+
const terminals = getTerminals(ctx);
|
|
515
|
+
const launchShells = terminals?.launchShells;
|
|
516
|
+
const shortcuts = getShortcuts(ctx)?.catalog ?? EMPTY_SHORTCUTS;
|
|
517
|
+
if (terminals === void 0 || launchShells === void 0) return { hooks: { shortcuts } };
|
|
518
|
+
return {
|
|
519
|
+
hooks: { shortcuts },
|
|
520
|
+
loadShells: (signal) => launchShells.call(terminals, sessionId, signal),
|
|
521
|
+
selectShell: (path) => {
|
|
522
|
+
getTerminals(ctx)?.selectShell?.(path);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* 主题服务缺席时的兜底值。
|
|
528
|
+
*
|
|
529
|
+
* 只影响终端字号/字体;颜色由 CSS 令牌决定,不会变成黑底白字。
|
|
530
|
+
*/
|
|
531
|
+
const DEFAULT_THEME = {
|
|
532
|
+
fontFamilies: { terminal: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" },
|
|
533
|
+
fontSizes: { terminal: 13 }
|
|
534
|
+
};
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/client/terminal/chunk-loader.ts
|
|
537
|
+
/** 块名 → 产物文件名(必须与 `tsdown.config.ts` 和 `src/routes.ts` 白名单一致)。 */
|
|
538
|
+
const SCRIPTS = { terminal: "chunk-terminal.js" };
|
|
539
|
+
/** 路由前缀(必须与 `src/routes.ts` 的 `PREFIX` 一致)。 */
|
|
540
|
+
const BUNDLE_PREFIX = "/devtools-terminal/bundle";
|
|
541
|
+
/** 正在加载/已加载的块。 */
|
|
542
|
+
const pending = /* @__PURE__ */ new Map();
|
|
543
|
+
/**
|
|
544
|
+
* 加载一个块(幂等)。
|
|
545
|
+
*
|
|
546
|
+
* @param name - 块名。
|
|
547
|
+
* @returns 加载完成。
|
|
548
|
+
*/
|
|
549
|
+
function loadChunk(name) {
|
|
550
|
+
if (typeof globalThis.__devtoolsTerminalChunk__?.[name] === "function") return Promise.resolve();
|
|
551
|
+
const existing = pending.get(name);
|
|
552
|
+
if (existing !== void 0) return existing;
|
|
553
|
+
const guarded = new Promise((resolve, reject) => {
|
|
554
|
+
const script = document.createElement("script");
|
|
555
|
+
script.src = `${BUNDLE_PREFIX}/${SCRIPTS[name]}`;
|
|
556
|
+
script.async = true;
|
|
557
|
+
script.onload = () => {
|
|
558
|
+
if (typeof globalThis.__devtoolsTerminalChunk__?.[name] === "function") resolve();
|
|
559
|
+
else reject(/* @__PURE__ */ new Error(`分块 ${name} 加载了但没有注册`));
|
|
560
|
+
};
|
|
561
|
+
script.onerror = () => {
|
|
562
|
+
reject(/* @__PURE__ */ new Error(`分块 ${name} 加载失败`));
|
|
563
|
+
};
|
|
564
|
+
document.head.append(script);
|
|
565
|
+
}).catch((error) => {
|
|
566
|
+
pending.delete(name);
|
|
567
|
+
throw error;
|
|
568
|
+
});
|
|
569
|
+
pending.set(name, guarded);
|
|
570
|
+
return guarded;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* 加载并取回实现。
|
|
574
|
+
*
|
|
575
|
+
* @param name - 块名。
|
|
576
|
+
* @returns 实现。
|
|
577
|
+
*/
|
|
578
|
+
async function useChunk(name) {
|
|
579
|
+
await loadChunk(name);
|
|
580
|
+
const implementation = globalThis.__devtoolsTerminalChunk__?.[name];
|
|
581
|
+
if (implementation === void 0) throw new Error(`分块 ${name} 未注册`);
|
|
582
|
+
return implementation;
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/client/terminal/styles.ts
|
|
586
|
+
/**
|
|
587
|
+
* 终端正文自己的样式(注入 + 类名字符串)。
|
|
588
|
+
*
|
|
589
|
+
* ## 为什么和平台终端长得像
|
|
590
|
+
*
|
|
591
|
+
* 我们这个组件接管的是平台内置的 `terminal` 页签 —— 用户看到的还是同一个面板。
|
|
592
|
+
* 所以几何照着平台那份 CSS 抄(flex 列、屏幕区撑满、状态行只在需要时出现),
|
|
593
|
+
* 只换类名前缀;否则同一个页签在两种实现之间切换时会跳一下。
|
|
594
|
+
*
|
|
595
|
+
* ## 只写令牌,不写颜色
|
|
596
|
+
*
|
|
597
|
+
* 皮肤契约(根仓库 `test/design-tokens.test.mjs`)要求颜色来自 `--dsw-alias-*`。
|
|
598
|
+
* 这里唯一"写死"的颜色是光标对比色,由懒加载块按单元格背景实时算出来,经 CSS
|
|
599
|
+
* 变量传进来(见 `chunks/terminal.ts` 的 `observeTerminalCursor`)—— 它必须
|
|
600
|
+
* 是具体色值,没法用令牌表达。
|
|
601
|
+
*
|
|
602
|
+
* ## 注入时机
|
|
603
|
+
*
|
|
604
|
+
* 模块加载时注入一次(`injectStyle` 按 id 幂等,热重载不会叠样式表)。正文是
|
|
605
|
+
* 本包唯一使用这些类名的组件,不需要入口再记得调一次安装函数。
|
|
606
|
+
*/
|
|
607
|
+
/** 样式表 id(与 `src/client/title.tsx` 的 id 风格一致)。 */
|
|
608
|
+
const STYLE_ID = "dsh-devtools-terminal/body";
|
|
609
|
+
/** 类名(前缀统一 `dsh-devtools-terminal-`,避免和别的插件抢样式)。 */
|
|
610
|
+
const cls = {
|
|
611
|
+
/** 正文根节点(带 `data-devtools-terminal`)。 */
|
|
612
|
+
root: "dsh-devtools-terminal-root",
|
|
613
|
+
/** 状态行(loading / 重连 / 只读 / 已退出)。 */
|
|
614
|
+
status: "dsh-devtools-terminal-status",
|
|
615
|
+
/** 终端屏幕区(xterm 的容器)。 */
|
|
616
|
+
screen: "dsh-devtools-terminal-screen",
|
|
617
|
+
/** xterm 真正挂载的元素(跟随屏幕区撑满)。 */
|
|
618
|
+
host: "dsh-devtools-terminal-host",
|
|
619
|
+
/** 临时状态文字。 */
|
|
620
|
+
muted: "dsh-devtools-terminal-muted",
|
|
621
|
+
/** 占位(把右侧按钮推到行尾)。 */
|
|
622
|
+
spacer: "dsh-devtools-terminal-spacer",
|
|
623
|
+
/** 状态行 / 空态里的按钮。 */
|
|
624
|
+
button: "dsh-devtools-terminal-button",
|
|
625
|
+
/** 错误段落。 */
|
|
626
|
+
error: "dsh-devtools-terminal-error",
|
|
627
|
+
/** 空态(缺终端 / 缺服务)。 */
|
|
628
|
+
empty: "dsh-devtools-terminal-empty",
|
|
629
|
+
/** 空态主文案。 */
|
|
630
|
+
emptyMessage: "dsh-devtools-terminal-empty-message",
|
|
631
|
+
/** 空态次要说明(宿主错误原文)。 */
|
|
632
|
+
emptyDetail: "dsh-devtools-terminal-empty-detail",
|
|
633
|
+
/** xterm 初始化失败的覆盖层。 */
|
|
634
|
+
failure: "dsh-devtools-terminal-failure"
|
|
635
|
+
};
|
|
636
|
+
injectStyle(STYLE_ID, `
|
|
637
|
+
.dsh-devtools-terminal-root {
|
|
638
|
+
box-sizing: border-box;
|
|
639
|
+
display: flex;
|
|
640
|
+
flex: 1 1 auto;
|
|
641
|
+
flex-direction: column;
|
|
642
|
+
height: 100%;
|
|
643
|
+
min-height: 0;
|
|
644
|
+
padding-top: 8px;
|
|
645
|
+
color: var(--dsw-alias-label-primary);
|
|
646
|
+
font-size: 13px;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* ─────────────────────── 状态行 ─────────────────────── */
|
|
650
|
+
/* 只在"还没连上 / 连上但不可写 / 已经结束"时出现,连上以后一行都不画(平台同款:
|
|
651
|
+
右侧栏本来就不宽,常驻工具条是纯浪费)。 */
|
|
652
|
+
.dsh-devtools-terminal-status {
|
|
653
|
+
display: flex;
|
|
654
|
+
flex: none;
|
|
655
|
+
flex-wrap: wrap;
|
|
656
|
+
align-items: center;
|
|
657
|
+
gap: 8px;
|
|
658
|
+
padding: 5px 12px;
|
|
659
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
660
|
+
}
|
|
661
|
+
.dsh-devtools-terminal-spacer { flex: 1 1 auto; }
|
|
662
|
+
.dsh-devtools-terminal-muted { color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
|
|
663
|
+
.dsh-devtools-terminal-button {
|
|
664
|
+
flex: none;
|
|
665
|
+
padding: 2px 10px;
|
|
666
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
667
|
+
border-radius: var(--dsw-radius-sm);
|
|
668
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
669
|
+
color: var(--dsw-alias-label-primary);
|
|
670
|
+
font: inherit;
|
|
671
|
+
font-size: 12px;
|
|
672
|
+
line-height: 1.6;
|
|
673
|
+
cursor: pointer;
|
|
674
|
+
}
|
|
675
|
+
.dsh-devtools-terminal-button:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
676
|
+
|
|
677
|
+
/* ─────────────────────── 终端屏幕 ─────────────────────── */
|
|
678
|
+
/* 屏幕区必须真的撑满:xterm 靠容器的盒子尺寸算行列,高度塌了就是 0 行。 */
|
|
679
|
+
.dsh-devtools-terminal-screen {
|
|
680
|
+
position: relative;
|
|
681
|
+
display: flex;
|
|
682
|
+
flex: 1 1 auto;
|
|
683
|
+
min-width: 0;
|
|
684
|
+
min-height: 0;
|
|
685
|
+
padding: 8px;
|
|
686
|
+
overflow: hidden;
|
|
687
|
+
background: var(--dsw-alias-bg-base);
|
|
688
|
+
color: var(--dsw-alias-label-primary);
|
|
689
|
+
}
|
|
690
|
+
/* 主题色也从这里读(懒加载块读这个元素的计算样式),所以背景写在这一层。 */
|
|
691
|
+
.dsh-devtools-terminal-host {
|
|
692
|
+
position: relative;
|
|
693
|
+
width: 100%;
|
|
694
|
+
height: 100%;
|
|
695
|
+
background: var(--dsw-alias-bg-base);
|
|
696
|
+
}
|
|
697
|
+
.dsh-devtools-terminal-host .xterm { height: 100%; }
|
|
698
|
+
/* xterm 自己的 .xterm-viewport 默认是 #000,不盖掉会在浅色主题下露黑边。 */
|
|
699
|
+
.dsh-devtools-terminal-host .xterm-viewport { background: var(--dsw-alias-bg-base); }
|
|
700
|
+
|
|
701
|
+
/* ─────────────────────── 光标 ─────────────────────── */
|
|
702
|
+
/* 光标颜色由懒加载块逐帧算(保证在任何单元格背景上都看得见),这里是消费端。
|
|
703
|
+
--dsh-devtools-terminal-cursor-* 由 chunks/terminal.ts 写在 host 的行内样式上。 */
|
|
704
|
+
.dsh-devtools-terminal-host .xterm-cursor { opacity: 1; }
|
|
705
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-block {
|
|
706
|
+
background-color: var(--dsh-devtools-terminal-cursor);
|
|
707
|
+
color: var(--dsh-devtools-terminal-cursor-accent);
|
|
708
|
+
}
|
|
709
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) {
|
|
710
|
+
background-color: var(--dsh-devtools-terminal-cursor) !important;
|
|
711
|
+
color: var(--dsh-devtools-terminal-cursor-accent) !important;
|
|
712
|
+
}
|
|
713
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-outline {
|
|
714
|
+
outline-color: var(--dsh-devtools-terminal-cursor);
|
|
715
|
+
}
|
|
716
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-bar {
|
|
717
|
+
box-shadow: 1px 0 0 var(--dsh-devtools-terminal-cursor) inset;
|
|
718
|
+
}
|
|
719
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-underline {
|
|
720
|
+
border-bottom-color: var(--dsh-devtools-terminal-cursor);
|
|
721
|
+
}
|
|
722
|
+
@keyframes dsh-devtools-terminal-cursor-blink {
|
|
723
|
+
0% {
|
|
724
|
+
background-color: var(--dsh-devtools-terminal-cursor);
|
|
725
|
+
color: var(--dsh-devtools-terminal-cursor-accent);
|
|
726
|
+
}
|
|
727
|
+
50% {
|
|
728
|
+
background-color: var(--dsh-devtools-terminal-cursor-cell-background);
|
|
729
|
+
color: var(--dsh-devtools-terminal-cursor-cell-foreground);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
.dsh-devtools-terminal-host .xterm .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-block.xterm-cursor-blink {
|
|
733
|
+
animation: 1s step-end infinite dsh-devtools-terminal-cursor-blink;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/* ─────────────────────── 错误与空态 ─────────────────────── */
|
|
737
|
+
.dsh-devtools-terminal-error {
|
|
738
|
+
flex: none;
|
|
739
|
+
margin: 0;
|
|
740
|
+
padding: 10px 12px;
|
|
741
|
+
overflow-wrap: anywhere;
|
|
742
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
743
|
+
color: var(--dsw-alias-label-primary);
|
|
744
|
+
}
|
|
745
|
+
.dsh-devtools-terminal-empty {
|
|
746
|
+
display: flex;
|
|
747
|
+
flex: 1 1 auto;
|
|
748
|
+
flex-direction: column;
|
|
749
|
+
align-items: center;
|
|
750
|
+
justify-content: center;
|
|
751
|
+
gap: 16px;
|
|
752
|
+
min-height: 0;
|
|
753
|
+
padding: 24px;
|
|
754
|
+
overflow: auto;
|
|
755
|
+
text-align: center;
|
|
756
|
+
}
|
|
757
|
+
.dsh-devtools-terminal-empty-message {
|
|
758
|
+
max-width: 28ch;
|
|
759
|
+
margin: 0;
|
|
760
|
+
line-height: 1.6;
|
|
761
|
+
overflow-wrap: anywhere;
|
|
762
|
+
}
|
|
763
|
+
.dsh-devtools-terminal-empty-detail {
|
|
764
|
+
max-width: 40ch;
|
|
765
|
+
margin: 0;
|
|
766
|
+
color: var(--dsw-alias-label-tertiary);
|
|
767
|
+
font-size: 11.5px;
|
|
768
|
+
line-height: 1.5;
|
|
769
|
+
overflow-wrap: anywhere;
|
|
770
|
+
}
|
|
771
|
+
/* xterm 建不起来(懒加载块 404 / 浏览器不兼容)时的覆盖层:终端区不能留一块
|
|
772
|
+
黑屏让人猜,要给错误原文和重试。 */
|
|
773
|
+
.dsh-devtools-terminal-failure {
|
|
774
|
+
position: absolute;
|
|
775
|
+
inset: 0;
|
|
776
|
+
display: flex;
|
|
777
|
+
flex-direction: column;
|
|
778
|
+
align-items: center;
|
|
779
|
+
justify-content: center;
|
|
780
|
+
gap: 12px;
|
|
781
|
+
padding: 24px;
|
|
782
|
+
text-align: center;
|
|
783
|
+
background: var(--dsw-alias-bg-base);
|
|
784
|
+
color: var(--dsw-alias-label-primary);
|
|
785
|
+
}
|
|
786
|
+
`);
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/client/terminal/body.tsx
|
|
789
|
+
/**
|
|
790
|
+
* 右侧栏终端正文:接管平台内置的 `terminal` 页签。
|
|
791
|
+
*
|
|
792
|
+
* ## 为什么是"接管"而不是"新写一个页签类型"
|
|
793
|
+
*
|
|
794
|
+
* 平台的终端正文只做「PTY → xterm」,没有 link provider —— 输出里的 URL 只是
|
|
795
|
+
* 普通文字。平台把那份实现做成**私有**懒加载块,第三方插件既拿不到 xterm 实例,
|
|
796
|
+
* 也换不掉它的 UI。`sidebar-right` 的页签类型注册表允许 `priority: 'extension'`
|
|
797
|
+
* 顶替同 kind 的内置类型(`dsh-devtools-files` 已用同一手法接管 `files`),
|
|
798
|
+
* 所以这里注册同一个 key、自己渲染正文,PTY 仍然用平台的 `ctx.webTerminals`。
|
|
799
|
+
*
|
|
800
|
+
* ## 和平台正文的对应关系
|
|
801
|
+
*
|
|
802
|
+
* 状态栏、只读 / 接管输入、重试、缺失终端、帧泵(snapshot 重置、output 追加、
|
|
803
|
+
* 写完 `acknowledge`)、fit 夹宿主上限、隐藏页签不 fit、主题变化换色、卸载
|
|
804
|
+
* dispose —— 全部照平台 `TerminalBody` / `TerminalScreen` 的行为搬过来。
|
|
805
|
+
*
|
|
806
|
+
* ## 两条不能破的规矩
|
|
807
|
+
*
|
|
808
|
+
* 1. **帧必须 ack**:宿主是背压发送的,不 ack 就再也不发下一帧(终端看起来
|
|
809
|
+
* 永久冻住)。ack 只能写在 `xterm.write` 的回调里 —— 那才是"真的解析完"。
|
|
810
|
+
* 2. **终端服务可能不在**(用户关掉了那一行):`injection` 的 `view` 会抛。
|
|
811
|
+
* 正文必须自己接住并显示成空态,不能让一个页签把渲染打挂 —— 而且**不能**
|
|
812
|
+
* 用「服务不在就少调一个 hook」的写法:hook 数量随服务在不在变化,React 会
|
|
813
|
+
* 在下一次渲染时直接报 hook 顺序错误。所以状态统一从 `model.state` 读。
|
|
814
|
+
*/
|
|
815
|
+
/** 平台终端也用的等宽字体栈;用户的主题字体栈接在它前面。 */
|
|
816
|
+
const TERMINAL_FONT_STACK = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
817
|
+
/** 服务缺席时的空快照(同一引用,不会触发重渲染)。 */
|
|
818
|
+
const EMPTY_STATE = {};
|
|
819
|
+
/**
|
|
820
|
+
* 服务缺席时给 uSES 的空源。
|
|
821
|
+
*
|
|
822
|
+
* 为什么不直接用注入的 `useTerminal`:它在服务缺席时**直接抛**,而 hook 不能
|
|
823
|
+
* 因为服务在不在而少调/多调(React 会报 hook 顺序变化)。这里把「状态源」当成
|
|
824
|
+
* 普通值:服务不在就拿这份空源,hook 数量永远一致,服务恢复后也能正常接上。
|
|
825
|
+
*/
|
|
826
|
+
const ABSENT_STATE = {
|
|
827
|
+
getSnapshot: () => EMPTY_STATE,
|
|
828
|
+
subscribe: () => () => void 0
|
|
829
|
+
};
|
|
830
|
+
/**
|
|
831
|
+
* 终端页签正文。
|
|
832
|
+
*
|
|
833
|
+
* @param props - 槽运行时 props + 注入面(见 `injection.ts`)。
|
|
834
|
+
* @returns 渲染结果。
|
|
835
|
+
*/
|
|
836
|
+
function TerminalBody(props) {
|
|
837
|
+
const { useTabInfo, useTheme, view: resolveView, openUrl } = props;
|
|
838
|
+
const { tab } = useTabInfo();
|
|
839
|
+
const theme = useTheme((value) => value);
|
|
840
|
+
let model;
|
|
841
|
+
let unavailable;
|
|
842
|
+
try {
|
|
843
|
+
model = resolveView(tab.id);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
unavailable = messageOf(error);
|
|
846
|
+
}
|
|
847
|
+
const source = model?.state ?? ABSENT_STATE;
|
|
848
|
+
const subscribe = (0, react.useCallback)((listener) => source.subscribe(listener), [source]);
|
|
849
|
+
const getSnapshot = (0, react.useCallback)(() => source.getSnapshot(), [source]);
|
|
850
|
+
const state = (0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
851
|
+
(0, react.useEffect)(() => {
|
|
852
|
+
if (model === void 0) return;
|
|
853
|
+
return model.mount();
|
|
854
|
+
}, [model]);
|
|
855
|
+
if (unavailable !== void 0 || model === void 0) {
|
|
856
|
+
const reason = unavailable;
|
|
857
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
858
|
+
className: cls.root,
|
|
859
|
+
"data-devtools-terminal": true,
|
|
860
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
861
|
+
className: cls.empty,
|
|
862
|
+
role: "status",
|
|
863
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
864
|
+
className: cls.emptyMessage,
|
|
865
|
+
children: t("unavailable")
|
|
866
|
+
}), reason === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
867
|
+
className: cls.emptyDetail,
|
|
868
|
+
children: reason
|
|
869
|
+
})]
|
|
870
|
+
})
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const view = model;
|
|
874
|
+
/** "新建终端"动作(缺失终端 / 已退出时都要给)。 */
|
|
875
|
+
const newTerminal = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
876
|
+
type: "button",
|
|
877
|
+
className: cls.button,
|
|
878
|
+
onClick: () => {
|
|
879
|
+
tab.actions.openTab("terminal", { replaceTab: true });
|
|
880
|
+
},
|
|
881
|
+
children: t("new")
|
|
882
|
+
});
|
|
883
|
+
if (state.issue === "missingTerminal") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
884
|
+
className: cls.root,
|
|
885
|
+
"data-devtools-terminal": true,
|
|
886
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
887
|
+
className: cls.empty,
|
|
888
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
889
|
+
className: cls.emptyMessage,
|
|
890
|
+
role: "alert",
|
|
891
|
+
children: t("missingTerminal")
|
|
892
|
+
}), newTerminal]
|
|
893
|
+
})
|
|
894
|
+
});
|
|
895
|
+
const error = state.phase === "disconnected" ? void 0 : state.issue === void 0 ? state.error ?? state.info?.error : issueText(state.issue);
|
|
896
|
+
let status;
|
|
897
|
+
if (state.phase === "idle" || state.phase === "loading") status = t("loading");
|
|
898
|
+
else if (state.phase === "creating" || state.phase === "connecting" || state.phase === "disconnected") status = t(state.phase);
|
|
899
|
+
else if (state.info?.state === "exited") status = t("exited", { code: String(state.info.exitCode ?? "—") });
|
|
900
|
+
else if (state.info?.state === "failed") status = t("unavailable");
|
|
901
|
+
else if (state.phase === "closed") status = t("closed");
|
|
902
|
+
const ended = state.info?.state === "exited" || state.phase === "closed";
|
|
903
|
+
const retry = !ended && (state.phase === "failed" || state.phase === "disconnected");
|
|
904
|
+
const readOnly = state.phase === "connected" && state.info?.state === "running" && state.writable !== true;
|
|
905
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
906
|
+
className: cls.root,
|
|
907
|
+
"data-devtools-terminal": true,
|
|
908
|
+
children: [
|
|
909
|
+
status !== void 0 || retry || readOnly ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
910
|
+
className: cls.status,
|
|
911
|
+
role: "status",
|
|
912
|
+
children: [
|
|
913
|
+
status === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: status }),
|
|
914
|
+
readOnly ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("readonly") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
915
|
+
type: "button",
|
|
916
|
+
className: cls.button,
|
|
917
|
+
onClick: () => {
|
|
918
|
+
view.connect();
|
|
919
|
+
},
|
|
920
|
+
children: t("control")
|
|
921
|
+
})] }) : null,
|
|
922
|
+
retry ? state.info === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
923
|
+
type: "button",
|
|
924
|
+
className: cls.button,
|
|
925
|
+
onClick: () => {
|
|
926
|
+
view.refresh();
|
|
927
|
+
},
|
|
928
|
+
children: t(state.phase === "disconnected" ? "reconnect" : "retry")
|
|
929
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
930
|
+
type: "button",
|
|
931
|
+
className: cls.button,
|
|
932
|
+
onClick: () => {
|
|
933
|
+
view.connect();
|
|
934
|
+
},
|
|
935
|
+
children: t("reconnect")
|
|
936
|
+
}) : null,
|
|
937
|
+
ended ? newTerminal : null
|
|
938
|
+
]
|
|
939
|
+
}) : null,
|
|
940
|
+
state.info === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalScreen, {
|
|
941
|
+
state,
|
|
942
|
+
model: view,
|
|
943
|
+
visible: tab.visible,
|
|
944
|
+
label: t("title"),
|
|
945
|
+
theme,
|
|
946
|
+
openUrl
|
|
947
|
+
}),
|
|
948
|
+
error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
949
|
+
className: cls.error,
|
|
950
|
+
role: "alert",
|
|
951
|
+
children: t("failed", { message: error })
|
|
952
|
+
})
|
|
953
|
+
]
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* 终端屏幕:xterm 实例 + 帧泵 + 尺寸同步。
|
|
958
|
+
*
|
|
959
|
+
* xterm 几百 KB,走懒加载块(`chunk-loader.ts`)。所以创建是**异步**的;在块
|
|
960
|
+
* 到位之前帧照常会来,创建完成后用"最新一帧"把屏幕补上(`lastRevision` 是
|
|
961
|
+
* 跨渲染的 ref,重复帧不会画第二遍,也不会重复 ack)。
|
|
962
|
+
*
|
|
963
|
+
* @param props - 见 {@link TerminalScreenProps}。
|
|
964
|
+
* @returns 屏幕容器。
|
|
965
|
+
*/
|
|
966
|
+
function TerminalScreen({ state, model, visible, label, theme, openUrl }) {
|
|
967
|
+
const host = (0, react.useRef)(null);
|
|
968
|
+
/** 懒加载块到位后建出来的句柄。 */
|
|
969
|
+
const handle = (0, react.useRef)(void 0);
|
|
970
|
+
/** 已经画到第几版(用来丢重复帧 / 补帧)。 */
|
|
971
|
+
const lastRevision = (0, react.useRef)(0);
|
|
972
|
+
const [factory, setFactory] = (0, react.useState)(void 0);
|
|
973
|
+
/** 懒加载或建 xterm 失败时的原文。 */
|
|
974
|
+
const [failure, setFailure] = (0, react.useState)(void 0);
|
|
975
|
+
/** 创建代数:下游 effect 靠它知道"句柄刚建好,该补一次"。 */
|
|
976
|
+
const [ready, setReady] = (0, react.useState)(0);
|
|
977
|
+
/** 用户点重试时 +1(作为 effect 依赖重新触发)。 */
|
|
978
|
+
const [attempt, setAttempt] = (0, react.useState)(0);
|
|
979
|
+
const fontFamily = terminalFontFamily(theme.fontFamilies.terminal);
|
|
980
|
+
const fontSize = theme.fontSizes.terminal;
|
|
981
|
+
const size = infoSize(state.info);
|
|
982
|
+
/**
|
|
983
|
+
* 最新一份"渲染时数据"。
|
|
984
|
+
*
|
|
985
|
+
* 懒加载、ResizeObserver 的回调都发生在两次渲染之间;闭包里的 props 会过期,
|
|
986
|
+
* 所以统一从这里读最新值(平台正文同样的做法)。
|
|
987
|
+
*/
|
|
988
|
+
const current = (0, react.useRef)({
|
|
989
|
+
state,
|
|
990
|
+
visible,
|
|
991
|
+
fontFamily,
|
|
992
|
+
fontSize,
|
|
993
|
+
label,
|
|
994
|
+
openUrl
|
|
995
|
+
});
|
|
996
|
+
current.current = {
|
|
997
|
+
state,
|
|
998
|
+
visible,
|
|
999
|
+
fontFamily,
|
|
1000
|
+
fontSize,
|
|
1001
|
+
label,
|
|
1002
|
+
openUrl
|
|
1003
|
+
};
|
|
1004
|
+
(0, react.useEffect)(() => {
|
|
1005
|
+
if (factory !== void 0) return;
|
|
1006
|
+
let alive = true;
|
|
1007
|
+
useChunk("terminal").then((create) => {
|
|
1008
|
+
if (alive) setFactory(() => create);
|
|
1009
|
+
}).catch((error) => {
|
|
1010
|
+
if (alive) setFailure(messageOf(error));
|
|
1011
|
+
});
|
|
1012
|
+
return () => {
|
|
1013
|
+
alive = false;
|
|
1014
|
+
};
|
|
1015
|
+
}, [factory, attempt]);
|
|
1016
|
+
(0, react.useLayoutEffect)(() => {
|
|
1017
|
+
if (factory === void 0) return;
|
|
1018
|
+
const element = host.current;
|
|
1019
|
+
if (element === null) return;
|
|
1020
|
+
const snapshot = current.current;
|
|
1021
|
+
let created;
|
|
1022
|
+
try {
|
|
1023
|
+
created = factory(element, {
|
|
1024
|
+
fontFamily: snapshot.fontFamily,
|
|
1025
|
+
fontSize: snapshot.fontSize,
|
|
1026
|
+
scrollback: snapshot.state.environment?.scrollback ?? 0,
|
|
1027
|
+
readOnly: snapshot.state.writable !== true,
|
|
1028
|
+
label: snapshot.label,
|
|
1029
|
+
openUrl: (url) => {
|
|
1030
|
+
current.current.openUrl(url);
|
|
1031
|
+
},
|
|
1032
|
+
onData: (data) => {
|
|
1033
|
+
model.write(data);
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
setFailure(messageOf(error));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
handle.current = created;
|
|
1041
|
+
lastRevision.current = 0;
|
|
1042
|
+
const render = snapshot.state.render;
|
|
1043
|
+
if (render !== void 0) {
|
|
1044
|
+
lastRevision.current = render.revision;
|
|
1045
|
+
paintFrame(created, render, model);
|
|
1046
|
+
}
|
|
1047
|
+
created.setReadOnly(snapshot.state.writable !== true);
|
|
1048
|
+
if (snapshot.visible && snapshot.state.writable === true) fitScreen(element, created, snapshot.state, model);
|
|
1049
|
+
else if (snapshot.state.writable !== true) resizeToHost(created, snapshot.state);
|
|
1050
|
+
setFailure(void 0);
|
|
1051
|
+
setReady((value) => value + 1);
|
|
1052
|
+
return () => {
|
|
1053
|
+
created.dispose();
|
|
1054
|
+
if (handle.current === created) handle.current = void 0;
|
|
1055
|
+
};
|
|
1056
|
+
}, [
|
|
1057
|
+
factory,
|
|
1058
|
+
model,
|
|
1059
|
+
attempt
|
|
1060
|
+
]);
|
|
1061
|
+
(0, react.useLayoutEffect)(() => {
|
|
1062
|
+
const instance = handle.current;
|
|
1063
|
+
if (instance === void 0) return;
|
|
1064
|
+
const render = state.render;
|
|
1065
|
+
if (render === void 0 || render.revision <= lastRevision.current) return;
|
|
1066
|
+
lastRevision.current = render.revision;
|
|
1067
|
+
paintFrame(instance, render, model);
|
|
1068
|
+
}, [
|
|
1069
|
+
state.render,
|
|
1070
|
+
model,
|
|
1071
|
+
ready
|
|
1072
|
+
]);
|
|
1073
|
+
(0, react.useLayoutEffect)(() => {
|
|
1074
|
+
handle.current?.applyTheme();
|
|
1075
|
+
}, [
|
|
1076
|
+
theme,
|
|
1077
|
+
model,
|
|
1078
|
+
ready
|
|
1079
|
+
]);
|
|
1080
|
+
(0, react.useLayoutEffect)(() => {
|
|
1081
|
+
const instance = handle.current;
|
|
1082
|
+
if (instance === void 0) return;
|
|
1083
|
+
if (!instance.setFont(fontFamily, fontSize)) return;
|
|
1084
|
+
const element = host.current;
|
|
1085
|
+
if (element === null) return;
|
|
1086
|
+
const snapshot = current.current;
|
|
1087
|
+
if (!snapshot.visible || snapshot.state.writable !== true) return;
|
|
1088
|
+
fitScreen(element, instance, snapshot.state, model);
|
|
1089
|
+
}, [
|
|
1090
|
+
fontFamily,
|
|
1091
|
+
fontSize,
|
|
1092
|
+
model,
|
|
1093
|
+
ready
|
|
1094
|
+
]);
|
|
1095
|
+
(0, react.useLayoutEffect)(() => {
|
|
1096
|
+
const instance = handle.current;
|
|
1097
|
+
if (instance === void 0) return;
|
|
1098
|
+
instance.setReadOnly(state.writable !== true);
|
|
1099
|
+
const element = host.current;
|
|
1100
|
+
if (element === null) return;
|
|
1101
|
+
if (visible && state.writable === true) fitScreen(element, instance, state, model);
|
|
1102
|
+
else if (state.writable !== true) resizeToHost(instance, state);
|
|
1103
|
+
}, [
|
|
1104
|
+
visible,
|
|
1105
|
+
state.writable,
|
|
1106
|
+
size?.cols,
|
|
1107
|
+
size?.rows,
|
|
1108
|
+
model,
|
|
1109
|
+
ready
|
|
1110
|
+
]);
|
|
1111
|
+
(0, react.useEffect)(() => {
|
|
1112
|
+
handle.current?.setLabel(label);
|
|
1113
|
+
}, [label, ready]);
|
|
1114
|
+
(0, react.useEffect)(() => {
|
|
1115
|
+
if (!visible || state.writable !== true) return;
|
|
1116
|
+
handle.current?.focus();
|
|
1117
|
+
}, [
|
|
1118
|
+
visible,
|
|
1119
|
+
state.writable,
|
|
1120
|
+
ready
|
|
1121
|
+
]);
|
|
1122
|
+
(0, react.useLayoutEffect)(() => {
|
|
1123
|
+
const element = host.current;
|
|
1124
|
+
if (element === null) return;
|
|
1125
|
+
const observer = new ResizeObserver(() => {
|
|
1126
|
+
const instance = handle.current;
|
|
1127
|
+
if (instance === void 0) return;
|
|
1128
|
+
const snapshot = current.current;
|
|
1129
|
+
if (!snapshot.visible || snapshot.state.writable !== true) return;
|
|
1130
|
+
if (element.clientWidth === 0 || element.clientHeight === 0) return;
|
|
1131
|
+
fitScreen(element, instance, snapshot.state, model);
|
|
1132
|
+
});
|
|
1133
|
+
observer.observe(element);
|
|
1134
|
+
return () => {
|
|
1135
|
+
observer.disconnect();
|
|
1136
|
+
};
|
|
1137
|
+
}, [model, ready]);
|
|
1138
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1139
|
+
className: cls.screen,
|
|
1140
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1141
|
+
className: cls.host,
|
|
1142
|
+
ref: host
|
|
1143
|
+
}), failure === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1144
|
+
className: cls.failure,
|
|
1145
|
+
role: "alert",
|
|
1146
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1147
|
+
className: cls.emptyMessage,
|
|
1148
|
+
children: t("failed", { message: failure })
|
|
1149
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1150
|
+
type: "button",
|
|
1151
|
+
className: cls.button,
|
|
1152
|
+
onClick: () => {
|
|
1153
|
+
setFailure(void 0);
|
|
1154
|
+
setAttempt((value) => value + 1);
|
|
1155
|
+
},
|
|
1156
|
+
children: t("retry")
|
|
1157
|
+
})]
|
|
1158
|
+
})]
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* 画一帧。
|
|
1163
|
+
*
|
|
1164
|
+
* snapshot 是"全量":先 reset 再按宿主给的行列开屏,然后写整屏文本;
|
|
1165
|
+
* output 是增量,直接追加。**两种情况都必须写完后 ack**,否则宿主不发下一帧。
|
|
1166
|
+
*
|
|
1167
|
+
* @param handle - xterm 句柄。
|
|
1168
|
+
* @param render - 宿主帧。
|
|
1169
|
+
* @param model - 宿主视图(ack 用)。
|
|
1170
|
+
*/
|
|
1171
|
+
function paintFrame(handle, render, model) {
|
|
1172
|
+
const frame = render.frame;
|
|
1173
|
+
if (frame.type === "snapshot") {
|
|
1174
|
+
if (frame.info !== void 0) handle.reset(frame.info.cols, frame.info.rows);
|
|
1175
|
+
handle.write(frame.screen ?? "", () => {
|
|
1176
|
+
model.acknowledge(render.revision);
|
|
1177
|
+
});
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
handle.write(frame.data ?? "", () => {
|
|
1181
|
+
model.acknowledge(render.revision);
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* 按容器尺寸同步行列,并用宿主环境上限夹住。
|
|
1186
|
+
*
|
|
1187
|
+
* 不夹的话 PTY 会收到超范围的行列(宿主的 PTY 侧有上限,越界会直接拒)。
|
|
1188
|
+
*
|
|
1189
|
+
* @param host - 容器(有真实盒子尺寸才量)。
|
|
1190
|
+
* @param handle - xterm 句柄。
|
|
1191
|
+
* @param state - 当前状态(读 `environment.maxCols/maxRows`)。
|
|
1192
|
+
* @param model - 宿主视图(同步行列)。
|
|
1193
|
+
*/
|
|
1194
|
+
function fitScreen(host, handle, state, model) {
|
|
1195
|
+
if (host.clientWidth === 0 || host.clientHeight === 0) return;
|
|
1196
|
+
const proposed = handle.fit();
|
|
1197
|
+
if (proposed === void 0) return;
|
|
1198
|
+
const environment = state.environment;
|
|
1199
|
+
const cols = environment?.maxCols === void 0 ? proposed.cols : Math.min(proposed.cols, environment.maxCols);
|
|
1200
|
+
const rows = environment?.maxRows === void 0 ? proposed.rows : Math.min(proposed.rows, environment.maxRows);
|
|
1201
|
+
if (cols < 2 || rows < 1) return;
|
|
1202
|
+
if (cols !== proposed.cols || rows !== proposed.rows) handle.resize(cols, rows);
|
|
1203
|
+
model.resize(cols, rows);
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* 只读时跟随宿主给的行列(不量容器,也不反向 notify 宿主)。
|
|
1207
|
+
*
|
|
1208
|
+
* @param handle - xterm 句柄。
|
|
1209
|
+
* @param state - 当前状态。
|
|
1210
|
+
*/
|
|
1211
|
+
function resizeToHost(handle, state) {
|
|
1212
|
+
const size = infoSize(state.info);
|
|
1213
|
+
if (size !== void 0) handle.resize(size.cols, size.rows);
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* 从宿主 info 里读行列。
|
|
1217
|
+
*
|
|
1218
|
+
* 契约类型(`injection.ts`)只列了正文**直接**读的字段,宿主的 `TerminalInfo`
|
|
1219
|
+
* 实际还带 `cols/rows`(平台正文用它做只读尺寸对齐)。这里按能力探测取,
|
|
1220
|
+
* 拿不到就跳过 —— 不影响可写路径。
|
|
1221
|
+
*
|
|
1222
|
+
* @param info - 状态里的 info。
|
|
1223
|
+
* @returns 行列,或 undefined。
|
|
1224
|
+
*/
|
|
1225
|
+
function infoSize(info) {
|
|
1226
|
+
if (info === void 0) return void 0;
|
|
1227
|
+
const probe = info;
|
|
1228
|
+
if (typeof probe.cols !== "number" || typeof probe.rows !== "number") return void 0;
|
|
1229
|
+
return {
|
|
1230
|
+
cols: probe.cols,
|
|
1231
|
+
rows: probe.rows
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* 拼字体族:用户的终端字体栈 + 平台兜底栈。
|
|
1236
|
+
*
|
|
1237
|
+
* @param list - 主题给的字体族(可能为空)。
|
|
1238
|
+
* @returns xterm 的 `fontFamily`。
|
|
1239
|
+
*/
|
|
1240
|
+
function terminalFontFamily(list) {
|
|
1241
|
+
return list === "" ? TERMINAL_FONT_STACK : `${list}, ${TERMINAL_FONT_STACK}`;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* 宿主 issue → 文案。
|
|
1245
|
+
*
|
|
1246
|
+
* @param issue - 宿主给的 issue(契约里是 string,实际是文案键的并集)。
|
|
1247
|
+
* @returns 认得的键取当前语言文案,不认得就原样显示(好过空白)。
|
|
1248
|
+
*/
|
|
1249
|
+
function issueText(issue) {
|
|
1250
|
+
return Object.hasOwn(zh, issue) ? t(issue) : issue;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* 取错误原文。
|
|
1254
|
+
*
|
|
1255
|
+
* @param error - 任意抛出物。
|
|
1256
|
+
* @returns 可显示的文字。
|
|
1257
|
+
*/
|
|
1258
|
+
function messageOf(error) {
|
|
1259
|
+
return error instanceof Error ? error.message : String(error);
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/client/title.tsx
|
|
1263
|
+
/** 注入一次标题的样式。 */
|
|
1264
|
+
injectStyle("dsh-devtools-terminal/title", ".dsh-devtools-terminal-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-devtools-terminal-title-icon{flex:none;display:inline-flex;color:currentColor;opacity:.8}");
|
|
1265
|
+
/**
|
|
1266
|
+
* 终端页签标题。
|
|
1267
|
+
*
|
|
1268
|
+
* @param props - 槽运行时 props。
|
|
1269
|
+
* @returns 图标 + 当前名称。
|
|
1270
|
+
*/
|
|
1271
|
+
function TerminalTitle(props) {
|
|
1272
|
+
const { tab } = props.useTabInfo();
|
|
1273
|
+
const live = props.useTerminal(tab.id, (state) => state?.info?.title ?? state?.title);
|
|
1274
|
+
const title = (live !== void 0 && live !== "" ? live : void 0) ?? tab.title ?? t("title");
|
|
1275
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1276
|
+
className: "dsh-devtools-terminal-title",
|
|
1277
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1278
|
+
className: "dsh-devtools-terminal-title-icon",
|
|
1279
|
+
"aria-hidden": "true",
|
|
1280
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1281
|
+
width: "16",
|
|
1282
|
+
height: "16",
|
|
1283
|
+
viewBox: "0 0 16 16",
|
|
1284
|
+
fill: "none",
|
|
1285
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1286
|
+
d: "M3 4L7 8L3 12",
|
|
1287
|
+
stroke: "currentColor"
|
|
1288
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1289
|
+
d: "M9 12H13",
|
|
1290
|
+
stroke: "currentColor"
|
|
1291
|
+
})]
|
|
1292
|
+
})
|
|
1293
|
+
}), title]
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/client/index.tsx
|
|
1298
|
+
/** Cordis 插件名(= 包名)。 */
|
|
1299
|
+
const name = "dsh-devtools-terminal";
|
|
1300
|
+
/** 静态依赖门(见文件头)。 */
|
|
1301
|
+
const inject = [
|
|
1302
|
+
"slots",
|
|
1303
|
+
"locale",
|
|
1304
|
+
"sidebarRight",
|
|
1305
|
+
"sidebarRightTabs"
|
|
1306
|
+
];
|
|
1307
|
+
/** 页签类型的 id。**必须唯一**,用包名最省心(slot 注册的 key 也是它)。 */
|
|
1308
|
+
const TAB_ID = "@zhangzhian/dsh-devtools-terminal";
|
|
1309
|
+
/** 接管的 kind(平台内置终端的 discriminator)。 */
|
|
1310
|
+
const TAB_KIND = "terminal";
|
|
1311
|
+
/** 引导页里本卡片的排序(平台终端是 20;我们接替它,保持同一位置)。 */
|
|
1312
|
+
const GUIDE_ORDER = 20;
|
|
1313
|
+
/**
|
|
1314
|
+
* 挂载客户端半。
|
|
1315
|
+
*
|
|
1316
|
+
* @param ctx - 客户端根上下文。
|
|
1317
|
+
*/
|
|
1318
|
+
function apply(ctx) {
|
|
1319
|
+
ctx.inject([
|
|
1320
|
+
"slots",
|
|
1321
|
+
"locale",
|
|
1322
|
+
"sidebarRight",
|
|
1323
|
+
"sidebarRightTabs"
|
|
1324
|
+
], (scoped) => {
|
|
1325
|
+
installLocale(scoped.locale);
|
|
1326
|
+
scoped.effect(() => scoped.locale.register(NS, {
|
|
1327
|
+
zh,
|
|
1328
|
+
en
|
|
1329
|
+
}), "dsh-devtools-terminal: locale");
|
|
1330
|
+
/**
|
|
1331
|
+
* 正文/标题的注入面。
|
|
1332
|
+
*
|
|
1333
|
+
* ⚠️ 必须在**注册时**造,不能提到外面:`ctx.inject` 的回调跑起来时
|
|
1334
|
+
* `sidebarRight` 才就位;而 `inject` 回调里的 `ctx.get('webTerminals')`
|
|
1335
|
+
* 是每次渲染调用时再探的(见 `injection.ts`)。
|
|
1336
|
+
*/
|
|
1337
|
+
const injectFace = createTerminalInjection(scoped);
|
|
1338
|
+
scoped.effect(() => scoped.sidebarRightTabs.register({
|
|
1339
|
+
id: TAB_ID,
|
|
1340
|
+
kind: TAB_KIND,
|
|
1341
|
+
multiple: true,
|
|
1342
|
+
priority: "extension",
|
|
1343
|
+
title: () => t("title"),
|
|
1344
|
+
guide: [{
|
|
1345
|
+
id: TAB_ID,
|
|
1346
|
+
order: GUIDE_ORDER,
|
|
1347
|
+
title: () => t("new"),
|
|
1348
|
+
description: () => t("description"),
|
|
1349
|
+
icon: _deepseek_ai_dsh_client_ui_primitives.PluginArtworkTerminal
|
|
1350
|
+
}]
|
|
1351
|
+
}), "dsh-devtools-terminal: tab type");
|
|
1352
|
+
scoped.effect(() => scoped.slots.inject("sidebar.right.pane.tab", () => scoped.slots.register({
|
|
1353
|
+
name: "sidebar.right.pane.tab",
|
|
1354
|
+
key: TAB_ID,
|
|
1355
|
+
inject: injectFace
|
|
1356
|
+
}, TerminalBody)), "dsh-devtools-terminal: body");
|
|
1357
|
+
scoped.effect(() => scoped.slots.inject("sidebar.right.pane.tab.title", () => scoped.slots.register({
|
|
1358
|
+
name: "sidebar.right.pane.tab.title",
|
|
1359
|
+
key: TAB_ID,
|
|
1360
|
+
inject: injectFace
|
|
1361
|
+
}, TerminalTitle)), "dsh-devtools-terminal: title");
|
|
1362
|
+
scoped.effect(() => scoped.slots.inject("sidebar.right.tab.guide.entry", () => scoped.slots.register({
|
|
1363
|
+
name: "sidebar.right.tab.guide.entry",
|
|
1364
|
+
key: TAB_ID,
|
|
1365
|
+
inject: (sessionId) => createGuideInjection(scoped, sessionId)
|
|
1366
|
+
}, TerminalGuide)), "dsh-devtools-terminal: guide");
|
|
1367
|
+
if (getTerminals(scoped) === void 0) console.warn("[devtools-terminal] 没有 webTerminals 服务:终端正文会显示「不可用」");
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
//#endregion
|
|
1371
|
+
exports.TAB_ID = TAB_ID;
|
|
1372
|
+
exports.TAB_KIND = TAB_KIND;
|
|
1373
|
+
exports.apply = apply;
|
|
1374
|
+
exports.inject = inject;
|
|
1375
|
+
exports.name = name;
|
|
1376
|
+
|
|
1377
|
+
return module.exports;
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
//# sourceMappingURL=client.js.map
|