@aiti-ai/aiti-folder 0.0.0-stage → 0.1.8
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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/check-client.mjs +273 -0
- package/client/src/comp-i18n.ts +144 -0
- package/client/src/directory-browser.tsx +58 -0
- package/client/src/entry.tsx +82 -0
- package/client/src/folder-actions.ts +13 -0
- package/client/src/folder-browser.tsx +1568 -0
- package/client/src/folder-dock.test.mjs +241 -0
- package/client/src/folder-dock.tsx +646 -0
- package/client/src/folder-switcher.tsx +212 -0
- package/client/src/git-plan.tsx +282 -0
- package/client/src/git-toolbar.tsx +382 -0
- package/client/src/host-shim.ts +126 -0
- package/client/src/icons.tsx +135 -0
- package/client/src/jsx-runtime.ts +2 -0
- package/client/src/lib.ts +148 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-folder-client.mjs +4 -0
- package/dist/entry-e6L3SQB9.js +2411 -0
- package/dist/folder-browser-dlVFbFla.js +3830 -0
- package/icon.svg +1 -0
- package/index.ts +827 -0
- package/package.json +28 -4
- package/reload-instances.mjs +60 -0
- package/server/git-plan.test.mjs +62 -0
- package/server/git-plan.ts +196 -0
- package/tools/build-client.mjs +27 -0
- package/tools/deploy.mjs +100 -0
- package/vite.config.ts +43 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# aiti-folder 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-folder/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-folder`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 红线
|
|
25
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
26
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
27
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
28
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aiti-ai/aiti-folder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> fileViewer folder 接管 + pathPicker 目录选择器 —— 浏览/回收站删除 + git 工具条与分组提交计划(软依赖 aiti-git-ops)
|
|
4
|
+
|
|
5
|
+
## 来源 · Origin
|
|
6
|
+
|
|
7
|
+
本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
|
|
8
|
+
|
|
9
|
+
This package is part of the **aiti** ecosystem — a personal AI development workbench powered by [pi](https://pi.dev): **aiti-agent** (a self-hosted web workbench built on the open-source [pi-web](https://github.com/aiti-ai/pi-web)) plus debug injection into mainstream dev tools (aiti-devtools) and a rich extension system.
|
|
10
|
+
|
|
11
|
+
- 工作台 / workbench: <https://github.com/aiti-ai/pi-web>
|
|
12
|
+
- 生态仓库 / monorepo: <https://github.com/aiti-ai/aiti-ai>
|
|
13
|
+
- 官网 / site: <https://aiti.pw>
|
|
14
|
+
|
|
15
|
+
## 功能定位(中文)
|
|
16
|
+
|
|
17
|
+
fileViewer folder 接管 + pathPicker 目录选择器 —— 浏览/回收站删除 + git 工具条与分组提交计划(软依赖 aiti-git-ops)
|
|
18
|
+
|
|
19
|
+
开发工具 · tags: `folder` · `file-viewer` · `path-picker` · `trash` · `git`
|
|
20
|
+
|
|
21
|
+
## Positioning (English)
|
|
22
|
+
|
|
23
|
+
fileViewer folder take-over + pathPicker directory picker — browse / delete to trash + git toolbar and grouped commit plans (soft-depends on aiti-git-ops)
|
|
24
|
+
|
|
25
|
+
## 安装 / 部署
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 部署到 ~/.pi/web/extensions/aiti-folder/ 后 reload
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 文档 · Documentation
|
|
32
|
+
|
|
33
|
+
- 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-folder.html>
|
|
34
|
+
- English docs: <https://aiti.pw/en/extensions/aiti-folder.html>
|
|
35
|
+
- 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
package/check-client.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
|
|
5
|
+
* `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
|
|
6
|
+
* node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
|
|
7
|
+
* 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
|
|
8
|
+
*
|
|
9
|
+
* 用法:
|
|
10
|
+
* CLI node check-client.mjs <file|dir>... FAIL → exit 1
|
|
11
|
+
* 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
|
|
12
|
+
* API lintClientSource(src) → violations[] { line, level, rule, text }
|
|
13
|
+
* lintPath(path) → { files, violations }(文件或目录,递归)
|
|
14
|
+
* checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
|
|
15
|
+
*
|
|
16
|
+
* 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
|
|
17
|
+
* aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
|
|
18
|
+
* 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
|
|
19
|
+
*
|
|
20
|
+
* 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
|
|
21
|
+
* 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
// ── 规则(分级)──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const RULES = [
|
|
29
|
+
// FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
|
|
30
|
+
{ level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
|
|
31
|
+
// 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
|
|
32
|
+
// 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
|
|
33
|
+
// 字符串字面量则会被词法遮蔽。
|
|
34
|
+
{ level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
|
|
35
|
+
{ level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
|
|
36
|
+
{ level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
|
|
37
|
+
{ level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
|
|
38
|
+
// warn:疑似残留,人工确认,不阻断
|
|
39
|
+
{ level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
|
|
40
|
+
{ level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
|
|
41
|
+
{ level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
|
|
45
|
+
export function stripLiterals(src) {
|
|
46
|
+
const out = src.split("");
|
|
47
|
+
const n = src.length;
|
|
48
|
+
const mask = (from, to) => {
|
|
49
|
+
for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
|
|
50
|
+
};
|
|
51
|
+
let mode = "code";
|
|
52
|
+
let depth = 0;
|
|
53
|
+
const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < n) {
|
|
56
|
+
const c = src[i];
|
|
57
|
+
const d = i + 1 < n ? src[i + 1] : "";
|
|
58
|
+
if (mode === "code") {
|
|
59
|
+
if (c === "/" && d === "/") {
|
|
60
|
+
const j = src.indexOf("\n", i);
|
|
61
|
+
const end = j === -1 ? n : j;
|
|
62
|
+
mask(i, end);
|
|
63
|
+
i = end;
|
|
64
|
+
} else if (c === "/" && d === "*") {
|
|
65
|
+
const j = src.indexOf("*/", i + 2);
|
|
66
|
+
const end = j === -1 ? n : j + 2;
|
|
67
|
+
mask(i, end);
|
|
68
|
+
i = end;
|
|
69
|
+
} else if (c === "'" || c === '"') {
|
|
70
|
+
let j = i + 1;
|
|
71
|
+
while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
|
|
72
|
+
if (j < n && src[j] === c) {
|
|
73
|
+
mask(i, j + 1);
|
|
74
|
+
i = j + 1;
|
|
75
|
+
} else {
|
|
76
|
+
// 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
|
|
77
|
+
out[i] = " ";
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
} else if (c === "`") {
|
|
81
|
+
mode = "tpl";
|
|
82
|
+
out[i] = " ";
|
|
83
|
+
i++;
|
|
84
|
+
} else if (c === "{") {
|
|
85
|
+
depth++;
|
|
86
|
+
i++;
|
|
87
|
+
} else if (c === "}") {
|
|
88
|
+
if (stack.length && depth === stack[stack.length - 1]) {
|
|
89
|
+
stack.pop();
|
|
90
|
+
mode = "tpl"; // 插值结束回模板
|
|
91
|
+
out[i] = " ";
|
|
92
|
+
} else {
|
|
93
|
+
depth = Math.max(0, depth - 1);
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
} else i++;
|
|
97
|
+
} else if (mode === "tpl") {
|
|
98
|
+
if (c === "\\") {
|
|
99
|
+
mask(i, Math.min(i + 2, n));
|
|
100
|
+
i += 2;
|
|
101
|
+
} else if (c === "`") {
|
|
102
|
+
mode = "code";
|
|
103
|
+
out[i] = " ";
|
|
104
|
+
i++;
|
|
105
|
+
} else if (c === "$" && d === "{") {
|
|
106
|
+
stack.push(depth);
|
|
107
|
+
mode = "code";
|
|
108
|
+
mask(i, i + 2);
|
|
109
|
+
i += 2;
|
|
110
|
+
} else {
|
|
111
|
+
if (c !== "\n") out[i] = " ";
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// 不可达:line/block/字符串均在 code 分支内联处理
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out.join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 单源检查 ────────────────────────────────────────────────────────────────
|
|
123
|
+
export function lintClientSource(src) {
|
|
124
|
+
const masked = stripLiterals(src);
|
|
125
|
+
const origLines = src.split("\n");
|
|
126
|
+
const maskedLines = masked.split("\n");
|
|
127
|
+
const violations = [];
|
|
128
|
+
for (let i = 0; i < maskedLines.length; i++) {
|
|
129
|
+
const prev = i > 0 ? origLines[i - 1] : "";
|
|
130
|
+
if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
|
|
131
|
+
for (const { level, re, why } of RULES) {
|
|
132
|
+
if (re.test(maskedLines[i])) {
|
|
133
|
+
violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 文件/目录检查 ───────────────────────────────────────────────────────────
|
|
141
|
+
const SKIP_DIR = new Set(["node_modules", ".git"]);
|
|
142
|
+
/** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
|
|
143
|
+
const MAX_BYTES = 512 * 1024;
|
|
144
|
+
const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
|
|
145
|
+
|
|
146
|
+
export function lintPath(p, out = { files: 0, violations: [] }) {
|
|
147
|
+
const st = statSync(p);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
for (const name of readdirSync(p)) {
|
|
150
|
+
if (SKIP_DIR.has(name)) continue;
|
|
151
|
+
lintPath(join(p, name), out);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
const base = p.split("/").pop();
|
|
156
|
+
if (!isClientFile(base)) return out;
|
|
157
|
+
if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
|
|
158
|
+
out.files++;
|
|
159
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatViolations(violations) {
|
|
164
|
+
return violations
|
|
165
|
+
.map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
|
|
166
|
+
.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
|
|
170
|
+
export function checkClientDir(dir, { log = console.log } = {}) {
|
|
171
|
+
const { files, violations } = lintPath(resolve(dir));
|
|
172
|
+
const fails = violations.filter((v) => v.level === "FAIL");
|
|
173
|
+
const warns = violations.filter((v) => v.level === "warn");
|
|
174
|
+
if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
|
|
175
|
+
if (fails.length) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
|
|
178
|
+
formatViolations(fails) +
|
|
179
|
+
`\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
|
|
183
|
+
return { files, fails: fails.length, warns: warns.length };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
|
|
188
|
+
* 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
|
|
189
|
+
* 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
|
|
190
|
+
*/
|
|
191
|
+
export function lintExtensionDir(dir) {
|
|
192
|
+
const out = { files: 0, violations: [] };
|
|
193
|
+
const walk = (p) => {
|
|
194
|
+
const st = statSync(p);
|
|
195
|
+
if (st.isDirectory()) {
|
|
196
|
+
for (const name of readdirSync(p)) {
|
|
197
|
+
if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
|
|
198
|
+
walk(join(p, name));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const base = p.split("/").pop();
|
|
203
|
+
if (!isClientFile(base) || st.size > MAX_BYTES) return;
|
|
204
|
+
out.files++;
|
|
205
|
+
for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
|
|
206
|
+
};
|
|
207
|
+
walk(resolve(dir));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
|
|
211
|
+
|
|
212
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
213
|
+
function selftest() {
|
|
214
|
+
const MUST_FAIL = [
|
|
215
|
+
"const [st, setSt] = useState<any>(null);",
|
|
216
|
+
"const r = useRef<number>(null);",
|
|
217
|
+
"const x = v as any;",
|
|
218
|
+
"function f(a: unknown) {}",
|
|
219
|
+
'import type { Foo } from "./x";',
|
|
220
|
+
"interface Foo { a: string }",
|
|
221
|
+
"enum Color { Red }",
|
|
222
|
+
"const y = v as const;",
|
|
223
|
+
];
|
|
224
|
+
const MUST_WARN = [
|
|
225
|
+
"const opts = { mode: string };",
|
|
226
|
+
"x satisfies Y;",
|
|
227
|
+
"const m: Record<string, any> = {};",
|
|
228
|
+
];
|
|
229
|
+
const MUST_PASS = [
|
|
230
|
+
'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
|
|
231
|
+
"/** @return {{chars1: string, chars2: string}} */",
|
|
232
|
+
"const [s] = useState(null);",
|
|
233
|
+
"useState<any>(null); // check-client:allow",
|
|
234
|
+
"// check-client:allow\nuseState<never>(0);",
|
|
235
|
+
"const cmp = a < b && c > (d);",
|
|
236
|
+
"if (a // 注释里 as any 不算\n ) run();",
|
|
237
|
+
];
|
|
238
|
+
let bad = 0;
|
|
239
|
+
const check = (src, expect, desc) => {
|
|
240
|
+
const v = lintClientSource(src);
|
|
241
|
+
const fails = v.filter((x) => x.level === "FAIL");
|
|
242
|
+
const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
|
|
243
|
+
if (!ok) {
|
|
244
|
+
bad++;
|
|
245
|
+
console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
|
|
249
|
+
MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
|
|
250
|
+
MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
|
|
251
|
+
if (bad) {
|
|
252
|
+
console.log(`selftest: ${bad} 处未过`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
|
259
|
+
if (process.argv.includes("--selftest")) {
|
|
260
|
+
selftest();
|
|
261
|
+
} else if (process.argv.length > 2) {
|
|
262
|
+
const all = { files: 0, violations: [] };
|
|
263
|
+
for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
|
|
264
|
+
const fails = all.violations.filter((v) => v.level === "FAIL");
|
|
265
|
+
const warns = all.violations.filter((v) => v.level === "warn");
|
|
266
|
+
if (all.violations.length) console.log(formatViolations(all.violations));
|
|
267
|
+
console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
|
|
268
|
+
process.exit(fails.length ? 1 : 0);
|
|
269
|
+
} else {
|
|
270
|
+
console.log("用法:node check-client.mjs <file|dir>... | --selftest");
|
|
271
|
+
process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* comp-i18n · 跨扩展复用组件自带多语。
|
|
3
|
+
*
|
|
4
|
+
* 背景:aiti-folder 的可复用组件(WorkspaceDropdown / DirectoryBrowser / FolderSwitcher)
|
|
5
|
+
* 会被其他扩展构建期内联(session-manager、aiti-git-review 等)。运行时宿主 t() 委托的是
|
|
6
|
+
* 调用方扩展的 i18n 字典——那里没有本扩展的 key,界面会原样显示 key 字符串。
|
|
7
|
+
*
|
|
8
|
+
* 解法:可复用组件用到的文案内置在 COMPONENT_DICT,ct() 按当前语言直查;
|
|
9
|
+
* 未收录的 key 回落宿主 t()(不破坏既有行为)。新增可复用组件文案一律加到这里,
|
|
10
|
+
* 组件内不要再用宿主 t() 直查本扩展页面专属词条。
|
|
11
|
+
*/
|
|
12
|
+
import { t } from "./host-shim";
|
|
13
|
+
|
|
14
|
+
const COMPONENT_DICT: Record<string, Record<string, string>> = {
|
|
15
|
+
en: {
|
|
16
|
+
"files.cancel": "Cancel",
|
|
17
|
+
"files.loading": "Loading files...",
|
|
18
|
+
"files.noSubdirs": "No subdirectories",
|
|
19
|
+
"files.parentDir": "Parent directory",
|
|
20
|
+
"files.selectFolder": "Select this folder",
|
|
21
|
+
"i18n.close": "Close",
|
|
22
|
+
"sidebar.customPath": "Custom path…",
|
|
23
|
+
"files.openFolder": "Open Folder",
|
|
24
|
+
"sidebar.filterProjects": "Filter projects…",
|
|
25
|
+
"sidebar.noMatchingProjects": "No matching projects",
|
|
26
|
+
"sidebar.useDefaultDirectory": "Use default directory",
|
|
27
|
+
|
|
28
|
+
"git.branchPanelTitle": "Branch panel (branch / tag / stash)",
|
|
29
|
+
"git.pushTitle": "Push commits",
|
|
30
|
+
"git.pullTitle": "Pull (ff-only)",
|
|
31
|
+
"git.pushConfirm": "Push {count} commit(s) to upstream?",
|
|
32
|
+
"git.pullConfirm": "Pull {count} commit(s) from upstream (ff-only)?",
|
|
33
|
+
"git.commitTitle": "Commit",
|
|
34
|
+
"git.commitShort": "Commit",
|
|
35
|
+
"git.stageAll": "Select all",
|
|
36
|
+
"git.stageNone": "Staged only",
|
|
37
|
+
"git.stagedCount": "{count} selected",
|
|
38
|
+
"git.commitPlaceholder": "Commit message…",
|
|
39
|
+
"git.amend": "Amend last commit",
|
|
40
|
+
"git.commitBtn": "Commit",
|
|
41
|
+
"git.planShort": "Group",
|
|
42
|
+
"git.planTitle": "Group & commit plan (staged group-by-group, breakpoint-resumable)",
|
|
43
|
+
"git.planMoveTo": "Move to next group",
|
|
44
|
+
"git.planExclude": "Exclude from plan",
|
|
45
|
+
"git.planRestore": "Restore to first group",
|
|
46
|
+
"git.planAddGroup": "+ New group (takes last file of largest group)",
|
|
47
|
+
"git.planExcluded": "Excluded (won't be committed)",
|
|
48
|
+
"git.planStuck": "Stuck at group {group} — earlier groups already committed",
|
|
49
|
+
"git.planRolledBack": "rolled back to base HEAD",
|
|
50
|
+
"git.planRollback": "Roll back plan (reset --mixed)",
|
|
51
|
+
"git.planExecute": "Commit group by group",
|
|
52
|
+
"git.planConfirm": "Commit {count} group(s), one commit each?",
|
|
53
|
+
"git.planNewGroupMessage": "chore: new group",
|
|
54
|
+
"git.aiRefine": "AI refine",
|
|
55
|
+
"git.aiRefineBusy": "AI grouping… (up to 3 min)",
|
|
56
|
+
"git.aiMessage": "Generate",
|
|
57
|
+
"git.aiMessageBusy": "Generating… (up to 3 min)",
|
|
58
|
+
"git.aiMessageOverwrite": "Message already has content. Overwrite with the generated commit message?",
|
|
59
|
+
"git.aiMessageTitle": "Generate a commit message with AI (reads AGENTS.md and the diff of selected files)",
|
|
60
|
+
"git.stagedBucket": "Staged",
|
|
61
|
+
"git.unstagedBucket": "Changes",
|
|
62
|
+
"git.stageTitle": "Stage",
|
|
63
|
+
"git.unstageTitle": "Unstage",
|
|
64
|
+
"git.discardTitle": "Discard changes",
|
|
65
|
+
"git.discardConfirm": "Discard ALL uncommitted changes of {path}? (untracked files go to quarantine, recoverable by hand)",
|
|
66
|
+
"git.diffTitle": "View diff (git-review)",
|
|
67
|
+
"git.changesToggle": "Toggle changed-files section",
|
|
68
|
+
"git.planRetry": "Retry remaining groups",
|
|
69
|
+
},
|
|
70
|
+
"zh-CN": {
|
|
71
|
+
"files.cancel": "取消",
|
|
72
|
+
"files.loading": "正在加载文件...",
|
|
73
|
+
"files.noSubdirs": "无子目录",
|
|
74
|
+
"files.parentDir": "上级目录",
|
|
75
|
+
"files.selectFolder": "选择此文件夹",
|
|
76
|
+
"i18n.close": "关闭",
|
|
77
|
+
"sidebar.customPath": "自定义路径…",
|
|
78
|
+
"files.openFolder": "打开文件夹",
|
|
79
|
+
"sidebar.filterProjects": "筛选项目…",
|
|
80
|
+
"sidebar.noMatchingProjects": "没有匹配的项目",
|
|
81
|
+
"sidebar.useDefaultDirectory": "使用默认目录",
|
|
82
|
+
|
|
83
|
+
"git.branchPanelTitle": "分支面板(分支 / tag / stash)",
|
|
84
|
+
"git.pushTitle": "推送提交",
|
|
85
|
+
"git.pullTitle": "拉取(ff-only)",
|
|
86
|
+
"git.pushConfirm": "推送 {count} 个提交到上游?",
|
|
87
|
+
"git.pullConfirm": "从上游拉取 {count} 个提交(ff-only)?",
|
|
88
|
+
"git.commitTitle": "提交",
|
|
89
|
+
"git.commitShort": "提交",
|
|
90
|
+
"git.stageAll": "全选",
|
|
91
|
+
"git.stageNone": "仅暂存区",
|
|
92
|
+
"git.stagedCount": "已选 {count} 个",
|
|
93
|
+
"git.commitPlaceholder": "提交消息…",
|
|
94
|
+
"git.amend": "修正上一笔(amend)",
|
|
95
|
+
"git.commitBtn": "提交",
|
|
96
|
+
"git.planShort": "分组",
|
|
97
|
+
"git.planTitle": "分组提交计划(逐组提交 · 失败断点可回滚)",
|
|
98
|
+
"git.planMoveTo": "移到下一组",
|
|
99
|
+
"git.planExclude": "剔除出计划",
|
|
100
|
+
"git.planRestore": "恢复到第一组",
|
|
101
|
+
"git.planAddGroup": "+ 新建组(取最大组的最后一个文件)",
|
|
102
|
+
"git.planExcluded": "已剔除(不会提交)",
|
|
103
|
+
"git.planStuck": "卡在第 {group} 组——之前的组已提交",
|
|
104
|
+
"git.planRolledBack": "已回滚到起点 HEAD",
|
|
105
|
+
"git.planRollback": "回滚本计划(reset --mixed)",
|
|
106
|
+
"git.planExecute": "逐组提交",
|
|
107
|
+
"git.planConfirm": "共 {count} 组,每组一笔提交?",
|
|
108
|
+
"git.planNewGroupMessage": "chore: new group",
|
|
109
|
+
"git.aiRefine": "AI 精修",
|
|
110
|
+
"git.aiRefineBusy": "AI 分组中…(最长 3 分钟)",
|
|
111
|
+
"git.aiMessage": "生成",
|
|
112
|
+
"git.aiMessageBusy": "生成中…(最长 3 分钟)",
|
|
113
|
+
"git.aiMessageOverwrite": "输入框已有内容,生成结果将覆盖,是否继续?",
|
|
114
|
+
"git.aiMessageTitle": "AI 生成提交信息(读取 AGENTS.md 与所选文件的 diff)",
|
|
115
|
+
"git.stagedBucket": "已暂存",
|
|
116
|
+
"git.unstagedBucket": "工作区",
|
|
117
|
+
"git.stageTitle": "暂存",
|
|
118
|
+
"git.unstageTitle": "取消暂存",
|
|
119
|
+
"git.discardTitle": "丢弃变更",
|
|
120
|
+
"git.discardConfirm": "丢弃 {path} 的全部未提交改动?(未跟踪文件移入隔离区,可手工找回)",
|
|
121
|
+
"git.diffTitle": "查看 diff(git-review)",
|
|
122
|
+
"git.changesToggle": "开/合变更文件区",
|
|
123
|
+
"git.planRetry": "重试剩余组",
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** 当前语言(pi-web 注入 __PI_LOCALE__;兜底 <html lang>;zh 系归一为 zh-CN) */
|
|
128
|
+
function currentLocale(): string {
|
|
129
|
+
const raw = (window as any).__PI_LOCALE__ || document.documentElement.lang || "en";
|
|
130
|
+
const l = String(raw);
|
|
131
|
+
if (l.startsWith("zh")) return "zh-CN";
|
|
132
|
+
return l;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 组件多语:内置字典优先;未收录回落宿主 t() */
|
|
136
|
+
export function ct(key: string, params?: Record<string, unknown>): string {
|
|
137
|
+
const dict = COMPONENT_DICT[currentLocale()];
|
|
138
|
+
let hit: string | undefined = dict && dict[key];
|
|
139
|
+
if (hit === undefined) hit = t(key);
|
|
140
|
+
if (params && typeof hit === "string") {
|
|
141
|
+
for (const [k, v] of Object.entries(params)) hit = hit.split("{" + k + "}").join(String(v));
|
|
142
|
+
}
|
|
143
|
+
return hit;
|
|
144
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-folder · 目录浏览器弹窗(自定义路径,/api/cwd/browse)——自手写版迁移,一比一
|
|
3
|
+
*/
|
|
4
|
+
import { useState, useEffect, useCallback } from "react";
|
|
5
|
+
import { apiUrl } from "./host-shim";
|
|
6
|
+
import { ct as t } from "./comp-i18n"; // 组件自带多语:内置字典优先,回落宿主 t
|
|
7
|
+
import { FolderIcon } from "./icons";
|
|
8
|
+
|
|
9
|
+
export const rowStyle: Record<string, any> = { display:"flex", alignItems:"center", gap:8, width:"100%", padding:"0 12px", height:26, border:"none", background:"transparent", color:"var(--text)", cursor:"pointer", textAlign:"left" };
|
|
10
|
+
|
|
11
|
+
export function DirectoryBrowser({ initialPath, onSelect, onCancel }: { initialPath?: string; onSelect: (p: string) => void; onCancel: () => void }) {
|
|
12
|
+
const [path, setPath] = useState(initialPath || "");
|
|
13
|
+
const [data, setData] = useState<any>(null);
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
const load = useCallback((target?: string) => {
|
|
17
|
+
if (loading) return; setLoading(true); setError(null);
|
|
18
|
+
const p = target ? { path: target } : {};
|
|
19
|
+
fetch(apiUrl("/api/cwd/browse"), { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify(p) })
|
|
20
|
+
.then(async r => { const d = await r.json(); if (!r.ok || d.error) throw new Error(d.error||("HTTP "+r.status)); setData(d); setPath(d.path); })
|
|
21
|
+
.catch(e => setError(e&&e.message?e.message:String(e)))
|
|
22
|
+
.finally(()=>setLoading(false));
|
|
23
|
+
}, [loading]);
|
|
24
|
+
useEffect(()=>{ load(initialPath||""); /* eslint-disable-line */ }, []);
|
|
25
|
+
useEffect(()=>{ const k=(e:any)=>{ if(e.key==="Escape") onCancel(); }; document.addEventListener("keydown",k,true); return ()=>document.removeEventListener("keydown",k,true); }, [onCancel]);
|
|
26
|
+
const dirs = (data && data.directories) || []; const drives = data && data.drives;
|
|
27
|
+
return (
|
|
28
|
+
<div onClick={(e)=>{ if(e.target===e.currentTarget) onCancel(); }} style={{ position:"fixed", inset:0, zIndex:400, background:"rgba(0,0,0,0.35)", display:"flex", alignItems:"center", justifyContent:"center" }}>
|
|
29
|
+
<div style={{ width:"min(560px,92vw)", maxHeight:"80vh", display:"flex", flexDirection:"column", background:"var(--bg-panel)", border:"1px solid var(--border)", borderRadius:8, boxShadow:"0 8px 32px rgba(0,0,0,0.3)", overflow:"hidden" }}>
|
|
30
|
+
<div style={{ display:"flex", alignItems:"center", gap:8, padding:"8px 12px", borderBottom:"1px solid var(--border)", background:"var(--bg)", flexShrink:0 }}>
|
|
31
|
+
<span style={{ fontSize:13, fontWeight:600, color:"var(--text)", flex:1 }}>{t("sidebar.customPath")}</span>
|
|
32
|
+
<button onClick={onCancel} title={t("i18n.close")} style={{ height:28, padding:"0 10px", border:"1px solid var(--border)", borderRadius:5, background:"var(--bg-panel)", color:"var(--text)", cursor:"pointer", fontSize:12 }}>✕</button>
|
|
33
|
+
</div>
|
|
34
|
+
<div style={{ display:"flex", alignItems:"center", gap:6, padding:"6px 12px", borderBottom:"1px solid var(--border)", flexShrink:0, fontSize:12, color:"var(--text-muted)", fontFamily:"var(--font-mono)", overflowX:"auto", whiteSpace:"nowrap" }}>
|
|
35
|
+
{(data && data.parentPath!=null) ? <button onClick={()=>load(data.parentPath)} title={t("files.parentDir")} style={{ height:22, padding:"0 6px", border:"1px solid var(--border)", borderRadius:4, background:"var(--bg-panel)", color:"var(--text-muted)", cursor:"pointer", fontSize:12, flexShrink:0 }}>↑</button> : null}
|
|
36
|
+
<span title={path} style={{ overflow:"hidden", textOverflow:"ellipsis" }}>{path||"/"}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div style={{ flex:1, overflowY:"auto", padding:"4px 0" }}>
|
|
39
|
+
{loading ? <div style={{ padding:"12px", fontSize:12, color:"var(--text-dim)" }}>{t("files.loading")}</div> : null}
|
|
40
|
+
{error ? <div style={{ padding:"12px", fontSize:12, color:"var(--danger, #f87171)" }}>{error}</div> : null}
|
|
41
|
+
{(!loading && !error && drives && drives.length>0) ? drives.map((d: any)=> <button key={d.path} onClick={()=>load(d.path)} style={rowStyle}><FolderIcon open={false} /><span style={{fontSize:12}}>{d.name}</span></button>) : null}
|
|
42
|
+
{(!loading && !error && dirs.length===0 && (!drives||drives.length===0)) ? <div style={{ padding:"12px", fontSize:12, color:"var(--text-dim)" }}>{t("files.noSubdirs")}</div> : null}
|
|
43
|
+
{(!loading && !error) ? dirs.map((d: any)=> (
|
|
44
|
+
<button key={d.path} onClick={()=>load(d.path)} onDoubleClick={()=>onSelect(d.path)} style={rowStyle}>
|
|
45
|
+
<FolderIcon open={false} />
|
|
46
|
+
<span style={{ flex:1, fontSize:12, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{d.name}</span>
|
|
47
|
+
<span style={{fontSize:10,color:"var(--text-dim)"}}>→</span>
|
|
48
|
+
</button>
|
|
49
|
+
)) : null}
|
|
50
|
+
</div>
|
|
51
|
+
<div style={{ display:"flex", gap:8, padding:"8px 12px", borderTop:"1px solid var(--border)", flexShrink:0, background:"var(--bg)" }}>
|
|
52
|
+
<button onClick={()=>path&&onSelect(path)} disabled={!path} style={{ height:28, padding:"0 12px", border:"none", borderRadius:5, background:"var(--accent)", color:"#fff", cursor:path?"pointer":"not-allowed", fontSize:12, fontWeight:600, opacity:path?1:0.5 }}>{t("files.selectFolder")}</button>
|
|
53
|
+
<button onClick={onCancel} style={{ height:28, padding:"0 10px", border:"1px solid var(--border)", borderRadius:5, background:"var(--bg-panel)", color:"var(--text)", cursor:"pointer", fontSize:12 }}>{t("files.cancel")}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-folder · client 入口(JSX + TypeScript + vite 构建)。
|
|
3
|
+
*
|
|
4
|
+
* 注册两项(均复用核心端点):
|
|
5
|
+
* - fileViewer 项 aiti-folder:match kind==="folder" → FolderBrowser
|
|
6
|
+
* - pathPicker 项 aiti-folder-picker:FolderSwitcher(能力,不切 cwd)
|
|
7
|
+
*
|
|
8
|
+
* 开放点:window.__aitiFolder.registerAction(其他扩展注册目录级工具条按钮)
|
|
9
|
+
* [懒加载 v2 D3] 薄入口拆包:FolderBrowser(~113K)按需 import();本文件 + pathPicker
|
|
10
|
+
* + registerAction 开放点为薄入口(~8K),随启动物化即时可用。
|
|
11
|
+
*
|
|
12
|
+
* 构建:node tools/build-client.mjs (或 npm run build:client)
|
|
13
|
+
* 部署:node tools/deploy.mjs (自动先构建再拷贝)
|
|
14
|
+
*/
|
|
15
|
+
import { useState, useEffect } from "react";
|
|
16
|
+
import { __setCtx } from "./host-shim";
|
|
17
|
+
import { registerAction } from "./folder-actions";
|
|
18
|
+
import { FolderSwitcher } from "./folder-switcher";
|
|
19
|
+
import { syncFolderDockFaces, INSTANCES_EVENT, openFolderDocked } from "./folder-dock";
|
|
20
|
+
|
|
21
|
+
// [懒加载 v2 D3 拆包] FolderBrowser(目录树/预览,~113K 大头)按需 import():
|
|
22
|
+
// 薄入口(本文件 + pathPicker + registerAction 开放点)随启动物化即时可用;
|
|
23
|
+
// 首次真正渲染目录 tab 时才拉重 chunk(加载期返回 null——接管判定已由 match 先行命中)。
|
|
24
|
+
let FolderBrowserComp: ((props: any) => unknown) | null = null;
|
|
25
|
+
function FolderBrowserLazy(props: Record<string, unknown>) {
|
|
26
|
+
const [, force] = useState(0);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// 每个实例独立等 import 完成后各自唤醒(同 URL 模块加载浏览器天然去重;
|
|
29
|
+
// 不设全局 loading 标志——否则并发挂载的第二个实例永远等不到重渲染)
|
|
30
|
+
let alive = true;
|
|
31
|
+
import("./folder-browser").then((m) => {
|
|
32
|
+
if (!alive) return;
|
|
33
|
+
FolderBrowserComp = m.FolderBrowser;
|
|
34
|
+
force((n) => n + 1);
|
|
35
|
+
});
|
|
36
|
+
return () => { alive = false; };
|
|
37
|
+
}, []);
|
|
38
|
+
// 必须以 JSX 组件渲染(<Comp {...props}/>),严禁普通函数调用 Comp(props):
|
|
39
|
+
// 函数调用会把 FolderBrowser 的 ~50 个 hooks 并进本薄壳的 hook 链——首渲染只挂 1 个
|
|
40
|
+
// hook(返回 null 等加载),加载完成后 force 重渲染变成 1+N 个,React 更新路径发现
|
|
41
|
+
// hook 列表越界 → #310「Rendered fewer hooks than expected」崩溃(2026-09-08 修复)。
|
|
42
|
+
const Comp = FolderBrowserComp;
|
|
43
|
+
return Comp ? <Comp {...(props as any)} /> : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 开放点暴露(其他扩展注册目录级工具条按钮;aiti-git-review 的「审此目录」用它)
|
|
47
|
+
// openFolderDocked:其他扩展可显式把目录以 dock face 落右栏(带全套 ⇱ 停靠菜单)
|
|
48
|
+
try { (window as any).__aitiFolder = { registerAction, openFolderDocked }; } catch { /* noop */ }
|
|
49
|
+
// 就绪广播(开页方案 §1 F5):aiti-git-review「审此目录」动作晚注册用(同 aiti-editor:ready 注)
|
|
50
|
+
try { window.dispatchEvent(new CustomEvent("aiti-folder:ready")); } catch { /* noop */ }
|
|
51
|
+
|
|
52
|
+
export default function register(ctx: Record<string, unknown>) {
|
|
53
|
+
// 重要:必须在任何 JSX 组件渲染前调用(host-shim 模块级持 ctx)
|
|
54
|
+
__setCtx(ctx);
|
|
55
|
+
|
|
56
|
+
ctx.registerItems?.("fileViewer", [{
|
|
57
|
+
id: "aiti-folder",
|
|
58
|
+
render: (props: Record<string, unknown>) => <FolderBrowserLazy {...props} />,
|
|
59
|
+
meta: { label: "文件夹", title: "aiti-folder: folder browser", match: (_fp: unknown, opts: any) => opts && opts.kind === "folder" },
|
|
60
|
+
}]);
|
|
61
|
+
ctx.registerItems?.("pathPicker", [{
|
|
62
|
+
id: "aiti-folder-picker",
|
|
63
|
+
// 选中目录走本扩展 dock 状态机(openFolderDocked)而非宿主 onSelect(handleOpenFolder
|
|
64
|
+
// 开旧式文件夹 tab——不在 dock 状态机里,tab 条无 ⇱ 浮窗/停靠菜单)。拦截 onSelect
|
|
65
|
+
// 纯扩展侧闭环:宿主 onSelect 仅用于无本扩展时的兜底路径,此处覆盖后不再触达。
|
|
66
|
+
render: (props: Record<string, unknown>) => (
|
|
67
|
+
<FolderSwitcher {...(props as any)} onSelect={(p: string) => openFolderDocked(p, "right-panel")} />
|
|
68
|
+
),
|
|
69
|
+
meta: { label: "目录选择", title: "aiti-folder: directory switcher (no cwd change)" },
|
|
70
|
+
}]);
|
|
71
|
+
|
|
72
|
+
// 多实例 dock faces(v2):左栏栈/浮窗/右栏三面按清单动态注册;instances 变化
|
|
73
|
+
// (项目切换自动加 / + 选择器 / 钉住 / FIFO 挤出)→ 事件驱动重注册(零轮询)。
|
|
74
|
+
try {
|
|
75
|
+
const resync = () => { try { syncFolderDockFaces(ctx); } catch { /* noop */ } };
|
|
76
|
+
resync();
|
|
77
|
+
window.addEventListener(INSTANCES_EVENT, resync);
|
|
78
|
+
window.addEventListener("storage", resync);
|
|
79
|
+
} catch { /* noop */ }
|
|
80
|
+
|
|
81
|
+
try { console.log("[aiti-folder] client 已挂载(fileViewer 文件夹接管 + pathPicker 目录选择器 + registerAction 开放点 + dock faces 多实例)"); } catch { /* noop */ }
|
|
82
|
+
}
|