@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/index.ts
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-folder · 文件夹浏览器扩展(独立)
|
|
3
|
+
*
|
|
4
|
+
* 把文件夹查看/切换的业务逻辑从 pi-web 核心搬到本扩展。核心只保留薄委托:
|
|
5
|
+
* - fileViewer 槽:kind==="folder" 的 tab 由 FolderBrowser 接管(树 + git 徽标 + 开文件/子文件夹)
|
|
6
|
+
* - pathPicker 槽:FolderSwitcher 暴露为「目录选择器能力」,核心顶栏「打开文件夹」消费
|
|
7
|
+
*
|
|
8
|
+
* 全部复用核心现有端点(/api/files、/api/git/status、/api/sessions、/api/cwd/browse、
|
|
9
|
+
* /api/default-cwd、/api/home),UI 用 ctx.h 重画,不引核心组件。
|
|
10
|
+
*
|
|
11
|
+
* 与 aiti-editor 的关系:同一 fileViewer 槽里两项共存——aiti-editor match 排除 folder,
|
|
12
|
+
* 本扩展 match 仅命中 folder,互不冲突。文件夹 tab 的打开/关闭/重启恢复仍走核心
|
|
13
|
+
* handleOpenFolder + aiti-editor session-files(kind=folder),本扩展不碰会话记录。
|
|
14
|
+
*
|
|
15
|
+
* 文件夹操作(+ 菜单):新建文件/新建文件夹/上传文件夹走本扩展 server.api
|
|
16
|
+
* (核心 files API 无 create/mkdir/嵌套上传),上传文件复用核心 /api/files?type=upload。
|
|
17
|
+
* 删除(行悬停 🗑):POST /trash 移入系统回收站(trash npm 包:Linux XDG / macOS 原生 / Windows 回收站),可恢复。
|
|
18
|
+
* 写路径先过 isWritableTarget(realpath 最深已存在父链 + 词法 isAllowed)——防根内 symlink
|
|
19
|
+
* 逃逸到根外;目录自身校验用 allowedDir(isAllowed 同一安全面)。
|
|
20
|
+
*/
|
|
21
|
+
import { renameSync, rmSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { groupHeuristic, loadPlan, savePlan, newPlan, learn } from "./server/git-plan";
|
|
23
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import trash from "trash";
|
|
27
|
+
|
|
28
|
+
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
|
|
29
|
+
status,
|
|
30
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** 条目名合法性:非空、无路径分隔符/\0/..、长度 ≤200。 */
|
|
34
|
+
function validEntryName(name: string): boolean {
|
|
35
|
+
if (!name || name.length > 200) return false;
|
|
36
|
+
if (name === "." || name === "..") return false;
|
|
37
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** aiti-doc-center services.docs 出口(search=FTS 全文检索带 <mark> snippet;query=拉取面);未装/未启用/版本旧 → null(调用方降级)。
|
|
42
|
+
* 每请求现取(不缓存引用)——doc-center 后装/热重载后下一请求自愈(plan-first 同款惰性解析)。
|
|
43
|
+
* 双轨(DI 方案 P1):ctx.services 优先(startup 捕获),__piWebExt.byId 穿透兜底(生产旧 core 过渡期)。 */
|
|
44
|
+
let hostSvc: any = null; // startup 捕获 ctx.services
|
|
45
|
+
function docsService(): { search?: (q: string, limit?: number) => any[] } | null {
|
|
46
|
+
const viaSvc = hostSvc?.get?.("aiti-doc-center.docs");
|
|
47
|
+
if (viaSvc && typeof viaSvc.search === "function") return viaSvc;
|
|
48
|
+
const reg = (globalThis as any).__piWebExt;
|
|
49
|
+
const loaded = reg && typeof reg.byId === "function" ? reg.byId("aiti-doc-center") : null;
|
|
50
|
+
const s = loaded?.ext?.services?.docs;
|
|
51
|
+
return s && typeof s.search === "function" ? s : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 路径末段(文件/文件夹名);无害纯函数(此前 archive 路由直接引用未定义标识符,已补) */
|
|
55
|
+
function lastSeg(p: string): string {
|
|
56
|
+
const s = p.replace(/[\\/]+$/, "");
|
|
57
|
+
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
|
58
|
+
return i >= 0 ? s.slice(i + 1) : s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 相对路径合法性(上传树用):逐段校验,防目录逃逸。 */
|
|
62
|
+
function validRelPath(rel: string): boolean {
|
|
63
|
+
if (!rel || rel.length > 2000) return false;
|
|
64
|
+
if (rel.startsWith("/") || rel.startsWith("\\") || /^[a-zA-Z]:/.test(rel)) return false;
|
|
65
|
+
const segs = rel.split(/[\\/]+/).filter(Boolean);
|
|
66
|
+
if (segs.length === 0) return false;
|
|
67
|
+
return segs.every((s) => validEntryName(s));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 校验写目标目录(绝对路径 + allowed-roots)。返回规范化目录或 null。 */
|
|
71
|
+
async function allowedDir(dir: string, ctx: any): Promise<string | null> {
|
|
72
|
+
if (!dir || !isAbsolute(dir)) return null;
|
|
73
|
+
const norm = normalize(dir);
|
|
74
|
+
try { if (!(await ctx.core.fileAccess.isAllowed(norm))) return null; } catch { return null; }
|
|
75
|
+
return norm;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 归档下载票据(可重复使用,TTL 5 分钟)──
|
|
79
|
+
// 动机:GET /archive?path=/abs/dir 会把本地路径落进隧道/代理 access log。
|
|
80
|
+
// 改两步:POST /archive-ticket { path } 换随机票据 → GET /archive?ticket=<token> 流式下载。
|
|
81
|
+
// TTL 内可重复取(外部下载器/断点重试友好);到期即作废;不绑 cookie(下载器不带会话)。
|
|
82
|
+
const TICKET_TTL_MS = 5 * 60 * 1000;
|
|
83
|
+
const archiveTickets = new Map<string, { path: string; exp: number }>();
|
|
84
|
+
function sweepArchiveTickets(now: number) { for (const [k, v] of archiveTickets) if (v.exp <= now) archiveTickets.delete(k); }
|
|
85
|
+
function issueArchiveTicket(path: string): { ok: true; ticket: string; expiresIn: number } {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
sweepArchiveTickets(now);
|
|
88
|
+
const ticket = randomBytes(24).toString("base64url");
|
|
89
|
+
archiveTickets.set(ticket, { path, exp: now + TICKET_TTL_MS });
|
|
90
|
+
return { ok: true, ticket, expiresIn: Math.round(TICKET_TTL_MS / 1000) };
|
|
91
|
+
}
|
|
92
|
+
function redeemArchiveTicket(ticket: unknown): string | null {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
sweepArchiveTickets(now);
|
|
95
|
+
const v = archiveTickets.get(typeof ticket === "string" ? ticket : "");
|
|
96
|
+
return v ? v.path : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 写目标授权(扩展内实现,不依赖 core 新增接口):
|
|
101
|
+
* realpath 最深已存在父链,再对 real 父路径做词法 isAllowed。
|
|
102
|
+
* writeFile/mkdir 都跟随中间 symlink 段——纯词法前缀检查会被「根内 symlink 目录
|
|
103
|
+
* 指向根外」逃逸(如 root/link → /etc,写 link/evil 落到根外);先把已存在父链
|
|
104
|
+
* 解析成真实路径再校验即封住。差异:core isAllowed 对 roots 做词法比较(未
|
|
105
|
+
* realpath 化 roots),allowed root 自身是 symlink 时会误拒——deny 方向,可接受。
|
|
106
|
+
*/
|
|
107
|
+
async function isWritableTarget(target: string, ctx: any): Promise<boolean> {
|
|
108
|
+
let cur = target;
|
|
109
|
+
for (;;) {
|
|
110
|
+
try {
|
|
111
|
+
lstatSync(cur);
|
|
112
|
+
} catch {
|
|
113
|
+
// 尚不存在——向上找最深已存在祖先;整条链缺失则写必败,防御性拒绝。
|
|
114
|
+
const parent = dirname(cur);
|
|
115
|
+
if (parent === cur) return false;
|
|
116
|
+
cur = parent;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return await ctx.core.fileAccess.isAllowed(realpathSync(cur));
|
|
121
|
+
} catch {
|
|
122
|
+
// lstat 存在但 realpath 失败(权限/竞态)——拒绝。
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const UPLOAD_MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
129
|
+
const UPLOAD_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
130
|
+
|
|
131
|
+
/** 移入系统回收站(trash npm 包:Linux XDG / macOS 原生 / Windows 回收站;可恢复)。 */
|
|
132
|
+
async function trashToRecycle(absPath: string): Promise<{ ok: boolean; error?: string }> {
|
|
133
|
+
try {
|
|
134
|
+
// glob:false —— 路径按字面处理,避免含 * ? [] 等字符被当 glob 展开
|
|
135
|
+
await trash([absPath], { glob: false });
|
|
136
|
+
return { ok: true };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { ok: false, error: String((e as Error)?.message || e) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default {
|
|
143
|
+
id: "aiti-folder",
|
|
144
|
+
name: "aiti-folder",
|
|
145
|
+
version: "0.1.0",
|
|
146
|
+
description: "文件夹浏览器(fileViewer folder 接管)+ 目录选择器能力(pathPicker FolderSwitcher)。复用核心端点,UI 全在扩展。",
|
|
147
|
+
client: "dist/aiti-folder-client.mjs",
|
|
148
|
+
clientImmediately: true,
|
|
149
|
+
// 可选依赖(git-ops 共享引擎方案 §4 软依赖):引擎在位时目录浏览器出 git 工具条
|
|
150
|
+
// (分支面板/同步/提交);缺席/禁用 → 工具条隐藏,folder 其余功能零影响。
|
|
151
|
+
optionalDependsOn: ["aiti-git-ops"],
|
|
152
|
+
icon: "icon.svg",
|
|
153
|
+
ui: {
|
|
154
|
+
fileViewer: [
|
|
155
|
+
{ id: "aiti-folder", label: "文件夹", title: "aiti-folder:文件夹浏览器(树 + git 状态 + 切换)" },
|
|
156
|
+
],
|
|
157
|
+
pathPicker: [
|
|
158
|
+
{ id: "aiti-folder-picker", label: "目录选择", title: "aiti-folder:目录选择器能力(FolderSwitcher,不切 cwd)" },
|
|
159
|
+
],
|
|
160
|
+
// 扩展自持 i18n 表(ctx.t 只读本表;缺 key 回退显 key)。复用核心 files.*/sidebar.* key,
|
|
161
|
+
// 翻译与 core lib/i18n/messages 对齐——新增 t()/tt() 调用记得同步补 key。
|
|
162
|
+
i18n: {
|
|
163
|
+
"zh-CN": {
|
|
164
|
+
"files.cancel": "取消",
|
|
165
|
+
"files.refresh": "刷新",
|
|
166
|
+
"files.changedCount": "{count} 个文件",
|
|
167
|
+
"files.loading": "正在加载文件...",
|
|
168
|
+
"files.noFiles": "未找到文件",
|
|
169
|
+
"files.noSubdirs": "无子目录",
|
|
170
|
+
"files.openFolder": "打开文件夹",
|
|
171
|
+
"files.parentDir": "上级目录",
|
|
172
|
+
"files.selectFolder": "选择此文件夹",
|
|
173
|
+
"files.containsChangedFiles": "包含已更改文件",
|
|
174
|
+
"files.insertPath": "将路径插入聊天",
|
|
175
|
+
"files.atNewSession": "到新session",
|
|
176
|
+
"files.entryInfo": "文件信息",
|
|
177
|
+
"files.infoName": "名称",
|
|
178
|
+
"files.infoType": "类型",
|
|
179
|
+
"files.infoSize": "大小",
|
|
180
|
+
"files.infoMtime": "修改时间",
|
|
181
|
+
"files.infoParent": "所在目录",
|
|
182
|
+
"files.infoOpenParent": "点击打开该目录",
|
|
183
|
+
"files.infoItems": "包含条目",
|
|
184
|
+
"files.infoTypeFile": "文件",
|
|
185
|
+
"files.infoTypeDir": "文件夹",
|
|
186
|
+
"files.open": "打开",
|
|
187
|
+
"files.openInPanel": "在面板打开",
|
|
188
|
+
"files.openNewWindow": "在新窗口打开",
|
|
189
|
+
"files.rename": "重命名",
|
|
190
|
+
"files.renamePlaceholder": "新名称",
|
|
191
|
+
"files.renameSuccess": "已重命名",
|
|
192
|
+
"files.renameFailed": "重命名失败",
|
|
193
|
+
"files.confirm": "确认",
|
|
194
|
+
"files.copyPath": "复制路径",
|
|
195
|
+
"files.copyDone": "已复制路径",
|
|
196
|
+
"files.copyFailed": "复制失败",
|
|
197
|
+
"files.copyTo": "复制到",
|
|
198
|
+
"files.copyToTitle": "复制到",
|
|
199
|
+
"files.copyHere": "复制到此目录",
|
|
200
|
+
"files.copyDoneAction": "已复制",
|
|
201
|
+
"files.menuTabs": "标签页",
|
|
202
|
+
"files.menuNav": "导航",
|
|
203
|
+
"files.menuOpen": "打开",
|
|
204
|
+
"files.menuRef": "路径引用",
|
|
205
|
+
"files.menuOrganize": "整理",
|
|
206
|
+
"files.menuCreate": "新建 / 上传",
|
|
207
|
+
"files.menuExport": "导出",
|
|
208
|
+
"files.menuGit": "Git",
|
|
209
|
+
"files.openParentDir": "打开上级目录",
|
|
210
|
+
"files.openTerminal": "打开终端",
|
|
211
|
+
"files.pin": "钉住",
|
|
212
|
+
"files.unpin": "取消钉住",
|
|
213
|
+
"files.pinDone": "已钉住(不受批量关闭影响)",
|
|
214
|
+
"files.unpinDone": "已取消钉住",
|
|
215
|
+
"files.closeLeft": "关闭左侧",
|
|
216
|
+
"files.closeRight": "关闭右侧",
|
|
217
|
+
"files.closeAll": "关闭全部",
|
|
218
|
+
"files.batchClosed": "已关闭 {count} 个标签页",
|
|
219
|
+
"files.download": "下载",
|
|
220
|
+
"files.newFile": "新建文件",
|
|
221
|
+
"files.newFolder": "新建文件夹",
|
|
222
|
+
"files.uploadFile": "上传文件",
|
|
223
|
+
"files.uploadFolder": "上传文件夹",
|
|
224
|
+
"files.create": "创建",
|
|
225
|
+
"files.delete": "删除",
|
|
226
|
+
"files.trash": "移入回收站",
|
|
227
|
+
"files.trashConfirmTitle": "移入回收站?",
|
|
228
|
+
"files.trashConfirmBody": "将移入系统回收站(可恢复)。",
|
|
229
|
+
"files.trashDone": "已移入回收站",
|
|
230
|
+
"files.move": "移动到",
|
|
231
|
+
"files.moveTitle": "移动到",
|
|
232
|
+
"files.moveTarget": "目标目录",
|
|
233
|
+
"files.moveWorkspace": "选择工作区",
|
|
234
|
+
"files.moveBrowse": "选择目标目录",
|
|
235
|
+
"files.moveHere": "移动到此目录",
|
|
236
|
+
"files.moveDone": "已移动",
|
|
237
|
+
"files.moveFailed": "移动失败",
|
|
238
|
+
"files.moveIntoItself": "不能移动到自身或其子目录",
|
|
239
|
+
"files.namePlaceholderFile": "文件名(如 readme.md)",
|
|
240
|
+
"files.namePlaceholderFolder": "文件夹名",
|
|
241
|
+
"i18n.close": "关闭",
|
|
242
|
+
"sidebar.agentRunning": "Agent 运行中…",
|
|
243
|
+
"sidebar.changedFiles": "{count} 个变更文件",
|
|
244
|
+
"sidebar.customPath": "自定义路径…",
|
|
245
|
+
"sidebar.filterProjects": "筛选项目…",
|
|
246
|
+
"sidebar.newSessionActivity": "会话有新活动",
|
|
247
|
+
"sidebar.noMatchingProjects": "没有匹配的项目",
|
|
248
|
+
"sidebar.noMatchingFiles": "没有匹配的文件",
|
|
249
|
+
"sidebar.refreshExplorer": "刷新文件浏览器",
|
|
250
|
+
"sidebar.searchFiles": "搜索文件",
|
|
251
|
+
"sidebar.searchFilesPlaceholder": "搜索文件…",
|
|
252
|
+
"sidebar.searchingFiles": "搜索中…",
|
|
253
|
+
"sidebar.clearSearch": "清除搜索",
|
|
254
|
+
"sidebar.searchTabFiles": "文件",
|
|
255
|
+
"sidebar.searchTabFulltext": "文档",
|
|
256
|
+
"sidebar.searchingDocs": "搜索文档中…",
|
|
257
|
+
"sidebar.noMatchingDocs": "没有匹配的文档",
|
|
258
|
+
"sidebar.docCenterUnavailable": "文档中心未启用",
|
|
259
|
+
"sidebar.fullScopeAll": "全 workspace",
|
|
260
|
+
"sidebar.fulltextHint": "限文档中心已收录 md",
|
|
261
|
+
"sidebar.fulltextHintTitle": "仅搜索文档中心已收录的 workspace md;收录为定期/手动扫描,最新文件可能未入库",
|
|
262
|
+
"sidebar.useDefaultDirectory": "使用默认目录",
|
|
263
|
+
"i18n.networkError": "网络错误",
|
|
264
|
+
},
|
|
265
|
+
"en": {
|
|
266
|
+
"files.cancel": "Cancel",
|
|
267
|
+
"files.refresh": "Refresh",
|
|
268
|
+
"files.changedCount": "{count} files",
|
|
269
|
+
"files.loading": "Loading files...",
|
|
270
|
+
"files.noFiles": "No files found",
|
|
271
|
+
"files.noSubdirs": "No subdirectories",
|
|
272
|
+
"files.openFolder": "Open Folder",
|
|
273
|
+
"files.parentDir": "Parent directory",
|
|
274
|
+
"files.selectFolder": "Select this folder",
|
|
275
|
+
"files.containsChangedFiles": "Contains changed files",
|
|
276
|
+
"files.insertPath": "Insert path into chat",
|
|
277
|
+
"files.atNewSession": "to new session",
|
|
278
|
+
"files.entryInfo": "File info",
|
|
279
|
+
"files.infoName": "Name",
|
|
280
|
+
"files.infoType": "Type",
|
|
281
|
+
"files.infoSize": "Size",
|
|
282
|
+
"files.infoMtime": "Modified",
|
|
283
|
+
"files.infoParent": "Parent directory",
|
|
284
|
+
"files.infoOpenParent": "Click to open this directory",
|
|
285
|
+
"files.infoItems": "Items",
|
|
286
|
+
"files.infoTypeFile": "File",
|
|
287
|
+
"files.infoTypeDir": "Folder",
|
|
288
|
+
"files.open": "Open",
|
|
289
|
+
"files.openInPanel": "Open in panel",
|
|
290
|
+
"files.openNewWindow": "Open in new window",
|
|
291
|
+
"files.rename": "Rename",
|
|
292
|
+
"files.renamePlaceholder": "New name",
|
|
293
|
+
"files.renameSuccess": "Renamed",
|
|
294
|
+
"files.renameFailed": "Rename failed",
|
|
295
|
+
"files.confirm": "Confirm",
|
|
296
|
+
"files.copyPath": "Copy path",
|
|
297
|
+
"files.copyDone": "Path copied",
|
|
298
|
+
"files.copyFailed": "Copy failed",
|
|
299
|
+
"files.copyTo": "Copy to",
|
|
300
|
+
"files.copyToTitle": "Copy to",
|
|
301
|
+
"files.copyHere": "Copy here",
|
|
302
|
+
"files.copyDoneAction": "Copied",
|
|
303
|
+
"files.menuTabs": "Tabs",
|
|
304
|
+
"files.menuNav": "Navigate",
|
|
305
|
+
"files.menuOpen": "Open",
|
|
306
|
+
"files.menuRef": "Path reference",
|
|
307
|
+
"files.menuOrganize": "Organize",
|
|
308
|
+
"files.menuCreate": "Create / Upload",
|
|
309
|
+
"files.menuExport": "Export",
|
|
310
|
+
"files.menuGit": "Git",
|
|
311
|
+
"files.openParentDir": "Open parent folder",
|
|
312
|
+
"files.openTerminal": "Open in Terminal",
|
|
313
|
+
"files.pin": "Pin",
|
|
314
|
+
"files.unpin": "Unpin",
|
|
315
|
+
"files.pinDone": "Pinned (protected from batch close)",
|
|
316
|
+
"files.unpinDone": "Unpinned",
|
|
317
|
+
"files.closeLeft": "Close Left",
|
|
318
|
+
"files.closeRight": "Close Right",
|
|
319
|
+
"files.closeAll": "Close All",
|
|
320
|
+
"files.batchClosed": "Closed {count} tab(s)",
|
|
321
|
+
"files.download": "Download",
|
|
322
|
+
"files.newFile": "New File",
|
|
323
|
+
"files.newFolder": "New Folder",
|
|
324
|
+
"files.uploadFile": "Upload File",
|
|
325
|
+
"files.uploadFolder": "Upload Folder",
|
|
326
|
+
"files.create": "Create",
|
|
327
|
+
"files.delete": "Delete",
|
|
328
|
+
"files.trash": "Move to trash",
|
|
329
|
+
"files.trashConfirmTitle": "Move to trash?",
|
|
330
|
+
"files.trashConfirmBody": "Will be moved to the system trash (recoverable).",
|
|
331
|
+
"files.trashDone": "Moved to trash",
|
|
332
|
+
"files.move": "Move to",
|
|
333
|
+
"files.moveTitle": "Move to",
|
|
334
|
+
"files.moveTarget": "Destination",
|
|
335
|
+
"files.moveWorkspace": "Select workspace",
|
|
336
|
+
"files.moveBrowse": "Choose destination",
|
|
337
|
+
"files.moveHere": "Move here",
|
|
338
|
+
"files.moveDone": "Moved",
|
|
339
|
+
"files.moveFailed": "Move failed",
|
|
340
|
+
"files.moveIntoItself": "Cannot move into itself or its subdirectory",
|
|
341
|
+
"files.namePlaceholderFile": "File name (e.g. readme.md)",
|
|
342
|
+
"files.namePlaceholderFolder": "Folder name",
|
|
343
|
+
"i18n.close": "Close",
|
|
344
|
+
"sidebar.agentRunning": "Agent running…",
|
|
345
|
+
"sidebar.changedFiles": "{count} changed files",
|
|
346
|
+
"sidebar.customPath": "Custom path…",
|
|
347
|
+
"sidebar.filterProjects": "Filter projects…",
|
|
348
|
+
"sidebar.newSessionActivity": "New session activity",
|
|
349
|
+
"sidebar.noMatchingProjects": "No matching projects",
|
|
350
|
+
"sidebar.noMatchingFiles": "No matching files",
|
|
351
|
+
"sidebar.refreshExplorer": "Refresh explorer",
|
|
352
|
+
"sidebar.searchFiles": "Search files",
|
|
353
|
+
"sidebar.searchFilesPlaceholder": "Search files…",
|
|
354
|
+
"sidebar.searchingFiles": "Searching…",
|
|
355
|
+
"sidebar.clearSearch": "Clear search",
|
|
356
|
+
"sidebar.searchTabFiles": "Files",
|
|
357
|
+
"sidebar.searchTabFulltext": "Docs",
|
|
358
|
+
"sidebar.searchingDocs": "Searching docs…",
|
|
359
|
+
"sidebar.noMatchingDocs": "No matching docs",
|
|
360
|
+
"sidebar.docCenterUnavailable": "Doc center unavailable",
|
|
361
|
+
"sidebar.fullScopeAll": "All workspaces",
|
|
362
|
+
"sidebar.fulltextHint": "doc-center indexed md only",
|
|
363
|
+
"sidebar.fulltextHintTitle": "Only workspace md indexed by aiti-doc-center; collection is periodic/manual, newest files may be missing",
|
|
364
|
+
"sidebar.useDefaultDirectory": "Use default directory",
|
|
365
|
+
"i18n.networkError": "Network error",
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
server: {
|
|
370
|
+
startup(ctx?: any) {
|
|
371
|
+
hostSvc = ctx?.services ?? null; // DI 方案 P1:services 通道捕获
|
|
372
|
+
console.log("[aiti-folder] 就绪 · fileViewer(folder) + pathPicker 槽");
|
|
373
|
+
},
|
|
374
|
+
async api(req: Request, ctx: any) {
|
|
375
|
+
const u = new URL(req.url);
|
|
376
|
+
const p = u.pathname.split("/").filter(Boolean);
|
|
377
|
+
const route = p[p.length - 1];
|
|
378
|
+
|
|
379
|
+
// POST /create-file { dir, name, content? } → 新建文件(可带初始内容;已存在 409)
|
|
380
|
+
if (route === "create-file" && req.method === "POST") {
|
|
381
|
+
const body = await req.json().catch(() => null);
|
|
382
|
+
const dir = typeof body?.dir === "string" ? body.dir : "";
|
|
383
|
+
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
|
384
|
+
if (!validEntryName(name)) return json({ ok: false, error: "文件名非法(不能含 / \\ 或 ..)" }, 400);
|
|
385
|
+
const root = await allowedDir(dir, ctx);
|
|
386
|
+
if (!root) return json({ ok: false, error: "目录不在可写范围" }, 403);
|
|
387
|
+
const target = join(root, name);
|
|
388
|
+
// 写前 realpath 校验:目录自身是 symlink(指向根外)时拒绝。
|
|
389
|
+
if (!(await isWritableTarget(target, ctx))) return json({ ok: false, error: "目标不在可写范围" }, 403);
|
|
390
|
+
if (existsSync(target)) return json({ ok: false, error: "文件已存在" }, 409);
|
|
391
|
+
try {
|
|
392
|
+
// wx:原子新建,竞态下不覆盖已有文件(EEXIST 归一 409)。
|
|
393
|
+
writeFileSync(target, typeof body?.content === "string" ? body.content : "", { flag: "wx" });
|
|
394
|
+
return json({ ok: true, path: target });
|
|
395
|
+
} catch (e) {
|
|
396
|
+
if ((e as NodeJS.ErrnoException)?.code === "EEXIST") return json({ ok: false, error: "文件已存在" }, 409);
|
|
397
|
+
return json({ ok: false, error: "创建失败: " + String((e as Error)?.message || e) }, 500);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// POST /open-terminal { path } → 在目录打开终端(Unix=终端模拟器新窗跑 bash;Windows=powershell 新窗)
|
|
402
|
+
// 选中文件夹→CWD=自身;选中文件→CWD=父目录。detached+stdio:ignore 弹独立窗口,与 pi-web 服务生命周期解耦。
|
|
403
|
+
if (route === "open-terminal" && req.method === "POST") {
|
|
404
|
+
const body = await req.json().catch(() => null);
|
|
405
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
406
|
+
if (!path || !isAbsolute(path)) return json({ ok: false, error: "需要绝对路径" }, 400);
|
|
407
|
+
const abs = normalize(path);
|
|
408
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "路径不在可写范围" }, 403);
|
|
409
|
+
if (!existsSync(abs)) return json({ ok: false, error: "不存在" }, 404);
|
|
410
|
+
const isDir = lstatSync(abs).isDirectory();
|
|
411
|
+
const cwd = isDir ? abs : dirname(abs);
|
|
412
|
+
// detached 启动单个候选;ENOENT 等经 error 事件 reject → 调用方换下一个候选
|
|
413
|
+
const trySpawn = (cmd: string, args: string[]) => new Promise<void>((resolve, reject) => {
|
|
414
|
+
try {
|
|
415
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
416
|
+
child.once("error", reject);
|
|
417
|
+
child.once("spawn", () => resolve());
|
|
418
|
+
child.unref();
|
|
419
|
+
} catch (e) { reject(e); }
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
if (process.platform === "win32") {
|
|
423
|
+
// start 在新控制台窗口拉起 powershell,-NoExit 保持会话
|
|
424
|
+
await trySpawn("cmd.exe", ["/c", "start", "powershell", "-NoExit", "-Command", "Set-Location", "-LiteralPath", cwd]);
|
|
425
|
+
} else if (process.platform === "darwin") {
|
|
426
|
+
await trySpawn("open", ["-a", "Terminal", cwd]);
|
|
427
|
+
} else {
|
|
428
|
+
// Linux:按常见度依次尝试(各终端 working-directory 参数形式不同)
|
|
429
|
+
const candidates: Array<[string, string[]]> = [
|
|
430
|
+
["x-terminal-emulator", ["--working-directory", cwd]],
|
|
431
|
+
["gnome-terminal", ["--working-directory=" + cwd]],
|
|
432
|
+
["konsole", ["--workdir", cwd]],
|
|
433
|
+
["xfce4-terminal", ["--working-directory", cwd]],
|
|
434
|
+
["xterm", ["-e", "bash", "-c", "cd " + JSON.stringify(cwd) + "; exec bash"]],
|
|
435
|
+
];
|
|
436
|
+
let lastErr: unknown = null;
|
|
437
|
+
let launched = false;
|
|
438
|
+
for (const [cmd, args] of candidates) {
|
|
439
|
+
try { await trySpawn(cmd, args); launched = true; break; } catch (e) { lastErr = e; }
|
|
440
|
+
}
|
|
441
|
+
if (!launched) return json({ ok: false, error: "没有可用的终端模拟器: " + String((lastErr as Error)?.message || lastErr) }, 500);
|
|
442
|
+
}
|
|
443
|
+
return json({ ok: true });
|
|
444
|
+
} catch (e) {
|
|
445
|
+
return json({ ok: false, error: "打开终端失败: " + String((e as Error)?.message || e) }, 500);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// POST /create-folder { dir, name } → 新建文件夹(已存在 409)
|
|
449
|
+
if (route === "create-folder" && req.method === "POST") {
|
|
450
|
+
const body = await req.json().catch(() => null);
|
|
451
|
+
const dir = typeof body?.dir === "string" ? body.dir : "";
|
|
452
|
+
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
|
453
|
+
if (!validEntryName(name)) return json({ ok: false, error: "文件夹名非法(不能含 / \\ 或 ..)" }, 400);
|
|
454
|
+
const root = await allowedDir(dir, ctx);
|
|
455
|
+
if (!root) return json({ ok: false, error: "目录不在可写范围" }, 403);
|
|
456
|
+
const target = join(root, name);
|
|
457
|
+
if (!(await isWritableTarget(target, ctx))) return json({ ok: false, error: "目标不在可写范围" }, 403);
|
|
458
|
+
if (existsSync(target)) return json({ ok: false, error: "文件夹已存在" }, 409);
|
|
459
|
+
try {
|
|
460
|
+
mkdirSync(target);
|
|
461
|
+
return json({ ok: true, path: target });
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if ((e as NodeJS.ErrnoException)?.code === "EEXIST") return json({ ok: false, error: "文件夹已存在" }, 409);
|
|
464
|
+
return json({ ok: false, error: "创建失败: " + String((e as Error)?.message || e) }, 500);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// POST /upload-tree — multipart:dir 字段 + files(文件名=相对路径,服务端逐层建目录)
|
|
469
|
+
// 语义:把选中文件夹的内容整体上传到 dir 下(含文件夹名),冲突跳过。
|
|
470
|
+
if (route === "upload-tree" && req.method === "POST") {
|
|
471
|
+
// 解析期预检:Content-Length 超上限直接 413(不解析 body,防超大请求打内存)。
|
|
472
|
+
const contentLength = Number(req.headers.get("content-length") || 0);
|
|
473
|
+
if (contentLength > UPLOAD_MAX_TOTAL_BYTES + 1024 * 1024) {
|
|
474
|
+
return json({ ok: false, error: "总大小超过 100MB" }, 413);
|
|
475
|
+
}
|
|
476
|
+
let form: FormData;
|
|
477
|
+
try { form = await req.formData(); }
|
|
478
|
+
catch { return json({ ok: false, error: "multipart 解析失败" }, 400); }
|
|
479
|
+
const dir = String(form.get("dir") || "");
|
|
480
|
+
const root = await allowedDir(dir, ctx);
|
|
481
|
+
if (!root) return json({ ok: false, error: "目录不在可写范围" }, 403);
|
|
482
|
+
const files = form.getAll("files").filter((e): e is File => typeof e !== "string");
|
|
483
|
+
if (files.length === 0) return json({ ok: false, error: "没有文件" }, 400);
|
|
484
|
+
if (files.some((f) => f.size > UPLOAD_MAX_FILE_BYTES)) return json({ ok: false, error: "单个文件超过 25MB" }, 413);
|
|
485
|
+
if (files.reduce((t, f) => t + f.size, 0) > UPLOAD_MAX_TOTAL_BYTES) return json({ ok: false, error: "总大小超过 100MB" }, 413);
|
|
486
|
+
const uploaded: string[] = [];
|
|
487
|
+
const skipped: string[] = [];
|
|
488
|
+
const errors: Array<{ name: string; error: string }> = [];
|
|
489
|
+
for (const f of files) {
|
|
490
|
+
const rel = f.name; // FormData append 的 filename = 相对路径
|
|
491
|
+
if (!validRelPath(rel)) { errors.push({ name: rel, error: "非法路径" }); continue; }
|
|
492
|
+
const target: string = join(root, rel.split(/[\\/]+/).join(sep));
|
|
493
|
+
if (!target.startsWith(root + sep)) { errors.push({ name: rel, error: "路径越界" }); continue; }
|
|
494
|
+
// 写前 realpath 校验:中间目录可能是根内 symlink(指向根外)——逐文件封住。
|
|
495
|
+
if (!(await isWritableTarget(target, ctx))) { errors.push({ name: rel, error: "路径越界" }); continue; }
|
|
496
|
+
if (existsSync(target)) { skipped.push(rel); continue; }
|
|
497
|
+
try {
|
|
498
|
+
const parent: string = target.slice(0, target.lastIndexOf(sep));
|
|
499
|
+
if (parent && parent !== root && !existsSync(parent)) mkdirSync(parent, { recursive: true });
|
|
500
|
+
writeFileSync(target, Buffer.from(await f.arrayBuffer()), { flag: "wx" });
|
|
501
|
+
uploaded.push(rel);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
errors.push({ name: rel, error: String((e as Error)?.message || e) });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return json({ ok: true, uploaded, skipped, errors });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// POST /trash { paths: string[] } → 移入系统回收站(gio trash / XDG 兑底),可恢复、不物理删除
|
|
510
|
+
if (route === "trash" && req.method === "POST") {
|
|
511
|
+
const body = await req.json().catch(() => null);
|
|
512
|
+
const paths: string[] = Array.isArray(body?.paths)
|
|
513
|
+
? body.paths.filter((x: unknown): x is string => typeof x === "string" && !!x)
|
|
514
|
+
: [];
|
|
515
|
+
if (paths.length === 0) return json({ ok: false, error: "需要 paths" }, 400);
|
|
516
|
+
const trashed: string[] = [];
|
|
517
|
+
const errors: Array<{ path: string; error: string }> = [];
|
|
518
|
+
for (const raw of paths) {
|
|
519
|
+
const abs = normalize(raw);
|
|
520
|
+
if (!isAbsolute(abs)) { errors.push({ path: raw, error: "非绝对路径" }); continue; }
|
|
521
|
+
// realpath 校验:中间段 symlink(指向根外)会把删除引到根外;路径已存在,
|
|
522
|
+
// isWritableTarget 解析其真实路径后做词法 isAllowed。
|
|
523
|
+
if (!(await isWritableTarget(abs, ctx))) { errors.push({ path: raw, error: "路径不在可写范围" }); continue; }
|
|
524
|
+
if (!existsSync(abs)) { errors.push({ path: raw, error: "不存在" }); continue; }
|
|
525
|
+
const r = await trashToRecycle(abs);
|
|
526
|
+
if (r.ok) trashed.push(raw);
|
|
527
|
+
else errors.push({ path: raw, error: r.error || "回收失败" });
|
|
528
|
+
}
|
|
529
|
+
return json({ ok: true, trashed, errors });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// POST /rename { path, newName } → 重命名文件或文件夹(renameSync)
|
|
533
|
+
if (route === "rename" && req.method === "POST") {
|
|
534
|
+
const body = await req.json().catch(() => null);
|
|
535
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
536
|
+
const newName = typeof body?.newName === "string" ? body.newName.trim() : "";
|
|
537
|
+
if (!path || !newName) return json({ ok: false, error: "需要 path 和 newName" }, 400);
|
|
538
|
+
if (!validEntryName(newName)) return json({ ok: false, error: "名称非法(不能含 / \\ 或 ..)" }, 400);
|
|
539
|
+
const abs = normalize(path);
|
|
540
|
+
if (!isAbsolute(abs)) return json({ ok: false, error: "非绝对路径" }, 400);
|
|
541
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "路径不在可写范围" }, 403);
|
|
542
|
+
if (!existsSync(abs)) return json({ ok: false, error: "不存在" }, 404);
|
|
543
|
+
const dir = dirname(abs);
|
|
544
|
+
const target = join(dir, newName);
|
|
545
|
+
if (target === abs) return json({ ok: true, path: abs });
|
|
546
|
+
if (existsSync(target)) return json({ ok: false, error: "目标已存在" }, 409);
|
|
547
|
+
if (!(await isWritableTarget(target, ctx))) return json({ ok: false, error: "目标不在可写范围" }, 403);
|
|
548
|
+
try {
|
|
549
|
+
renameSync(abs, target);
|
|
550
|
+
return json({ ok: true, path: target });
|
|
551
|
+
} catch (e) {
|
|
552
|
+
return json({ ok: false, error: "重命名失败: " + String((e as Error)?.message || e) }, 500);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// POST /move { path, targetDir } → 移动文件/文件夹到目标目录(renameSync;跨设备 EXDEV 时复制 + 源移入回收站)
|
|
557
|
+
if (route === "move" && req.method === "POST") {
|
|
558
|
+
const body = await req.json().catch(() => null);
|
|
559
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
560
|
+
const targetDir = typeof body?.targetDir === "string" ? body.targetDir : "";
|
|
561
|
+
if (!path || !targetDir) return json({ ok: false, error: "需要 path 和 targetDir" }, 400);
|
|
562
|
+
const abs = normalize(path);
|
|
563
|
+
const dest = normalize(targetDir);
|
|
564
|
+
if (!isAbsolute(abs) || !isAbsolute(dest)) return json({ ok: false, error: "需要绝对路径" }, 400);
|
|
565
|
+
// 源与目标目录都必须过 realpath 写授权(防根内 symlink 逃逸)
|
|
566
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "源路径不在可写范围" }, 403);
|
|
567
|
+
if (!(await isWritableTarget(dest, ctx))) return json({ ok: false, error: "目标目录不在可写范围" }, 403);
|
|
568
|
+
if (!existsSync(abs)) return json({ ok: false, error: "源不存在" }, 404);
|
|
569
|
+
if (!existsSync(dest) || !lstatSync(dest).isDirectory()) return json({ ok: false, error: "目标不是目录或不存在" }, 400);
|
|
570
|
+
const name = lastSeg(abs);
|
|
571
|
+
const target = join(dest, name);
|
|
572
|
+
// 移动进自身目录(同名条目冲突);目标 = 源自身时无操作
|
|
573
|
+
if (target === abs) return json({ ok: true, path: abs });
|
|
574
|
+
// 目录不能移进自己或自己的子孙目录(否则会把自己搬进自己)
|
|
575
|
+
const srcReal = abs.replace(/[\\/]+$/, "");
|
|
576
|
+
if (lstatSync(abs).isDirectory() && (dest === srcReal || dest.startsWith(srcReal + sep))) {
|
|
577
|
+
return json({ ok: false, error: "不能移动到自身或其子目录" }, 400);
|
|
578
|
+
}
|
|
579
|
+
if (existsSync(target)) return json({ ok: false, error: "目标位置已存在同名条目" }, 409);
|
|
580
|
+
if (!(await isWritableTarget(target, ctx))) return json({ ok: false, error: "目标不在可写范围" }, 403);
|
|
581
|
+
try {
|
|
582
|
+
renameSync(abs, target);
|
|
583
|
+
return json({ ok: true, path: target });
|
|
584
|
+
} catch (e) {
|
|
585
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
586
|
+
// EXDEV:跨设备(rename 不支持)——递归复制到目标,成功后源移入回收站(可恢复,不物理删除)
|
|
587
|
+
if (code === "EXDEV") {
|
|
588
|
+
try {
|
|
589
|
+
cpSync(abs, target, { recursive: true, preserveTimestamps: true });
|
|
590
|
+
const r = await trashToRecycle(abs);
|
|
591
|
+
if (!r.ok) { try { rmSync(target, { recursive: true, force: true }); } catch { /* noop */ } return json({ ok: false, error: "跨设备移动失败: " + (r.error || "源清理失败") }, 500); }
|
|
592
|
+
return json({ ok: true, path: target });
|
|
593
|
+
} catch (e2) {
|
|
594
|
+
try { rmSync(target, { recursive: true, force: true }); } catch { /* noop */ }
|
|
595
|
+
return json({ ok: false, error: "跨设备移动失败: " + String((e2 as Error)?.message || e2) }, 500);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return json({ ok: false, error: "移动失败: " + String((e as Error)?.message || e) }, 500);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// POST /copy { path, targetDir } → 复制文件/文件夹到目标目录(cpSync recursive,源保留不动;行菜单「复制到」)
|
|
603
|
+
if (route === "copy" && req.method === "POST") {
|
|
604
|
+
const body = await req.json().catch(() => null);
|
|
605
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
606
|
+
const targetDir = typeof body?.targetDir === "string" ? body.targetDir : "";
|
|
607
|
+
if (!path || !targetDir) return json({ ok: false, error: "需要 path 和 targetDir" }, 400);
|
|
608
|
+
const abs = normalize(path);
|
|
609
|
+
const dest = normalize(targetDir);
|
|
610
|
+
if (!isAbsolute(abs) || !isAbsolute(dest)) return json({ ok: false, error: "需要绝对路径" }, 400);
|
|
611
|
+
// 源与目标目录都必须过 realpath 写授权(防根内 symlink 逃逸)
|
|
612
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "源路径不在可写范围" }, 403);
|
|
613
|
+
if (!(await isWritableTarget(dest, ctx))) return json({ ok: false, error: "目标目录不在可写范围" }, 403);
|
|
614
|
+
if (!existsSync(abs)) return json({ ok: false, error: "源不存在" }, 404);
|
|
615
|
+
if (!existsSync(dest) || !lstatSync(dest).isDirectory()) return json({ ok: false, error: "目标不是目录或不存在" }, 400);
|
|
616
|
+
const srcReal = abs.replace(/[\\/]+$/, "");
|
|
617
|
+
const target = join(dest, lastSeg(abs));
|
|
618
|
+
if (target === abs) return json({ ok: false, error: "目标与源相同" }, 400);
|
|
619
|
+
// 目录不能复制进自己或自己的子孙目录(自包含递归)
|
|
620
|
+
if (lstatSync(abs).isDirectory() && (dest === srcReal || dest.startsWith(srcReal + sep))) {
|
|
621
|
+
return json({ ok: false, error: "不能复制到自身或其子目录" }, 400);
|
|
622
|
+
}
|
|
623
|
+
if (existsSync(target)) return json({ ok: false, error: "目标位置已存在同名条目" }, 409);
|
|
624
|
+
if (!(await isWritableTarget(target, ctx))) return json({ ok: false, error: "目标不在可写范围" }, 403);
|
|
625
|
+
try {
|
|
626
|
+
cpSync(abs, target, { recursive: true, preserveTimestamps: true });
|
|
627
|
+
return json({ ok: true, path: target });
|
|
628
|
+
} catch (e) {
|
|
629
|
+
// cpSync 无原子性:半途失败先撤目标,不留半份拷贝
|
|
630
|
+
try { rmSync(target, { recursive: true, force: true }); } catch { /* noop */ }
|
|
631
|
+
return json({ ok: false, error: "复制失败: " + String((e as Error)?.message || e) }, 500);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// POST /entry-info { path } → 文件/目录基本信息(行菜单「文件信息」弹层用;读操作,与写操作同一安全面)
|
|
636
|
+
// POST /doc-search { q, limit? } → 文档全文检索(标题+正文 FTS5 trigram,<3 字符 LIKE 降级)。
|
|
637
|
+
// 代理 aiti-doc-center services.docs.search(进程内消费——客户端不直连兄弟扩展 HTTP,同 msg-smart 红线)。
|
|
638
|
+
// doc-center 未装/未启用 → available:false(客户端隐藏「文档」页签);db 未建库 → items:[](无命中展示)。
|
|
639
|
+
if (route === "doc-search" && req.method === "POST") {
|
|
640
|
+
let body: any = null;
|
|
641
|
+
try { body = await req.json(); } catch { return json({ ok: false, error: "Invalid JSON body" }, 400); } // 与 pi-web file-index POST 同风格:非法 body 400
|
|
642
|
+
const q = typeof body?.q === "string" ? body.q.trim().slice(0, 500) : "";
|
|
643
|
+
const limit = Math.min(Math.max(Number(body?.limit) || 200, 1), 200);
|
|
644
|
+
const svc = docsService();
|
|
645
|
+
if (!svc) return json({ ok: true, available: false, items: [] });
|
|
646
|
+
// 用 search(q, limit)(= queryDocs withSnippet:true)而非 query()——后者出口不透传 snippet,正文高亮就没了
|
|
647
|
+
const raw = q ? svc.search!(q, limit) : [];
|
|
648
|
+
const items = raw.map((d: any) => ({
|
|
649
|
+
path: [d.root, d.relpath].filter(Boolean).join("/"), // 绝对路径(root=收集时 workspace,relpath 相对它)
|
|
650
|
+
title: typeof d.title === "string" ? d.title : "",
|
|
651
|
+
snippet: typeof d.snippet === "string" ? d.snippet : "", // 已含 <mark>…</mark> 高亮;客户端切分渲染,不走 innerHTML
|
|
652
|
+
tags: Array.isArray(d.tags) ? d.tags : [],
|
|
653
|
+
mtimeMs: typeof d.mtimeMs === "number" ? d.mtimeMs : 0,
|
|
654
|
+
}));
|
|
655
|
+
return json({ ok: true, available: true, items });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (route === "entry-info" && req.method === "POST") {
|
|
659
|
+
const body = await req.json().catch(() => null);
|
|
660
|
+
const raw = typeof body?.path === "string" ? body.path : "";
|
|
661
|
+
if (!raw || !isAbsolute(raw)) return json({ ok: false, error: "需要绝对路径" }, 400);
|
|
662
|
+
const abs = normalize(raw);
|
|
663
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "路径不在可写范围" }, 403);
|
|
664
|
+
try {
|
|
665
|
+
const st = statSync(abs);
|
|
666
|
+
const info: Record<string, unknown> = {
|
|
667
|
+
name: lastSeg(abs),
|
|
668
|
+
isDir: st.isDirectory(),
|
|
669
|
+
size: st.size,
|
|
670
|
+
mtimeMs: st.mtimeMs,
|
|
671
|
+
};
|
|
672
|
+
if (st.isDirectory()) {
|
|
673
|
+
try { info.items = readdirSync(abs).length; } catch { info.items = null; }
|
|
674
|
+
}
|
|
675
|
+
return json({ ok: true, ...info });
|
|
676
|
+
} catch {
|
|
677
|
+
return json({ ok: false, error: "不存在或不可读" }, 404);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// POST /archive-ticket { path } → 签发短时效下载票据(5 分钟,可重复使用;供 GET /archive 用)
|
|
682
|
+
if (route === "archive-ticket" && req.method === "POST") {
|
|
683
|
+
const body = await req.json().catch(() => null);
|
|
684
|
+
const dir = typeof body?.path === "string" ? body.path : "";
|
|
685
|
+
if (!dir || !isAbsolute(dir)) return json({ ok: false, error: "需要绝对路径" }, 400);
|
|
686
|
+
const abs = normalize(dir);
|
|
687
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "路径不在可写范围" }, 403);
|
|
688
|
+
if (!existsSync(abs) || !lstatSync(abs).isDirectory()) return json({ ok: false, error: "不存在或不是目录" }, 404);
|
|
689
|
+
return json(issueArchiveTicket(abs));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// GET /archive?ticket= → 以 tar.gz 流式下载目录(无上限,流式响应)。
|
|
693
|
+
// 路径不收 query(会落隧道/代理 access log):先 POST /archive-ticket 换票据。
|
|
694
|
+
if (route === "archive" && req.method === "GET") {
|
|
695
|
+
const url = new URL(req.url);
|
|
696
|
+
const abs = redeemArchiveTicket(url.searchParams.get("ticket"));
|
|
697
|
+
if (!abs) return json({ ok: false, error: "票据无效或已过期" }, 403);
|
|
698
|
+
if (!(await isWritableTarget(abs, ctx))) return json({ ok: false, error: "路径不在可写范围" }, 403);
|
|
699
|
+
if (!existsSync(abs) || !lstatSync(abs).isDirectory()) return json({ ok: false, error: "不存在或不是目录" }, 404);
|
|
700
|
+
const parent = dirname(abs);
|
|
701
|
+
const name = lastSeg(abs);
|
|
702
|
+
return new Response(
|
|
703
|
+
new ReadableStream({
|
|
704
|
+
start(controller) {
|
|
705
|
+
const proc = spawn("tar", ["czf", "-", "-C", parent, name], { stdio: ["ignore", "pipe", "pipe"] });
|
|
706
|
+
const errBuf: Buffer[] = [];
|
|
707
|
+
proc.stderr.on("data", (d) => errBuf.push(d));
|
|
708
|
+
proc.stdout.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
|
709
|
+
proc.stdout.on("end", () => {
|
|
710
|
+
const err = Buffer.concat(errBuf).toString().trim();
|
|
711
|
+
if (err && proc.exitCode !== 0) {
|
|
712
|
+
controller.error(new Error("打包失败: " + err));
|
|
713
|
+
} else {
|
|
714
|
+
controller.close();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
proc.stderr.on("error", () => {});
|
|
718
|
+
proc.on("error", (e) => controller.error(e));
|
|
719
|
+
proc.on("exit", (code) => {
|
|
720
|
+
if (code !== 0 && code !== null) {
|
|
721
|
+
const err = Buffer.concat(errBuf).toString().trim();
|
|
722
|
+
controller.error(new Error(err || "tar 退出失败"));
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
{
|
|
728
|
+
headers: {
|
|
729
|
+
"content-type": "application/gzip",
|
|
730
|
+
"content-disposition": `attachment; filename="${name.replace(/[^a-zA-Z0-9_.-]/g,"_")}.tar.gz"`,
|
|
731
|
+
},
|
|
732
|
+
}
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── 提交计划(git-ops M4:CRUD/启发式/学习在 folder state 层;执行直调引擎 /git/plan/*)──
|
|
737
|
+
if (route === "git-plan-get" && req.method === "POST") {
|
|
738
|
+
const body = await req.json().catch(() => ({}));
|
|
739
|
+
const cwd = typeof body?.cwd === "string" ? body.cwd : "";
|
|
740
|
+
if (!isAbsolute(cwd)) return json({ ok: false, error: "cwd required" }, 400);
|
|
741
|
+
const stateDir = (ctx?.stateDir as string) || ((ctx?.configDir as string) ? join(ctx.configDir, "state") : "");
|
|
742
|
+
return json({ ok: true, plan: loadPlan(stateDir, cwd) });
|
|
743
|
+
}
|
|
744
|
+
if (route === "git-plan-save" && req.method === "POST") {
|
|
745
|
+
const body = await req.json().catch(() => ({}));
|
|
746
|
+
const cwd = typeof body?.cwd === "string" ? body.cwd : "";
|
|
747
|
+
if (!isAbsolute(cwd)) return json({ ok: false, error: "cwd required" }, 400);
|
|
748
|
+
const stateDir = (ctx?.stateDir as string) || ((ctx?.configDir as string) ? join(ctx.configDir, "state") : "");
|
|
749
|
+
const prev = loadPlan(stateDir, cwd);
|
|
750
|
+
const plan = savePlan(stateDir, {
|
|
751
|
+
id: prev?.id ?? newPlan(cwd, String(body?.baseHEAD || "")).id,
|
|
752
|
+
cwd,
|
|
753
|
+
baseHEAD: String(body?.baseHEAD || prev?.baseHEAD || ""),
|
|
754
|
+
groups: Array.isArray(body?.groups) ? body.groups : [],
|
|
755
|
+
excluded: Array.isArray(body?.excluded) ? body.excluded : [],
|
|
756
|
+
createdAt: prev?.createdAt ?? Date.now(),
|
|
757
|
+
updatedAt: Date.now(),
|
|
758
|
+
});
|
|
759
|
+
return json({ ok: true, plan });
|
|
760
|
+
}
|
|
761
|
+
if (route === "git-plan-group" && req.method === "POST") {
|
|
762
|
+
// 启发式分组(两级的第一级):目录/类型/状态规则 + 偏好(dirKey 共现合并 / 剔除建议)
|
|
763
|
+
const body = await req.json().catch(() => ({}));
|
|
764
|
+
const stateDir = (ctx?.stateDir as string) || ((ctx?.configDir as string) ? join(ctx.configDir, "state") : "");
|
|
765
|
+
const files = Array.isArray(body?.files) ? body.files : [];
|
|
766
|
+
return json({ ok: true, aiRefine: !!hostSvc?.get?.("aiti-git-review.aiReview"), ...groupHeuristic(stateDir, files) });
|
|
767
|
+
}
|
|
768
|
+
if (route === "git-plan-ai-refine" && req.method === "POST") {
|
|
769
|
+
// AI 精修(git-ops M4 补遗):中继 git-review aiReview 服务(spawn 会话 + settle 事件取回答)。
|
|
770
|
+
// 服务缺席 → 503(client 按钮隐藏/降级为启发式结果)。
|
|
771
|
+
const body = await req.json().catch(() => ({}));
|
|
772
|
+
const svc = hostSvc?.get?.("aiti-git-review.aiReview") as
|
|
773
|
+
| { groupReview?: (o: Record<string, unknown>) => Promise<unknown> }
|
|
774
|
+
| undefined;
|
|
775
|
+
if (!svc?.groupReview) return json({ ok: false, error: "aiti-git-review.aiReview 不可用" }, 503);
|
|
776
|
+
try {
|
|
777
|
+
const result = await svc.groupReview({
|
|
778
|
+
origin: String(body?.origin || ""),
|
|
779
|
+
basePath: String(body?.basePath || ""),
|
|
780
|
+
cwd: String(body?.cwd || ""),
|
|
781
|
+
groups: Array.isArray(body?.groups) ? body.groups : [],
|
|
782
|
+
excluded: Array.isArray(body?.excluded) ? body.excluded : [],
|
|
783
|
+
});
|
|
784
|
+
return json({ ok: true, result });
|
|
785
|
+
} catch (e) {
|
|
786
|
+
return json({ ok: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (route === "git-commit-ai-avail" && req.method === "POST") {
|
|
790
|
+
// AI 生成提交信息可用性探测(docs/aiti-folder-commit-ai-message-plan.md):服务版本旧(无 commitMessage)→ 按钮隐藏。
|
|
791
|
+
const svc = hostSvc?.get?.("aiti-git-review.aiReview") as { commitMessage?: unknown } | undefined;
|
|
792
|
+
return json({ ok: true, avail: typeof svc?.commitMessage === "function" });
|
|
793
|
+
}
|
|
794
|
+
if (route === "git-commit-ai-message" && req.method === "POST") {
|
|
795
|
+
// AI 生成提交信息:中继 git-review aiReview.commitMessage(服务端只读收集 AGENTS.md + diff 上下文);
|
|
796
|
+
// 服务缺席 503(client 按钮隐藏)、服务抛错 502(错误透传 UI)。
|
|
797
|
+
const body = await req.json().catch(() => ({}));
|
|
798
|
+
const svc = hostSvc?.get?.("aiti-git-review.aiReview") as
|
|
799
|
+
| { commitMessage?: (o: Record<string, unknown>) => Promise<{ message?: string }> }
|
|
800
|
+
| undefined;
|
|
801
|
+
if (typeof svc?.commitMessage !== "function") return json({ ok: false, error: "aiti-git-review.aiReview.commitMessage 不可用" }, 503);
|
|
802
|
+
try {
|
|
803
|
+
const result = await svc.commitMessage({
|
|
804
|
+
origin: String(body?.origin || ""),
|
|
805
|
+
basePath: String(body?.basePath || ""),
|
|
806
|
+
cwd: String(body?.cwd || ""),
|
|
807
|
+
files: Array.isArray(body?.files) ? body.files.map((f: unknown) => String(f)) : [],
|
|
808
|
+
amend: !!body?.amend,
|
|
809
|
+
});
|
|
810
|
+
return json({ ok: true, message: String(result?.message || "") });
|
|
811
|
+
} catch (e) {
|
|
812
|
+
return json({ ok: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (route === "git-plan-learn" && req.method === "POST") {
|
|
816
|
+
const body = await req.json().catch(() => ({}));
|
|
817
|
+
const action = body?.action === "exclude" ? "exclude" : body?.action === "move" ? "move" : null;
|
|
818
|
+
if (!action) return json({ ok: false, error: "action required" }, 400);
|
|
819
|
+
const stateDir = (ctx?.stateDir as string) || ((ctx?.configDir as string) ? join(ctx.configDir, "state") : "");
|
|
820
|
+
learn(stateDir, action, { file: body?.file, toKey: body?.toKey, path: body?.path });
|
|
821
|
+
return json({ ok: true });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return json({ ok: false, error: "not found" }, 404);
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
};
|