@bachi/pi-coder 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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
HEADER_GUARD_KEY,
|
|
5
|
+
findRenderContainer,
|
|
6
|
+
freezeHeaderContainer,
|
|
7
|
+
releaseHeaderGuard,
|
|
8
|
+
type RenderableContainer,
|
|
9
|
+
} from "./header-guard.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 仿 pi-tui 的 `Container`:`clear()` 会**换掉**整个 children 数组
|
|
13
|
+
* (`this.children = []`),所以守卫必须每次重读该字段而不是缓存它。
|
|
14
|
+
*/
|
|
15
|
+
class FakeContainer implements RenderableContainer {
|
|
16
|
+
children: unknown[] = [];
|
|
17
|
+
/** 原 render 被调用的次数(断言「内置 header 一帧都没出」用)。 */
|
|
18
|
+
originalRenders = 0;
|
|
19
|
+
|
|
20
|
+
addChild(component: unknown): void {
|
|
21
|
+
this.children.push(component);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
clear(): void {
|
|
25
|
+
this.children = [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
this.originalRenders += 1;
|
|
30
|
+
return this.children.flatMap((child) => (child as Leaf).render(width));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 仿叶子组件:行里带上自己的名字和宽度,方便看出行是谁、按哪个宽度渲染的。 */
|
|
35
|
+
interface Leaf {
|
|
36
|
+
render(width: number): string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function leaf(name: string, lines = 1): Leaf {
|
|
40
|
+
return { render: (width: number) => Array.from({ length: lines }, () => `${name}#${width}`) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 仿 `tui`:只有 children,没有 render(findRenderContainer 只认 children)。 */
|
|
44
|
+
function tuiRoot(...children: unknown[]): { children: unknown[] } {
|
|
45
|
+
return { children };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("findRenderContainer 按对象身份认出直接持有它的容器", () => {
|
|
49
|
+
const ours = leaf("startup-logo");
|
|
50
|
+
const inner = new FakeContainer();
|
|
51
|
+
inner.addChild(ours);
|
|
52
|
+
const outer = new FakeContainer();
|
|
53
|
+
outer.addChild(inner);
|
|
54
|
+
const root = tuiRoot(new FakeContainer(), outer);
|
|
55
|
+
|
|
56
|
+
assert.equal(findRenderContainer(root, ours), inner);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("findRenderContainer 找不到就返回 undefined,不猜位置", () => {
|
|
60
|
+
const inner = new FakeContainer();
|
|
61
|
+
inner.addChild(leaf("other"));
|
|
62
|
+
const root = tuiRoot(inner);
|
|
63
|
+
|
|
64
|
+
assert.equal(findRenderContainer(root, leaf("missing")), undefined);
|
|
65
|
+
assert.equal(findRenderContainer(root, undefined), undefined);
|
|
66
|
+
assert.equal(findRenderContainer(undefined, leaf("x")), undefined);
|
|
67
|
+
assert.equal(findRenderContainer({ notAContainer: true }, leaf("x")), undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("findRenderContainer 容忍环状引用与只读 children", () => {
|
|
71
|
+
const a = new FakeContainer();
|
|
72
|
+
const b = new FakeContainer();
|
|
73
|
+
a.addChild(b);
|
|
74
|
+
b.addChild(a);
|
|
75
|
+
|
|
76
|
+
assert.equal(findRenderContainer(a, leaf("nope")), undefined);
|
|
77
|
+
|
|
78
|
+
const frozenRoot = tuiRoot({ children: Object.freeze([leaf("x")]) as unknown[] });
|
|
79
|
+
assert.equal(findRenderContainer(frozenRoot, leaf("y")), undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("我们的组件在位时原样透传,被换成内置 header 时出重放行", () => {
|
|
83
|
+
const ours = leaf("startup-logo");
|
|
84
|
+
const builtIn = leaf("pi-default-header");
|
|
85
|
+
const container = new FakeContainer();
|
|
86
|
+
container.addChild(ours);
|
|
87
|
+
|
|
88
|
+
const release = freezeHeaderContainer({
|
|
89
|
+
container,
|
|
90
|
+
ownComponent: ours,
|
|
91
|
+
frozenRender: (width) => [`frozen#${width}`],
|
|
92
|
+
});
|
|
93
|
+
assert.equal(typeof release, "function");
|
|
94
|
+
|
|
95
|
+
// 正常会话:真实渲染照旧。
|
|
96
|
+
assert.deepEqual(container.render(80), ["startup-logo#80"]);
|
|
97
|
+
assert.equal(container.originalRenders, 1);
|
|
98
|
+
|
|
99
|
+
// 换会话窗口:pi 已经 clear() 并挂上内置 header。
|
|
100
|
+
container.clear();
|
|
101
|
+
container.addChild(builtIn);
|
|
102
|
+
assert.deepEqual(container.render(80), ["frozen#80"]);
|
|
103
|
+
assert.deepEqual(container.render(40), ["frozen#40"]);
|
|
104
|
+
// 内置 header 一次都没被渲染 → 屏幕上不会出现它。
|
|
105
|
+
assert.equal(container.originalRenders, 1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("重放行按当前宽度重新截断", () => {
|
|
109
|
+
const ours = leaf("startup-logo");
|
|
110
|
+
const container = new FakeContainer();
|
|
111
|
+
container.addChild(ours);
|
|
112
|
+
const wide = " ".repeat(30);
|
|
113
|
+
freezeHeaderContainer({
|
|
114
|
+
container,
|
|
115
|
+
ownComponent: ours,
|
|
116
|
+
frozenRender: (width) => [wide.slice(0, width)],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
container.clear();
|
|
120
|
+
container.addChild(leaf("pi-default-header"));
|
|
121
|
+
assert.deepEqual(container.render(10), [wide.slice(0, 10)]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("解除接管后恢复原渲染,且不留 own render 属性与接管记录", () => {
|
|
125
|
+
const ours = leaf("startup-logo");
|
|
126
|
+
const builtIn = leaf("pi-default-header");
|
|
127
|
+
const container = new FakeContainer();
|
|
128
|
+
container.addChild(ours);
|
|
129
|
+
|
|
130
|
+
const release = freezeHeaderContainer({
|
|
131
|
+
container,
|
|
132
|
+
ownComponent: ours,
|
|
133
|
+
frozenRender: () => ["frozen"],
|
|
134
|
+
})!;
|
|
135
|
+
container.clear();
|
|
136
|
+
container.addChild(builtIn);
|
|
137
|
+
assert.deepEqual(container.render(10), ["frozen"]);
|
|
138
|
+
|
|
139
|
+
release();
|
|
140
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
141
|
+
assert.equal((container as { [HEADER_GUARD_KEY]?: unknown })[HEADER_GUARD_KEY], undefined);
|
|
142
|
+
assert.deepEqual(container.render(10), ["pi-default-header#10"]);
|
|
143
|
+
|
|
144
|
+
// 幂等:重复解除不再改动任何东西。
|
|
145
|
+
release();
|
|
146
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("releaseHeaderGuard 只在确有接管时报告成功", () => {
|
|
150
|
+
const container = new FakeContainer();
|
|
151
|
+
assert.equal(releaseHeaderGuard(container), false);
|
|
152
|
+
assert.equal(releaseHeaderGuard(undefined), false);
|
|
153
|
+
const release = freezeHeaderContainer({ container, ownComponent: {}, frozenRender: () => ["frozen"] });
|
|
154
|
+
assert.equal(releaseHeaderGuard(container), true);
|
|
155
|
+
// 已被解除 → 再解除报 false。
|
|
156
|
+
release?.();
|
|
157
|
+
assert.equal(releaseHeaderGuard(container), false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("对同一容器重复接管会先交还上一次,不叠两层", () => {
|
|
161
|
+
const first = leaf("first");
|
|
162
|
+
const second = leaf("second");
|
|
163
|
+
const container = new FakeContainer();
|
|
164
|
+
container.addChild(first);
|
|
165
|
+
container.addChild(second);
|
|
166
|
+
|
|
167
|
+
freezeHeaderContainer({ container, ownComponent: first, frozenRender: () => ["frozen-first"] });
|
|
168
|
+
freezeHeaderContainer({ container, ownComponent: second, frozenRender: () => ["frozen-second"] });
|
|
169
|
+
|
|
170
|
+
// first 在位、second 不在位:若两层的 wrapper 还叠着,这里会出 frozen-first;
|
|
171
|
+
// 只保留最后一次接管 → 判定 second 不在位 → 出 frozen-second。
|
|
172
|
+
container.clear();
|
|
173
|
+
container.addChild(first);
|
|
174
|
+
assert.deepEqual(container.render(7), ["frozen-second"]);
|
|
175
|
+
|
|
176
|
+
// second 回到位 → 透传。
|
|
177
|
+
container.addChild(second);
|
|
178
|
+
assert.deepEqual(container.render(7), ["first#7", "second#7"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("超时后自愈:组件在位与否都交还原渲染", () => {
|
|
182
|
+
const ours = leaf("startup-logo");
|
|
183
|
+
const builtIn = leaf("pi-default-header");
|
|
184
|
+
const container = new FakeContainer();
|
|
185
|
+
container.addChild(ours);
|
|
186
|
+
let clock = 1000;
|
|
187
|
+
const release = freezeHeaderContainer({
|
|
188
|
+
container,
|
|
189
|
+
ownComponent: ours,
|
|
190
|
+
frozenRender: () => ["frozen"],
|
|
191
|
+
maxAgeMs: 500,
|
|
192
|
+
now: () => clock,
|
|
193
|
+
})!;
|
|
194
|
+
|
|
195
|
+
// 组件仍在新位置(正常交接后的样子)+ 已超时 → 透传并交还。
|
|
196
|
+
clock = 1600;
|
|
197
|
+
assert.deepEqual(container.render(9), ["startup-logo#9"]);
|
|
198
|
+
assert.equal(Object.prototype.hasOwnProperty.call(container, "render"), false);
|
|
199
|
+
|
|
200
|
+
// 另一侧:新 header 一直没来(换会话失败)→ 超时后回到 pi 默认行为。
|
|
201
|
+
const ours2 = leaf("startup-logo2");
|
|
202
|
+
const container2 = new FakeContainer();
|
|
203
|
+
container2.addChild(ours2);
|
|
204
|
+
let clock2 = 0;
|
|
205
|
+
freezeHeaderContainer({
|
|
206
|
+
container: container2,
|
|
207
|
+
ownComponent: ours2,
|
|
208
|
+
frozenRender: () => ["frozen"],
|
|
209
|
+
maxAgeMs: 500,
|
|
210
|
+
now: () => clock2,
|
|
211
|
+
});
|
|
212
|
+
container2.clear();
|
|
213
|
+
container2.addChild(builtIn);
|
|
214
|
+
clock2 = 501;
|
|
215
|
+
assert.deepEqual(container2.render(9), ["pi-default-header#9"]);
|
|
216
|
+
release();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("未超时期间无论多少帧都只出重放行", () => {
|
|
220
|
+
const ours = leaf("startup-logo");
|
|
221
|
+
const container = new FakeContainer();
|
|
222
|
+
container.addChild(ours);
|
|
223
|
+
freezeHeaderContainer({
|
|
224
|
+
container,
|
|
225
|
+
ownComponent: ours,
|
|
226
|
+
frozenRender: (width) => [`frozen#${width}`, `frozen#${width}`],
|
|
227
|
+
});
|
|
228
|
+
container.clear();
|
|
229
|
+
container.addChild(leaf("pi-default-header"));
|
|
230
|
+
|
|
231
|
+
for (let frame = 0; frame < 5; frame += 1) {
|
|
232
|
+
assert.deepEqual(container.render(80), ["frozen#80", "frozen#80"]);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("容器形状不对时什么都不做(非 tui 模式 / pi 改了结构)", () => {
|
|
237
|
+
const noop = () => ["frozen"];
|
|
238
|
+
assert.equal(freezeHeaderContainer({ container: undefined, ownComponent: {}, frozenRender: noop }), undefined);
|
|
239
|
+
assert.equal(freezeHeaderContainer({ container: {}, ownComponent: {}, frozenRender: noop }), undefined);
|
|
240
|
+
assert.equal(
|
|
241
|
+
freezeHeaderContainer({ container: { children: [] }, ownComponent: {}, frozenRender: noop }),
|
|
242
|
+
undefined,
|
|
243
|
+
);
|
|
244
|
+
assert.equal(
|
|
245
|
+
freezeHeaderContainer({
|
|
246
|
+
container: { children: "not-an-array", render: noop } as unknown as RenderableContainer,
|
|
247
|
+
ownComponent: {},
|
|
248
|
+
frozenRender: noop,
|
|
249
|
+
}),
|
|
250
|
+
undefined,
|
|
251
|
+
);
|
|
252
|
+
// ownComponent 为 undefined(header 还没装上)也不接管。
|
|
253
|
+
assert.equal(
|
|
254
|
+
freezeHeaderContainer({ container: new FakeContainer(), ownComponent: undefined, frozenRender: noop }),
|
|
255
|
+
undefined,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("frozenRender 抛错时回落到原渲染,不把异常漏给宿主", () => {
|
|
260
|
+
const ours = leaf("startup-logo");
|
|
261
|
+
const container = new FakeContainer();
|
|
262
|
+
container.addChild(ours);
|
|
263
|
+
freezeHeaderContainer({
|
|
264
|
+
container,
|
|
265
|
+
ownComponent: ours,
|
|
266
|
+
frozenRender: () => {
|
|
267
|
+
throw new Error("boom");
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
container.clear();
|
|
272
|
+
container.addChild(leaf("pi-default-header"));
|
|
273
|
+
assert.deepEqual(container.render(12), ["pi-default-header#12"]);
|
|
274
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* header-guard.ts — 会话替换期间的 header「冻结」
|
|
3
|
+
*
|
|
4
|
+
* 问题和 statusline 的 footer 冻结同源:pi 每次换会话(`/new`、`/clear`、`/resume`、fork、
|
|
5
|
+
* rewind)都会在 `session_shutdown` 之后、新会话 `session_start` 之前调 `resetExtensionUI()`,
|
|
6
|
+
* 它无条件 `setExtensionHeader(undefined)` 把 header 还原成内置那只(`interactive-mode.js`)。
|
|
7
|
+
* 中间要新建会话对象、重绑扩展,几十毫秒起步,而 pi-tui 的渲染节流只有 16ms,所以这段窗口
|
|
8
|
+
* 至少会出几帧 —— 装了 logo header 的话,顶部会「闪」回 `pi vX.Y.Z` + 提示行,而且内置 header
|
|
9
|
+
* 比 logo 矮,还会带一次整块布局跳动。
|
|
10
|
+
*
|
|
11
|
+
* 修法与 footer 版一致:扩展侧没有更早的钩子(新实例要等重绑才拿到 ctx),所以把保证落在
|
|
12
|
+
* **出帧那一刻** —— 接管 header 容器的 `render`,发现里面装的已经不是我们的组件时,返回上一帧
|
|
13
|
+
* 的行,内置 header 一帧都不出现。
|
|
14
|
+
*
|
|
15
|
+
* 约束(与 statusline/footer-guard.ts 相同):
|
|
16
|
+
* - 只替换容器的 `render`,不改 `children`、不猜下标、不碰 pi 内部状态;
|
|
17
|
+
* - 容器按**对象身份**在 `tui.children` 子树里认(`findRenderContainer`),认不出就什么都不做;
|
|
18
|
+
* - 任何一步抛错都当作「没冻住」,绝不把异常漏给宿主;
|
|
19
|
+
* - 接管记录挂在容器上的 `Symbol.for(...)` 键,`/reload` 后新实例能看到并解除旧实例的接管;
|
|
20
|
+
* 另有 `maxAgeMs` 兜底,交接万一失败也只冻一小会儿。
|
|
21
|
+
*
|
|
22
|
+
* 与 footer 版各持一份、各用各的符号键:两个扩展互不依赖,也不会互相顶掉接管。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 挂在 header 容器上的接管记录键(全局符号注册表:跨扩展实例可见)。 */
|
|
26
|
+
export const HEADER_GUARD_KEY: symbol = Symbol.for("litellm-any.pi-startup-logo.headerGuard");
|
|
27
|
+
|
|
28
|
+
/** 冻结的最长时长:正常情况下新会话的 `session_start` 会远早于它解除。 */
|
|
29
|
+
export const HEADER_GUARD_MAX_AGE_MS = 3000;
|
|
30
|
+
|
|
31
|
+
/** pi-tui `Container` 里本模块用到的那部分结构(`children` 是公开字段)。 */
|
|
32
|
+
export interface RenderableContainer {
|
|
33
|
+
children: unknown[];
|
|
34
|
+
render(width: number): string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface GuardRecord {
|
|
38
|
+
release: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 在 `root` 的子树里按对象身份找「直接装着 `component` 的那个容器」。
|
|
43
|
+
* 只认对象身份(同形状的组件到处都是),带 seen 集与深度上限,任何异常都当作没找到。
|
|
44
|
+
*/
|
|
45
|
+
export function findRenderContainer(
|
|
46
|
+
root: unknown,
|
|
47
|
+
component: unknown,
|
|
48
|
+
maxDepth = 6,
|
|
49
|
+
): RenderableContainer | undefined {
|
|
50
|
+
if (component === undefined || component === null) return undefined;
|
|
51
|
+
const seen = new Set<unknown>();
|
|
52
|
+
|
|
53
|
+
const walk = (node: unknown, depth: number): RenderableContainer | undefined => {
|
|
54
|
+
if (depth > maxDepth || node === null || typeof node !== "object" || seen.has(node)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
seen.add(node);
|
|
58
|
+
const children = (node as { children?: unknown }).children;
|
|
59
|
+
if (!Array.isArray(children)) return undefined;
|
|
60
|
+
if (children.includes(component)) return node as RenderableContainer;
|
|
61
|
+
for (const child of children) {
|
|
62
|
+
const found = walk(child, depth + 1);
|
|
63
|
+
if (found) return found;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return walk(root, 0);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 解除 `container` 上现有的接管(可能是别的扩展实例遗留的)。返回是否解掉了什么。 */
|
|
76
|
+
export function releaseHeaderGuard(container: unknown): boolean {
|
|
77
|
+
try {
|
|
78
|
+
const record = (container as { [HEADER_GUARD_KEY]?: GuardRecord } | undefined)?.[HEADER_GUARD_KEY];
|
|
79
|
+
if (!record || typeof record.release !== "function") return false;
|
|
80
|
+
record.release();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 接管 header 容器的渲染,返回解除函数(幂等)。
|
|
89
|
+
*
|
|
90
|
+
* - `ownComponent` 还在容器里 → 原样透传(并在超时后顺手交还,自愈);
|
|
91
|
+
* - 容器被 pi 换成内置 header → 返回 `frozenRender(width)` 的行;
|
|
92
|
+
* - 超过 `maxAgeMs` 仍等不到新 header → 交还,回到 pi 默认行为。
|
|
93
|
+
*/
|
|
94
|
+
export function freezeHeaderContainer(options: {
|
|
95
|
+
container: unknown;
|
|
96
|
+
ownComponent: unknown;
|
|
97
|
+
frozenRender: (width: number) => string[];
|
|
98
|
+
maxAgeMs?: number;
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}): (() => void) | undefined {
|
|
101
|
+
const container = options.container as RenderableContainer | undefined;
|
|
102
|
+
if (
|
|
103
|
+
!container ||
|
|
104
|
+
typeof container.render !== "function" ||
|
|
105
|
+
!Array.isArray(container.children) ||
|
|
106
|
+
options.ownComponent === undefined
|
|
107
|
+
) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// 先解除已有接管(自己重入 / 上一个扩展实例遗留),避免叠两层 wrapper。
|
|
111
|
+
releaseHeaderGuard(container);
|
|
112
|
+
|
|
113
|
+
const ownComponent = options.ownComponent;
|
|
114
|
+
const maxAgeMs = options.maxAgeMs ?? HEADER_GUARD_MAX_AGE_MS;
|
|
115
|
+
const now = options.now ?? Date.now;
|
|
116
|
+
const armedAt = now();
|
|
117
|
+
const hadOwnRender = Object.prototype.hasOwnProperty.call(container, "render");
|
|
118
|
+
const originalRender = container.render;
|
|
119
|
+
const state: { released: boolean; record?: GuardRecord } = { released: false };
|
|
120
|
+
|
|
121
|
+
const release = (): void => {
|
|
122
|
+
if (state.released) return;
|
|
123
|
+
state.released = true;
|
|
124
|
+
try {
|
|
125
|
+
const holder = container as { [HEADER_GUARD_KEY]?: GuardRecord };
|
|
126
|
+
if (state.record && holder[HEADER_GUARD_KEY] === state.record) delete holder[HEADER_GUARD_KEY];
|
|
127
|
+
if (hadOwnRender) container.render = originalRender;
|
|
128
|
+
else delete (container as { render?: unknown }).render;
|
|
129
|
+
} catch {
|
|
130
|
+
// 容器只读 / 已被替换:wrapper 自己会因 released 标志透传,不再冻。
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const wrapper = (width: number): string[] => {
|
|
135
|
+
if (state.released) return originalRender.call(container, width);
|
|
136
|
+
const expired = now() - armedAt > maxAgeMs;
|
|
137
|
+
let mounted = false;
|
|
138
|
+
try {
|
|
139
|
+
mounted = Array.isArray(container.children) && container.children.includes(ownComponent);
|
|
140
|
+
} catch {
|
|
141
|
+
mounted = true; // 读 children 都抛:别硬撑,交还给原渲染。
|
|
142
|
+
}
|
|
143
|
+
if (mounted) {
|
|
144
|
+
if (expired) release();
|
|
145
|
+
return originalRender.call(container, width);
|
|
146
|
+
}
|
|
147
|
+
if (expired) {
|
|
148
|
+
release();
|
|
149
|
+
return originalRender.call(container, width);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return options.frozenRender(width);
|
|
153
|
+
} catch {
|
|
154
|
+
return originalRender.call(container, width);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
container.render = wrapper as RenderableContainer["render"];
|
|
160
|
+
state.record = { release };
|
|
161
|
+
(container as { [HEADER_GUARD_KEY]?: GuardRecord })[HEADER_GUARD_KEY] = state.record;
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
return release;
|
|
166
|
+
}
|