dsh-projects-panel 0.1.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/README.md +96 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.d.ts +462 -0
- package/lib/client.js +7288 -0
- package/lib/index.d.ts +464 -0
- package/lib/index.js +1025 -0
- package/package.json +77 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
|
|
8
|
+
//#region src/host-tree.ts
|
|
9
|
+
/** 项目目录集合的子目录名(数据根之下;projects.json 位于数据根)。 */
|
|
10
|
+
const PROJECTS_DIRECTORY = "projects";
|
|
11
|
+
/** 项目目录集合的磁盘路径:数据根下 projects/ 子目录(不创建)。 */
|
|
12
|
+
function projectStoreRoot(root) {
|
|
13
|
+
return join(root, PROJECTS_DIRECTORY);
|
|
14
|
+
}
|
|
15
|
+
/** 新建一棵空的项目树:unit 标记沿用 Cordis 数据文件约定。 */
|
|
16
|
+
const emptyTree = () => ({
|
|
17
|
+
unit: {
|
|
18
|
+
name: "project",
|
|
19
|
+
version: 1
|
|
20
|
+
},
|
|
21
|
+
global: { initialized: true },
|
|
22
|
+
projectIds: [],
|
|
23
|
+
archivedProjectIds: [],
|
|
24
|
+
projects: {}
|
|
25
|
+
});
|
|
26
|
+
/** 运行时对象判定:排除 null 与数组,用作外部 JSON 数据的收窄基元。 */
|
|
27
|
+
const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
/**
|
|
29
|
+
* 校验项目名。
|
|
30
|
+
*
|
|
31
|
+
* 项目名仅做展示用(目录名 = 项目 ID),因此放宽为:非空、不含路径
|
|
32
|
+
* 分隔符(/ \)、不含控制字符、长度不超过 255。
|
|
33
|
+
*/
|
|
34
|
+
function validateProjectName(name) {
|
|
35
|
+
if (name.length === 0) throw new Error("项目名称不能为空");
|
|
36
|
+
if (name.length > 255) throw new Error("项目名称不能超过 255 个字符");
|
|
37
|
+
if (/[\\/]/.test(name)) throw new Error("项目名称不能包含路径分隔符");
|
|
38
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) throw new Error("项目名称不能包含控制字符");
|
|
39
|
+
}
|
|
40
|
+
/** UUID v4 格式校验(连字符分组,不强制版本位)。 */
|
|
41
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
42
|
+
/** 校验项目 id 是否为合法 UUID 格式。 */
|
|
43
|
+
function validateProjectId(id) {
|
|
44
|
+
if (!UUID_RE.test(id)) throw new Error(`项目 id 不是合法 UUID: ${id}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 递归校验单个项目节点。
|
|
48
|
+
*
|
|
49
|
+
* @param all 已见过的项目 id,用于检出跨层重复引用
|
|
50
|
+
* @param parent 当前节点的父 id,用于检出自环
|
|
51
|
+
*/
|
|
52
|
+
function validateNode(node, all, parent) {
|
|
53
|
+
if (!isRecord(node)) throw new Error("项目节点必须是对象");
|
|
54
|
+
for (const key of [
|
|
55
|
+
"projectIds",
|
|
56
|
+
"workspaceIds",
|
|
57
|
+
"archivedProjectIds"
|
|
58
|
+
]) if (!Array.isArray(node[key]) || node[key].some((v) => typeof v !== "string")) throw new Error(`字段 ${key} 无效`);
|
|
59
|
+
if (!isRecord(node.projects) || !isRecord(node.workspaces) || typeof node.name !== "string") throw new Error("项目节点结构无效");
|
|
60
|
+
for (const id of node.projectIds) {
|
|
61
|
+
if (id === parent || all.has(id)) throw new Error("项目树存在重复引用或循环");
|
|
62
|
+
if (!node.projects[id]) throw new Error(`未知项目引用: ${id}`);
|
|
63
|
+
all.add(id);
|
|
64
|
+
validateNode(node.projects[id], all, id);
|
|
65
|
+
}
|
|
66
|
+
for (const id of node.archivedProjectIds) if (node.projects[id]) throw new Error(`归档项目仍在活动树中: ${id}`);
|
|
67
|
+
for (const id of node.workspaceIds) if (!node.workspaces[id]) throw new Error(`未知工作区引用: ${id}`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 校验整棵项目树的根结构:顶层 id 不得重复、必须可解析,
|
|
71
|
+
* 且活动与归档两个集合不允许出现同一项目。
|
|
72
|
+
*/
|
|
73
|
+
function validateProjectTree(tree) {
|
|
74
|
+
if (!isRecord(tree) || !Array.isArray(tree.projectIds) || !Array.isArray(tree.archivedProjectIds) || !isRecord(tree.projects)) throw new Error("projects.json 根结构无效");
|
|
75
|
+
const all = /* @__PURE__ */ new Set();
|
|
76
|
+
for (const id of tree.projectIds) {
|
|
77
|
+
if (all.has(id) || !tree.projects[id]) throw new Error(`未知或重复项目引用: ${id}`);
|
|
78
|
+
all.add(id);
|
|
79
|
+
validateNode(tree.projects[id], all, id);
|
|
80
|
+
}
|
|
81
|
+
for (const id of tree.archivedProjectIds) if (all.has(id)) throw new Error(`项目同时处于活动和归档状态: ${id}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 以「临时文件 + fsync + rename」方式原子写入 JSON。
|
|
85
|
+
*
|
|
86
|
+
* rename 在同一文件系统内是原子的,避免读者读到写了一半的文件;
|
|
87
|
+
* 0o600 保证项目数据目录不被他人在磁盘上读取。
|
|
88
|
+
*/
|
|
89
|
+
async function atomicWriteJson(file, value) {
|
|
90
|
+
await mkdir(resolve(file, ".."), { recursive: true });
|
|
91
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
92
|
+
const handle = await open(tmp, "w", 384);
|
|
93
|
+
try {
|
|
94
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
95
|
+
await handle.sync();
|
|
96
|
+
} finally {
|
|
97
|
+
await handle.close();
|
|
98
|
+
}
|
|
99
|
+
await rename(tmp, file);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 读取项目树;projects.json 缺失或损坏时以项目目录集合内容重建。
|
|
103
|
+
*
|
|
104
|
+
* 缺失(ENOENT)或 JSON 损坏(SyntaxError)都视为首次启动:
|
|
105
|
+
* 扫描 root/projects 下 UUID 格式的目录,为每个目录建立一个顶层项目
|
|
106
|
+
* (名称缺省为 id,可由用户后续通过编辑补全)。
|
|
107
|
+
*/
|
|
108
|
+
async function readProjectTree(root) {
|
|
109
|
+
const file = join(root, "projects.json");
|
|
110
|
+
try {
|
|
111
|
+
const tree = JSON.parse(await readFile(file, "utf8"));
|
|
112
|
+
validateProjectTree(tree);
|
|
113
|
+
return tree;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
116
|
+
const store = projectStoreRoot(root);
|
|
117
|
+
await mkdir(store, { recursive: true });
|
|
118
|
+
const tree = emptyTree();
|
|
119
|
+
try {
|
|
120
|
+
for (const entry of await readdir(store, { withFileTypes: true })) if (entry.isDirectory() && UUID_RE.test(entry.name)) {
|
|
121
|
+
const id = entry.name;
|
|
122
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
123
|
+
tree.projectIds.push(id);
|
|
124
|
+
tree.projects[id] = newProject(id, now);
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
await atomicWriteJson(file, tree);
|
|
128
|
+
return tree;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** 校验后原子写入项目树。 */
|
|
132
|
+
async function writeProjectTree(root, tree) {
|
|
133
|
+
validateProjectTree(tree);
|
|
134
|
+
await atomicWriteJson(join(root, "projects.json"), tree);
|
|
135
|
+
}
|
|
136
|
+
/** 构造一个新项目节点;默认时间戳为当前时间。 */
|
|
137
|
+
function newProject(name, now = (/* @__PURE__ */ new Date()).toISOString(), description = "", link = "") {
|
|
138
|
+
validateProjectName(name);
|
|
139
|
+
return {
|
|
140
|
+
name,
|
|
141
|
+
description,
|
|
142
|
+
link,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
projectIds: [],
|
|
146
|
+
workspaceIds: [],
|
|
147
|
+
archivedProjectIds: [],
|
|
148
|
+
projects: {},
|
|
149
|
+
workspaces: {}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** 项目对应的磁盘目录路径:root/projects/<id>(不创建目录)。 */
|
|
153
|
+
function projectDirectory(root, id) {
|
|
154
|
+
validateProjectId(id);
|
|
155
|
+
return join(projectStoreRoot(root), id);
|
|
156
|
+
}
|
|
157
|
+
/** link 必须是宿主已有的绝对路径,拒绝相对路径与空值。 */
|
|
158
|
+
function isSafeLinkPath(link) {
|
|
159
|
+
return link.length > 0 && resolve(link) === link;
|
|
160
|
+
}
|
|
161
|
+
/** 在活动树内按 id 查找项目节点(跨层深度优先)。 */
|
|
162
|
+
function findActiveNode(nodes, id) {
|
|
163
|
+
const direct = nodes[id];
|
|
164
|
+
if (direct) return direct;
|
|
165
|
+
for (const node of Object.values(nodes)) {
|
|
166
|
+
const found = findActiveNode(node.projects, id);
|
|
167
|
+
if (found) return found;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** 在活动树内按 id 查找项目容器与其节点(跨层深度优先)。 */
|
|
171
|
+
function findProjectHolder(tree, id) {
|
|
172
|
+
const walk = (holder, parentNode) => {
|
|
173
|
+
const direct = holder.projects[id];
|
|
174
|
+
if (direct) return {
|
|
175
|
+
holder,
|
|
176
|
+
node: direct,
|
|
177
|
+
parentNode
|
|
178
|
+
};
|
|
179
|
+
for (const [key, node] of Object.entries(holder.projects)) {
|
|
180
|
+
const found = walk(node, node);
|
|
181
|
+
if (found) return found;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return walk(tree);
|
|
185
|
+
}
|
|
186
|
+
/** 收集某项目子树内的全部后代项目 id(含嵌套)。 */
|
|
187
|
+
function collectDescendantIds(node) {
|
|
188
|
+
const result = /* @__PURE__ */ new Set();
|
|
189
|
+
const walk = (current) => {
|
|
190
|
+
for (const [id, child] of Object.entries(current.projects)) {
|
|
191
|
+
result.add(id);
|
|
192
|
+
walk(child);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(node);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
/** 收集整棵活动树声明的工作区 id(含嵌套子项目)。 */
|
|
199
|
+
function collectWorkspaceIds(tree) {
|
|
200
|
+
const result = [];
|
|
201
|
+
const walk = (nodes) => {
|
|
202
|
+
for (const node of Object.values(nodes)) {
|
|
203
|
+
result.push(...node.workspaceIds);
|
|
204
|
+
walk(node.projects);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
walk(tree.projects);
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 断言整树改写未改变任何项目的父归属(换父一律走 moveProject)。
|
|
212
|
+
*
|
|
213
|
+
* 比较口径:每个项目 id 的父容器 id(顶层为空串);新增项目允许
|
|
214
|
+
* (设置页新建经 project 路由,此处仅防换父脱节),删除项目允许
|
|
215
|
+
* (归档经 project 路由)。
|
|
216
|
+
*/
|
|
217
|
+
function assertNoParentChange(oldTree, nextTree) {
|
|
218
|
+
const parentsOf = (tree) => {
|
|
219
|
+
const result = /* @__PURE__ */ new Map();
|
|
220
|
+
const walk = (nodes, parentId) => {
|
|
221
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
222
|
+
result.set(id, parentId);
|
|
223
|
+
walk(node.projects, id);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
for (const id of tree.projectIds) {
|
|
227
|
+
result.set(id, "");
|
|
228
|
+
const node = tree.projects[id];
|
|
229
|
+
if (node) walk(node.projects, id);
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
};
|
|
233
|
+
const oldParents = parentsOf(oldTree);
|
|
234
|
+
const nextParents = parentsOf(nextTree);
|
|
235
|
+
for (const [id, nextParent] of nextParents) {
|
|
236
|
+
const oldParent = oldParents.get(id);
|
|
237
|
+
if (oldParent !== void 0 && oldParent !== nextParent) throw new Error("换父请使用项目移动路由");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* 断言工作区归属在全树唯一(同一工作区至多出现一次)。
|
|
242
|
+
*
|
|
243
|
+
* 仅供整树替换(POST /projects-panel/tree)写边界兜底;日常挂载
|
|
244
|
+
* 走先摘除再挂载已天然保证唯一,不经此断言。
|
|
245
|
+
*/
|
|
246
|
+
function assertWorkspaceUnique(tree) {
|
|
247
|
+
const seen = /* @__PURE__ */ new Set();
|
|
248
|
+
for (const id of collectWorkspaceIds(tree)) {
|
|
249
|
+
if (seen.has(id)) throw new Error(`工作区重复归属: ${id}`);
|
|
250
|
+
seen.add(id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 移动项目归属与排序(UUID 身份不变)。
|
|
255
|
+
*
|
|
256
|
+
* 项目目录 = projects/<id>/,与名称/归属解耦,因此移动只改树,不碰目录。
|
|
257
|
+
* 同容器纯排序只改顺序;换父只改父子引用。移动到自身或后代下、
|
|
258
|
+
* 锚点不存在或锚点已归档时拒绝且不落盘;同容器内锚点指向源自身
|
|
259
|
+
* 视为无排序变化,同样不落盘。
|
|
260
|
+
*/
|
|
261
|
+
async function moveProject(root, sourceId, parentId, beforeId) {
|
|
262
|
+
const tree = await readProjectTree(root);
|
|
263
|
+
const found = findProjectHolder(tree, sourceId);
|
|
264
|
+
if (!found) throw new Error("项目不存在");
|
|
265
|
+
const { holder: oldHolder, node } = found;
|
|
266
|
+
let targetHolder;
|
|
267
|
+
if (parentId === void 0) targetHolder = tree;
|
|
268
|
+
else {
|
|
269
|
+
const target = findActiveNode(tree.projects, parentId);
|
|
270
|
+
if (!target) throw new Error("父项目不存在或已归档");
|
|
271
|
+
if (parentId === sourceId || collectDescendantIds(node).has(parentId)) throw new Error("不能移动到自身或子项目下");
|
|
272
|
+
targetHolder = target;
|
|
273
|
+
}
|
|
274
|
+
if (beforeId !== void 0 && beforeId === sourceId && oldHolder === targetHolder) return;
|
|
275
|
+
const movedAcross = oldHolder !== targetHolder;
|
|
276
|
+
const anchor = movedAcross && beforeId === sourceId ? void 0 : beforeId;
|
|
277
|
+
const isActiveAnchor = (holder, anchorId) => {
|
|
278
|
+
if (!holder.projectIds.includes(anchorId)) return false;
|
|
279
|
+
return findActiveNode(tree.projects, anchorId) !== void 0;
|
|
280
|
+
};
|
|
281
|
+
if (anchor !== void 0 && !isActiveAnchor(targetHolder, anchor)) throw new Error("排序锚点不存在或已归档");
|
|
282
|
+
if (!movedAcross) {
|
|
283
|
+
const oldIndex = oldHolder.projectIds.indexOf(sourceId);
|
|
284
|
+
if (oldIndex >= 0) oldHolder.projectIds.splice(oldIndex, 1);
|
|
285
|
+
if (anchor === void 0) targetHolder.projectIds.push(sourceId);
|
|
286
|
+
else targetHolder.projectIds.splice(targetHolder.projectIds.indexOf(anchor), 0, sourceId);
|
|
287
|
+
node.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
288
|
+
await writeProjectTree(root, tree);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (Object.entries(targetHolder.projects).some(([id, other]) => id !== sourceId && other.name === node.name)) throw new Error("目标项目下已存在同名项目");
|
|
292
|
+
applyProjectMove(tree, oldHolder, targetHolder, sourceId, node, anchor);
|
|
293
|
+
node.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
294
|
+
await writeProjectTree(root, tree);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 在内存树上执行换父(摘除源容器引用并按锚插入目标容器)。
|
|
298
|
+
*
|
|
299
|
+
* @param anchor 排序锚(省略=追加目标末尾)。
|
|
300
|
+
*/
|
|
301
|
+
function applyProjectMove(tree, oldHolder, targetHolder, sourceId, node, anchor) {
|
|
302
|
+
const oldIndex = oldHolder.projectIds.indexOf(sourceId);
|
|
303
|
+
if (oldIndex >= 0) oldHolder.projectIds.splice(oldIndex, 1);
|
|
304
|
+
delete oldHolder.projects[sourceId];
|
|
305
|
+
targetHolder.projects[sourceId] = node;
|
|
306
|
+
if (anchor === void 0) targetHolder.projectIds.push(sourceId);
|
|
307
|
+
else targetHolder.projectIds.splice(targetHolder.projectIds.indexOf(anchor), 0, sourceId);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* 从全部活动节点摘除某工作区引用(含 joinedAt 记录)。
|
|
311
|
+
*
|
|
312
|
+
* @returns 最后一次摘除到的成员关系记录(用于重新挂载时保留加入时间);
|
|
313
|
+
* 从未挂载过返回 undefined。
|
|
314
|
+
*/
|
|
315
|
+
function detachWorkspaceRef(holder, id) {
|
|
316
|
+
let meta;
|
|
317
|
+
for (const node of Object.values(holder.projects)) {
|
|
318
|
+
const index = node.workspaceIds.indexOf(id);
|
|
319
|
+
if (index >= 0) {
|
|
320
|
+
node.workspaceIds.splice(index, 1);
|
|
321
|
+
meta = node.workspaces[id];
|
|
322
|
+
delete node.workspaces[id];
|
|
323
|
+
}
|
|
324
|
+
const child = detachWorkspaceRef(node, id);
|
|
325
|
+
if (child) meta = child;
|
|
326
|
+
}
|
|
327
|
+
return meta;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* 挂载工作区到指定项目下(新增成员关系或移动归属)。
|
|
331
|
+
*
|
|
332
|
+
* 无论目标如何,都会先把该工作区从原有位置摘除再挂到目标项目,
|
|
333
|
+
* 保证同一工作区在整棵活动树中至多出现一次;加入时间保留原记录。
|
|
334
|
+
* parentId 省略时视为「移到顶层」——顶层不记录成员关系,因此等价于
|
|
335
|
+
* 仅摘除(工作区随后以悬浮节点显示,此时 beforeId 不允许)。
|
|
336
|
+
* beforeId 省略表示追加到目标末尾,否则插入到锚点之前;锚点等于
|
|
337
|
+
* 工作区自身或锚点已归属异常时视为无操作或拒绝(锚点须为目标现成员)。
|
|
338
|
+
*/
|
|
339
|
+
async function attachWorkspace(root, workspaceId, parentId, beforeId) {
|
|
340
|
+
const tree = await readProjectTree(root);
|
|
341
|
+
const meta = detachWorkspaceRef(tree, workspaceId);
|
|
342
|
+
if (parentId === void 0) {
|
|
343
|
+
if (beforeId !== void 0) throw new Error("顶层排序请使用官方工作区排序");
|
|
344
|
+
await writeProjectTree(root, tree);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const target = findActiveNode(tree.projects, parentId);
|
|
348
|
+
if (!target) throw new Error("父项目不存在");
|
|
349
|
+
if (beforeId !== void 0 && beforeId === workspaceId) return;
|
|
350
|
+
if (beforeId !== void 0 && !target.workspaceIds.includes(beforeId)) throw new Error("排序锚点不存在");
|
|
351
|
+
if (beforeId === void 0) target.workspaceIds.push(workspaceId);
|
|
352
|
+
else target.workspaceIds.splice(target.workspaceIds.indexOf(beforeId), 0, workspaceId);
|
|
353
|
+
target.workspaces[workspaceId] = meta ?? { joinedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
354
|
+
await writeProjectTree(root, tree);
|
|
355
|
+
}
|
|
356
|
+
/** 从活动树摘除某工作区的全部成员关系(官方工作区删除后的清理)。 */
|
|
357
|
+
async function detachWorkspace(root, workspaceId) {
|
|
358
|
+
const tree = await readProjectTree(root);
|
|
359
|
+
detachWorkspaceRef(tree, workspaceId);
|
|
360
|
+
await writeProjectTree(root, tree);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/host-project.ts
|
|
365
|
+
/** 按 id 深度查找节点,返回节点与其直接父(顶层项目无父)。 */
|
|
366
|
+
function findNode(tree, id) {
|
|
367
|
+
const walk = (nodes, path, parent) => {
|
|
368
|
+
for (const [key, node] of Object.entries(nodes)) {
|
|
369
|
+
const nextPath = [...path, key];
|
|
370
|
+
if (key === id) return {
|
|
371
|
+
node,
|
|
372
|
+
parent,
|
|
373
|
+
path: nextPath
|
|
374
|
+
};
|
|
375
|
+
const found = walk(node.projects, nextPath, node);
|
|
376
|
+
if (found) return found;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
return walk(tree.projects, []);
|
|
380
|
+
}
|
|
381
|
+
/** 从 id 列表移除一项(幂等:不存在时无操作)。 */
|
|
382
|
+
function removeId(list, id) {
|
|
383
|
+
const i = list.indexOf(id);
|
|
384
|
+
if (i >= 0) list.splice(i, 1);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 创建项目:建磁盘目录 + 挂入项目树。
|
|
388
|
+
*
|
|
389
|
+
* 目录名 = 项目 ID(UUID),与名称解耦。目录创建成功而树写入失败时,
|
|
390
|
+
* 残留一个孤儿目录,下次启动会通过 readProjectTree 的扫描恢复逻辑被重新挂载。
|
|
391
|
+
*/
|
|
392
|
+
async function createProject(root, input) {
|
|
393
|
+
const tree = await readProjectTree(root);
|
|
394
|
+
const parentFound = input.parentId ? findNode(tree, input.parentId) : void 0;
|
|
395
|
+
const parent = parentFound?.node;
|
|
396
|
+
if (input.parentId && !parentFound) throw new Error("父项目不存在");
|
|
397
|
+
if (input.link && !isSafeLinkPath(input.link)) throw new Error("link 必须是绝对路径");
|
|
398
|
+
const holder = parent ?? tree;
|
|
399
|
+
if (Object.values(holder.projects).some((p) => p.name === input.name)) throw new Error("项目名称已存在");
|
|
400
|
+
const id = randomUUID();
|
|
401
|
+
const node = newProject(input.name, void 0, input.description ?? "", input.link ?? "");
|
|
402
|
+
await mkdir(projectStoreRoot(root), { recursive: true });
|
|
403
|
+
await mkdir(projectDirectory(root, id), { recursive: false });
|
|
404
|
+
holder.projects[id] = node;
|
|
405
|
+
holder.projectIds.push(id);
|
|
406
|
+
await writeProjectTree(root, tree);
|
|
407
|
+
return id;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* 更新项目:可改名、改描述、改 link。
|
|
411
|
+
*
|
|
412
|
+
* 改名只更新 projects.json 的 name 字段,不碰磁盘目录(目录名 = ID,
|
|
413
|
+
* 与名称解耦)。重名检查在父级(顶层为树根)内进行。
|
|
414
|
+
*/
|
|
415
|
+
async function updateProject(root, id, patch) {
|
|
416
|
+
const tree = await readProjectTree(root);
|
|
417
|
+
const found = findNode(tree, id);
|
|
418
|
+
if (!found) throw new Error("项目不存在");
|
|
419
|
+
const node = found.node;
|
|
420
|
+
if (patch.link !== void 0 && patch.link && !isSafeLinkPath(patch.link)) throw new Error("link 必须是绝对路径");
|
|
421
|
+
if (patch.name && patch.name !== node.name) {
|
|
422
|
+
if ((found.parent ? Object.values(found.parent.projects) : Object.values(tree.projects)).some((other) => other !== node && other.name === patch.name)) throw new Error("项目名称已存在");
|
|
423
|
+
node.name = patch.name;
|
|
424
|
+
}
|
|
425
|
+
if (patch.description !== void 0) node.description = patch.description;
|
|
426
|
+
if (patch.link !== void 0) node.link = patch.link;
|
|
427
|
+
node.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
428
|
+
await writeProjectTree(root, tree);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* 归档项目:移出活动树(加入 archivedProjectIds),不碰磁盘目录。
|
|
432
|
+
*
|
|
433
|
+
* 目录名 = ID,与归档状态解耦,恢复零成本,数据零丢失。
|
|
434
|
+
*/
|
|
435
|
+
async function archiveProject(root, id) {
|
|
436
|
+
const tree = await readProjectTree(root);
|
|
437
|
+
const found = findNode(tree, id);
|
|
438
|
+
if (!found) throw new Error("项目不存在");
|
|
439
|
+
const { node, parent } = found;
|
|
440
|
+
const holder = parent ?? tree;
|
|
441
|
+
removeId(holder.projectIds, id);
|
|
442
|
+
holder.archivedProjectIds.push(id);
|
|
443
|
+
if (parent) delete parent.projects[id];
|
|
444
|
+
else delete tree.projects[id];
|
|
445
|
+
node.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
446
|
+
await writeProjectTree(root, tree);
|
|
447
|
+
}
|
|
448
|
+
/** link 文件白名单:只允许宿主项目根下的少数约定文件,拒绝路径穿越。 */
|
|
449
|
+
const LINKED_FILES = [
|
|
450
|
+
"AGENTS.md",
|
|
451
|
+
".mcp.json",
|
|
452
|
+
".env"
|
|
453
|
+
];
|
|
454
|
+
/**
|
|
455
|
+
* 读取 link 指向目录内的约定文件(如 AGENTS.md 供侧栏摘要展示)。
|
|
456
|
+
*
|
|
457
|
+
* @returns 文件不存在时返回 undefined;相对路径、绝对子路径或白名单外的
|
|
458
|
+
* 文件名一律抛错,防止把任意宿主文件暴露给客户端。
|
|
459
|
+
*/
|
|
460
|
+
async function readLinkFile(link, relative) {
|
|
461
|
+
if (!isSafeLinkPath(link) || relative.includes("..") || relative.startsWith("/") || !LINKED_FILES.includes(relative)) throw new Error("不允许读取该 link 文件");
|
|
462
|
+
try {
|
|
463
|
+
return await readFile(join(link, relative), "utf8");
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (error.code === "ENOENT") return void 0;
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/host-settings.ts
|
|
472
|
+
/** settings.json 缺失字段时的默认值(旧路由兼容)。 */
|
|
473
|
+
const defaultSettings = {
|
|
474
|
+
version: 1,
|
|
475
|
+
previewUrl: "/preview.html",
|
|
476
|
+
defaultProject: null,
|
|
477
|
+
tree: {
|
|
478
|
+
expandCurrent: true,
|
|
479
|
+
filter: null
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
/**
|
|
483
|
+
* 读取插件设置(旧路由兼容):与默认值做浅合并。
|
|
484
|
+
*/
|
|
485
|
+
async function readSettings(root) {
|
|
486
|
+
try {
|
|
487
|
+
const parsed = JSON.parse(await readFile(join(root, "settings.json"), "utf8"));
|
|
488
|
+
return {
|
|
489
|
+
...defaultSettings,
|
|
490
|
+
...parsed,
|
|
491
|
+
tree: {
|
|
492
|
+
...defaultSettings.tree,
|
|
493
|
+
...parsed.tree
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
498
|
+
return { ...defaultSettings };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** 写入设置(旧路由兼容):version 固定为当前结构版本 1。 */
|
|
502
|
+
async function writeSettings(root, settings) {
|
|
503
|
+
await atomicWriteJson(join(root, "settings.json"), {
|
|
504
|
+
...settings,
|
|
505
|
+
version: 1
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/** settings 命名空间名称。 */
|
|
509
|
+
const SETTINGS_NAMESPACE = "projects-panel";
|
|
510
|
+
/** projects-panel 命名空间 schema(schemastery)。 */
|
|
511
|
+
const PROJECTS_PANEL_SETTINGS_SCHEMA = z.object({
|
|
512
|
+
workspaces: z.object({ enabled: z.boolean() }),
|
|
513
|
+
notifications: z.object({ enabled: z.boolean() }),
|
|
514
|
+
brand: z.object({
|
|
515
|
+
mark: z.string(),
|
|
516
|
+
name: z.string()
|
|
517
|
+
}),
|
|
518
|
+
previewUrl: z.string(),
|
|
519
|
+
defaultProject: z.any(),
|
|
520
|
+
tree: z.object({ expandCurrent: z.boolean() })
|
|
521
|
+
});
|
|
522
|
+
/** base 默认层(schema 校验后与用户层合并)。 */
|
|
523
|
+
const PROJECTS_PANEL_SETTINGS_BASE = {
|
|
524
|
+
workspaces: { enabled: true },
|
|
525
|
+
notifications: { enabled: true },
|
|
526
|
+
brand: {
|
|
527
|
+
mark: "",
|
|
528
|
+
name: ""
|
|
529
|
+
},
|
|
530
|
+
previewUrl: "/preview.html",
|
|
531
|
+
defaultProject: null,
|
|
532
|
+
tree: { expandCurrent: true }
|
|
533
|
+
};
|
|
534
|
+
/** 注册 projects-panel 设置命名空间到宿主(fail-open:settings 服务缺失时不抛)。 */
|
|
535
|
+
function registerSettingsNamespace(ctx) {
|
|
536
|
+
ctx.settings?.register(SETTINGS_NAMESPACE, PROJECTS_PANEL_SETTINGS_SCHEMA, { base: PROJECTS_PANEL_SETTINGS_BASE });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region src/core/workspace.ts
|
|
541
|
+
/**
|
|
542
|
+
* 解析工作区的稳定标识。
|
|
543
|
+
*
|
|
544
|
+
* 宿主注入的 WorkspaceView 可能带显式 workspaceId;没有时退化为 view 自身的 id。
|
|
545
|
+
* 树推导、筛选与展开状态都以该返回值作为统一键。
|
|
546
|
+
*/
|
|
547
|
+
function workspaceIdOf(workspace) {
|
|
548
|
+
return workspace.workspaceId ?? workspace.id ?? "";
|
|
549
|
+
}
|
|
550
|
+
/** Windows 风格路径判定:盘符或 UNC 前缀(官方 util/workspace-path 同源)。 */
|
|
551
|
+
function isWindowsStylePath(value) {
|
|
552
|
+
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\") || value.startsWith("//");
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* 把主目录下路径缩写为 `~` 形式(hover 卡展示用,与官方
|
|
556
|
+
* abbreviateHomePath 语义一致):home 缺省/空、非 POSIX 路径、
|
|
557
|
+
* home 为根或不在该 home 下时原样返回。
|
|
558
|
+
* @param path 绝对路径。
|
|
559
|
+
* @param home 宿主主目录;缺省或空表示无法缩写。
|
|
560
|
+
* @returns 缩写后的展示路径。
|
|
561
|
+
*/
|
|
562
|
+
function abbreviateHomePath(path, home) {
|
|
563
|
+
if (home === void 0 || home === "") return path;
|
|
564
|
+
if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path;
|
|
565
|
+
const root = home.replace(/\/+$/, "");
|
|
566
|
+
if (root === "" || root === "/") return path;
|
|
567
|
+
if (path.replace(/\/+$/, "") === root) return "~";
|
|
568
|
+
if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}`;
|
|
569
|
+
return path;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/index.ts
|
|
574
|
+
/** 数据根目录名(位于 DSH_HOME 之下,缺省 ~/.dsh)。 */
|
|
575
|
+
const DATA_ROOT_DIRECTORY = "dsh-projects-panel";
|
|
576
|
+
/** 健康检查路径:供网关探活。 */
|
|
577
|
+
const HEALTH_PATH = "/projects-panel/health";
|
|
578
|
+
/** 插件版本:与 package.json version 保持同步;打包期无法注入动态版本。 */
|
|
579
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
580
|
+
/** DSH CLI 自身清单:`dsh --version` 读取的同一文件。 */
|
|
581
|
+
const DSH_MANIFEST_ID = "@deepseek-ai/dsh/package.json";
|
|
582
|
+
/** 解析锚点取本模块:插件安装位置决定向上查找的 node_modules 链。 */
|
|
583
|
+
const moduleRequire = createRequire(import.meta.url);
|
|
584
|
+
/**
|
|
585
|
+
* 解析用户主目录缩写:~ 展开为 homedir()。
|
|
586
|
+
*
|
|
587
|
+
* 仅识别单独的 "~" 或 "~/..." 形式;其它位置出现的 "~" 视为普通字符。
|
|
588
|
+
*/
|
|
589
|
+
function expandTilde(value) {
|
|
590
|
+
if (value === "~") return homedir();
|
|
591
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
592
|
+
if (value.startsWith("~\\")) return join(homedir(), value.slice(2));
|
|
593
|
+
return value;
|
|
594
|
+
}
|
|
595
|
+
/** 反射软取会话查询服务:未装配(旧宿主/测试)时返回 undefined,不抛错。 */
|
|
596
|
+
function sessionQueryOf(ctx) {
|
|
597
|
+
if (typeof ctx.get !== "function") return void 0;
|
|
598
|
+
const candidate = ctx.get("sessionQuery");
|
|
599
|
+
if (typeof candidate !== "object" || candidate === null) return void 0;
|
|
600
|
+
return typeof candidate.readTitleSnapshots === "function" ? candidate : void 0;
|
|
601
|
+
}
|
|
602
|
+
const inject = [
|
|
603
|
+
"webServer",
|
|
604
|
+
"workspaceRegistry",
|
|
605
|
+
"sessionController",
|
|
606
|
+
"settings"
|
|
607
|
+
];
|
|
608
|
+
/**
|
|
609
|
+
* 计算项目数据根目录。
|
|
610
|
+
*
|
|
611
|
+
* DSH_PROJECTS_ROOT_DIR 设置时直接采用(支持 "~" 缩写,展开为用户主目录);
|
|
612
|
+
* 未设置时回落到 DSH_HOME(缺省 ~/.dsh)下的 dsh-projects-panel 目录。
|
|
613
|
+
*
|
|
614
|
+
* @param home 宿主数据根,测试时可注入临时目录
|
|
615
|
+
*/
|
|
616
|
+
function projectsRoot(home = process.env.DSH_HOME || join(homedir(), ".dsh")) {
|
|
617
|
+
const override = process.env.DSH_PROJECTS_ROOT_DIR;
|
|
618
|
+
if (override && override.trim()) return resolve(expandTilde(override.trim()));
|
|
619
|
+
return join(home, DATA_ROOT_DIRECTORY);
|
|
620
|
+
}
|
|
621
|
+
/** 确保数据根与项目目录集合存在,返回数据根路径。 */
|
|
622
|
+
async function ensureProjectsRoot() {
|
|
623
|
+
const root = projectsRoot();
|
|
624
|
+
await mkdir(projectStoreRoot(root), { recursive: true });
|
|
625
|
+
return root;
|
|
626
|
+
}
|
|
627
|
+
/** 以 JSON 形式写出响应。 */
|
|
628
|
+
function json(res, status, value) {
|
|
629
|
+
res.statusCode = status;
|
|
630
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
631
|
+
res.end(JSON.stringify(value));
|
|
632
|
+
}
|
|
633
|
+
/** 统一的路由错误出口:业务错误的中文消息直接透传给客户端展示。 */
|
|
634
|
+
function routeError(res, error) {
|
|
635
|
+
json(res, 400, { error: error instanceof Error ? error.message : "请求失败" });
|
|
636
|
+
}
|
|
637
|
+
/** 读取并解析请求体;要求整体为 JSON 对象,其余字段留待各路由校验。 */
|
|
638
|
+
async function readBodyObject(req) {
|
|
639
|
+
let text = "";
|
|
640
|
+
for await (const chunk of req) text += chunk;
|
|
641
|
+
const parsed = text ? JSON.parse(text) : {};
|
|
642
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("请求体必须是 JSON 对象");
|
|
643
|
+
return parsed;
|
|
644
|
+
}
|
|
645
|
+
/** 取出必填字符串字段;缺失或类型不符时抛出客户端可读的错误。 */
|
|
646
|
+
function requireString(value, field) {
|
|
647
|
+
if (typeof value !== "string") throw new Error(`字段 ${field} 必须是字符串`);
|
|
648
|
+
return value;
|
|
649
|
+
}
|
|
650
|
+
/** 取出可选字符串字段;非字符串一律视为未提供。 */
|
|
651
|
+
function optionalString(value) {
|
|
652
|
+
return typeof value === "string" ? value : void 0;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* settings 为宽容结构:读侧 readSettings 会与默认值合并并恢复缺字段,
|
|
656
|
+
* 因此写侧仅做对象级校验,不逐字段验证类型。
|
|
657
|
+
*/
|
|
658
|
+
function settingsInput(input) {
|
|
659
|
+
return input;
|
|
660
|
+
}
|
|
661
|
+
/** 健康检查:报告插件存活与版本。 */
|
|
662
|
+
function healthHandler(_req, res) {
|
|
663
|
+
json(res, 200, {
|
|
664
|
+
ok: true,
|
|
665
|
+
plugin: "dsh-projects-panel",
|
|
666
|
+
version: PLUGIN_VERSION
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 读取 package.json 的 version 字段。
|
|
671
|
+
* @param manifestPath - package.json 绝对路径。
|
|
672
|
+
* @returns 版本号字符串;文件不可读、JSON 非法或缺 version 字段时返回 undefined。
|
|
673
|
+
*/
|
|
674
|
+
function readPackageVersion(manifestPath) {
|
|
675
|
+
try {
|
|
676
|
+
const pkg = moduleRequire(manifestPath);
|
|
677
|
+
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
678
|
+
} catch {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 解析 DSH 官方版本号,与 `dsh --version` 同源:同读 `@deepseek-ai/dsh/package.json`。
|
|
684
|
+
*
|
|
685
|
+
* DSH 启动时把安装依赖闭包镜像到 `$DSH_HOME/profiles/node_modules`,插件由自身
|
|
686
|
+
* 安装位置向上即可解析到该清单,因此结果与 `dsh --version` 恒等;清单不可解析或
|
|
687
|
+
* 读取失败(非标准安装)时回退插件版本。
|
|
688
|
+
* @param resolveManifest - 模块解析器,默认取本模块的 createRequire。
|
|
689
|
+
* @param readManifest - 清单读取器,默认 readPackageVersion。
|
|
690
|
+
* @returns DSH 版本号;解析失败回退 PLUGIN_VERSION。
|
|
691
|
+
*/
|
|
692
|
+
function readDshVersion(resolveManifest = (id) => moduleRequire.resolve(id), readManifest = readPackageVersion) {
|
|
693
|
+
let manifest;
|
|
694
|
+
try {
|
|
695
|
+
manifest = resolveManifest(DSH_MANIFEST_ID);
|
|
696
|
+
} catch {
|
|
697
|
+
return PLUGIN_VERSION;
|
|
698
|
+
}
|
|
699
|
+
return readManifest(manifest) ?? PLUGIN_VERSION;
|
|
700
|
+
}
|
|
701
|
+
/** DSH 官方版本号缓存:解析结果在进程内不变。 */
|
|
702
|
+
let dshVersionCache;
|
|
703
|
+
function getDshVersion() {
|
|
704
|
+
dshVersionCache ??= readDshVersion();
|
|
705
|
+
return dshVersionCache;
|
|
706
|
+
}
|
|
707
|
+
/** 版本信息路由:返回 DSH 官方版本号。 */
|
|
708
|
+
function versionHandler(_req, res) {
|
|
709
|
+
json(res, 200, {
|
|
710
|
+
ok: true,
|
|
711
|
+
dshVersion: getDshVersion()
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* 聚合官方 workspace 列表为客户端视图。
|
|
716
|
+
*
|
|
717
|
+
* registry.list() 失败(官方 workspace 域不可用)时降级为空列表并记录,
|
|
718
|
+
* 保证项目树仍可读。
|
|
719
|
+
*/
|
|
720
|
+
function workspaceViews(ctx) {
|
|
721
|
+
try {
|
|
722
|
+
return ctx.workspaceRegistry.list().map((workspace) => ({
|
|
723
|
+
workspaceId: workspace.id,
|
|
724
|
+
path: workspace.path,
|
|
725
|
+
title: workspace.title,
|
|
726
|
+
createdAt: workspace.createdAt,
|
|
727
|
+
updatedAt: workspace.updatedAt,
|
|
728
|
+
sessionIds: [...workspace.sessionIds]
|
|
729
|
+
}));
|
|
730
|
+
} catch (error) {
|
|
731
|
+
console.error("[projects-panel] 读取官方工作区列表失败,降级为空列表", error);
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** 从会话摘要的投影缓存中取 title(缓存缺失或非字符串时返回 undefined)。 */
|
|
736
|
+
function titleOf(summary) {
|
|
737
|
+
const title = summary.projections?.values.title;
|
|
738
|
+
return typeof title === "string" && title.length > 0 ? title : void 0;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* 用官方会话查询服务补读缺失标题:对标题缺失的冷会话(含归档,
|
|
742
|
+
* 投影缓存未预热)批量折叠日志中的持久 title,成功者回填。
|
|
743
|
+
*
|
|
744
|
+
* 官方 list 只携带投影缓存中已有的值(fail-soft 缓存,见
|
|
745
|
+
* session-projection-cache),冷会话可能缺标题;而标题的权威持久源
|
|
746
|
+
* 是日志里的 session/title 事件,sessionQuery.readTitleSnapshots 是
|
|
747
|
+
* live 优先的批量冷读(持久会话开只读日志折叠),对归档会话同样有效。
|
|
748
|
+
* 空白会话无标题事件、subagent 标题来自父链,均无需补读。
|
|
749
|
+
* sessionQuery 未装配或整次补读失败时原样返回并记录,不阻塞树读取。
|
|
750
|
+
*
|
|
751
|
+
* @param ctx 宿主上下文(反射读取 sessionQuery)
|
|
752
|
+
* @param views 已按工作区聚合的会话视图
|
|
753
|
+
* @returns 补读后的会话视图(顺序不变)
|
|
754
|
+
*/
|
|
755
|
+
async function backfillSessionTitles(ctx, views) {
|
|
756
|
+
const missingIds = views.filter((view) => !view.blank && !view.subagent && !view.title?.trim().length).map((view) => view.id);
|
|
757
|
+
if (missingIds.length === 0) return [...views];
|
|
758
|
+
const query = sessionQueryOf(ctx);
|
|
759
|
+
if (query === void 0) return [...views];
|
|
760
|
+
try {
|
|
761
|
+
const results = await query.readTitleSnapshots(missingIds);
|
|
762
|
+
const byId = new Map(results.filter((result) => result.status === "fulfilled").map((result) => [result.sessionId, result.value?.title?.title]));
|
|
763
|
+
return views.map((view) => byId.has(view.id) && byId.get(view.id)?.trim().length ? {
|
|
764
|
+
...view,
|
|
765
|
+
title: byId.get(view.id)
|
|
766
|
+
} : view);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.error("[projects-panel] 会话标题权威补读失败,保留投影标题", error);
|
|
769
|
+
return [...views];
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* 聚合官方会话列表为客户端视图(挂在所属 workspace 下)。
|
|
774
|
+
*
|
|
775
|
+
* 输出顺序与官方一致:按 workspace 的 attach 记录(sessionIds 顺序,
|
|
776
|
+
* 新会话前置)逐工作区聚合,保证新建会话显示在工作区列表最上方。
|
|
777
|
+
* 归属与官方分组语义一致:仅采用 workspace 的 attach 记录;未 attach
|
|
778
|
+
* 的会话官方显示在「未分组」,本树暂无未分组容器故不展示。归档标记
|
|
779
|
+
* 来自 registry 全局归档集,subagent 来自摘要 origin。投影标题缺失的
|
|
780
|
+
* 冷会话(含归档)由 sessionQuery 从日志权威补读(见
|
|
781
|
+
* backfillSessionTitles)。
|
|
782
|
+
* list() 失败时降级为空列表并记录,不阻塞树读取。
|
|
783
|
+
*/
|
|
784
|
+
async function sessionViews(ctx) {
|
|
785
|
+
try {
|
|
786
|
+
const workspaces = ctx.workspaceRegistry.list();
|
|
787
|
+
if (workspaces.length === 0) return [];
|
|
788
|
+
const archived = new Set(ctx.workspaceRegistry.archivedSessionIds);
|
|
789
|
+
const result = await ctx.sessionController.list({});
|
|
790
|
+
const bySessionId = new Map(result.items.map((summary) => [summary.sessionId, summary]));
|
|
791
|
+
const views = [];
|
|
792
|
+
for (const workspace of workspaces) for (const sessionId of workspace.sessionIds) {
|
|
793
|
+
const summary = bySessionId.get(sessionId);
|
|
794
|
+
if (!summary) continue;
|
|
795
|
+
views.push({
|
|
796
|
+
id: summary.sessionId,
|
|
797
|
+
workspaceId: workspace.id,
|
|
798
|
+
title: titleOf(summary),
|
|
799
|
+
updatedAt: summary.updatedAt,
|
|
800
|
+
running: summary.running,
|
|
801
|
+
blank: summary.blank,
|
|
802
|
+
subagent: summary.origin === "subagent",
|
|
803
|
+
archived: archived.has(summary.sessionId)
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return await backfillSessionTitles(ctx, views);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
console.error("[projects-panel] 读取官方会话列表失败,降级为空列表", error);
|
|
809
|
+
return [];
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** /projects-panel/tree:GET 返回完整树,POST 整树改写(仅排序与字段,不允许换父),其余 405。 */
|
|
813
|
+
function registerTreeRoute(ctx, root) {
|
|
814
|
+
ctx.webServer.register({
|
|
815
|
+
kind: "exact",
|
|
816
|
+
path: "/projects-panel/tree",
|
|
817
|
+
handler: async (req, res) => {
|
|
818
|
+
try {
|
|
819
|
+
if (req.method === "GET") {
|
|
820
|
+
json(res, 200, {
|
|
821
|
+
tree: await readProjectTree(root),
|
|
822
|
+
workspaces: workspaceViews(ctx),
|
|
823
|
+
sessions: await sessionViews(ctx),
|
|
824
|
+
archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds],
|
|
825
|
+
home: homedir()
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (req.method === "POST") {
|
|
830
|
+
const input = await readBodyObject(req);
|
|
831
|
+
validateProjectTree(input);
|
|
832
|
+
assertWorkspaceUnique(input);
|
|
833
|
+
assertNoParentChange(await readProjectTree(root), input);
|
|
834
|
+
await writeProjectTree(root, input);
|
|
835
|
+
json(res, 200, { ok: true });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
json(res, 405, { error: "method not allowed" });
|
|
839
|
+
} catch (error) {
|
|
840
|
+
routeError(res, error);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* /projects-panel/project:POST 建项目或归档。
|
|
847
|
+
*
|
|
848
|
+
* 建项目仅携带顶层字段并逐项校验,避免把客户端未知字段透传进树结构。
|
|
849
|
+
*/
|
|
850
|
+
function registerProjectRoute(ctx, root) {
|
|
851
|
+
ctx.webServer.register({
|
|
852
|
+
kind: "exact",
|
|
853
|
+
path: "/projects-panel/project",
|
|
854
|
+
handler: async (req, res) => {
|
|
855
|
+
try {
|
|
856
|
+
if (req.method !== "POST") {
|
|
857
|
+
json(res, 405, { error: "method not allowed" });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const input = await readBodyObject(req);
|
|
861
|
+
if (input.action === "archive") {
|
|
862
|
+
await archiveProject(root, requireString(input.id, "id"));
|
|
863
|
+
json(res, 200, { ok: true });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
json(res, 201, { id: await createProject(root, {
|
|
867
|
+
name: requireString(input.name, "name"),
|
|
868
|
+
description: optionalString(input.description),
|
|
869
|
+
link: optionalString(input.link),
|
|
870
|
+
parentId: optionalString(input.parentId)
|
|
871
|
+
}) });
|
|
872
|
+
} catch (error) {
|
|
873
|
+
routeError(res, error);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
/** /projects-panel/project-update:POST 更新项目(改名/描述/link)。 */
|
|
879
|
+
function registerProjectUpdateRoute(ctx, root) {
|
|
880
|
+
ctx.webServer.register({
|
|
881
|
+
kind: "exact",
|
|
882
|
+
path: "/projects-panel/project-update",
|
|
883
|
+
handler: async (req, res) => {
|
|
884
|
+
try {
|
|
885
|
+
if (req.method !== "POST") {
|
|
886
|
+
json(res, 405, { error: "method not allowed" });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const input = await readBodyObject(req);
|
|
890
|
+
await updateProject(root, requireString(input.id, "id"), {
|
|
891
|
+
name: optionalString(input.name),
|
|
892
|
+
description: optionalString(input.description),
|
|
893
|
+
link: optionalString(input.link)
|
|
894
|
+
});
|
|
895
|
+
json(res, 200, { ok: true });
|
|
896
|
+
} catch (error) {
|
|
897
|
+
routeError(res, error);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* /projects-panel/project-move:POST 移动项目归属与排序。
|
|
904
|
+
*
|
|
905
|
+
* UUID 身份不变;同容器纯排序只改顺序,换父同步搬磁盘目录
|
|
906
|
+
* (先搬后写树,树写失败回滚;源目录缺失则新建空目录)。
|
|
907
|
+
* 移动到自身或子项目下、目标同名、排序锚点不存在时拒绝。
|
|
908
|
+
* parentId 省略表示回到顶层,beforeId 省略表示追加末尾。
|
|
909
|
+
*/
|
|
910
|
+
function registerProjectMoveRoute(ctx, root) {
|
|
911
|
+
ctx.webServer.register({
|
|
912
|
+
kind: "exact",
|
|
913
|
+
path: "/projects-panel/project-move",
|
|
914
|
+
handler: async (req, res) => {
|
|
915
|
+
try {
|
|
916
|
+
if (req.method !== "POST") {
|
|
917
|
+
json(res, 405, { error: "method not allowed" });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const input = await readBodyObject(req);
|
|
921
|
+
await moveProject(root, requireString(input.id, "id"), optionalString(input.parentId), optionalString(input.beforeId));
|
|
922
|
+
json(res, 200, { ok: true });
|
|
923
|
+
} catch (error) {
|
|
924
|
+
routeError(res, error);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
/** /projects-panel/settings:GET 读设置,POST 整写设置,其余 405。 */
|
|
930
|
+
function registerSettingsRoute(ctx, root) {
|
|
931
|
+
ctx.webServer.register({
|
|
932
|
+
kind: "exact",
|
|
933
|
+
path: "/projects-panel/settings",
|
|
934
|
+
handler: async (req, res) => {
|
|
935
|
+
try {
|
|
936
|
+
if (req.method === "GET") {
|
|
937
|
+
json(res, 200, await readSettings(root));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (req.method !== "POST") {
|
|
941
|
+
json(res, 405, { error: "method not allowed" });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
await writeSettings(root, settingsInput(await readBodyObject(req)));
|
|
945
|
+
json(res, 200, { ok: true });
|
|
946
|
+
} catch (error) {
|
|
947
|
+
routeError(res, error);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* /projects-panel/workspace-attach:POST 挂载工作区到项目。
|
|
954
|
+
*
|
|
955
|
+
* body 携带 workspaceId 与可选 parentId/beforeId:parentId 省略表示移到顶层
|
|
956
|
+
* (只做摘除,工作区随后以悬浮节点显示,此时不允许 beforeId,顶层排序
|
|
957
|
+
* 请使用官方工作区排序);beforeId 省略表示追加到目标项目末尾。
|
|
958
|
+
* 写入失败时整体拒绝。
|
|
959
|
+
*/
|
|
960
|
+
function registerWorkspaceAttachRoute(ctx, root) {
|
|
961
|
+
ctx.webServer.register({
|
|
962
|
+
kind: "exact",
|
|
963
|
+
path: "/projects-panel/workspace-attach",
|
|
964
|
+
handler: async (req, res) => {
|
|
965
|
+
try {
|
|
966
|
+
if (req.method !== "POST") {
|
|
967
|
+
json(res, 405, { error: "method not allowed" });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const input = await readBodyObject(req);
|
|
971
|
+
await attachWorkspace(root, requireString(input.workspaceId, "workspaceId"), optionalString(input.parentId), optionalString(input.beforeId));
|
|
972
|
+
json(res, 200, { ok: true });
|
|
973
|
+
} catch (error) {
|
|
974
|
+
routeError(res, error);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
/** /projects-panel/workspace-detach:POST 摘除工作区归属(官方删除后的清理)。 */
|
|
980
|
+
function registerWorkspaceDetachRoute(ctx, root) {
|
|
981
|
+
ctx.webServer.register({
|
|
982
|
+
kind: "exact",
|
|
983
|
+
path: "/projects-panel/workspace-detach",
|
|
984
|
+
handler: async (req, res) => {
|
|
985
|
+
try {
|
|
986
|
+
if (req.method !== "POST") {
|
|
987
|
+
json(res, 405, { error: "method not allowed" });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
await detachWorkspace(root, requireString((await readBodyObject(req)).workspaceId, "workspaceId"));
|
|
991
|
+
json(res, 200, { ok: true });
|
|
992
|
+
} catch (error) {
|
|
993
|
+
routeError(res, error);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/** 插件入口:注册全部 HTTP 路由。数据根目录就绪后注册数据路由,健康检查立即生效。 */
|
|
999
|
+
function apply(ctx) {
|
|
1000
|
+
registerSettingsNamespace(ctx);
|
|
1001
|
+
ctx.webServer.register({
|
|
1002
|
+
kind: "exact",
|
|
1003
|
+
path: HEALTH_PATH,
|
|
1004
|
+
handler: healthHandler
|
|
1005
|
+
});
|
|
1006
|
+
ctx.webServer.register({
|
|
1007
|
+
kind: "exact",
|
|
1008
|
+
path: "/projects-panel/version",
|
|
1009
|
+
handler: versionHandler
|
|
1010
|
+
});
|
|
1011
|
+
ensureProjectsRoot().then((root) => {
|
|
1012
|
+
registerTreeRoute(ctx, root);
|
|
1013
|
+
registerProjectRoute(ctx, root);
|
|
1014
|
+
registerProjectUpdateRoute(ctx, root);
|
|
1015
|
+
registerProjectMoveRoute(ctx, root);
|
|
1016
|
+
registerSettingsRoute(ctx, root);
|
|
1017
|
+
registerWorkspaceAttachRoute(ctx, root);
|
|
1018
|
+
registerWorkspaceDetachRoute(ctx, root);
|
|
1019
|
+
}).catch((error) => {
|
|
1020
|
+
console.error("[projects-panel] 初始化失败", error);
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
//#endregion
|
|
1025
|
+
export { PROJECTS_PANEL_SETTINGS_BASE, PROJECTS_PANEL_SETTINGS_SCHEMA, SETTINGS_NAMESPACE, abbreviateHomePath, apply, applyProjectMove, archiveProject, assertNoParentChange, assertWorkspaceUnique, atomicWriteJson, attachWorkspace, createProject, defaultSettings, detachWorkspace, emptyTree, findNode, inject, isSafeLinkPath, moveProject, newProject, projectDirectory, projectStoreRoot, projectsRoot, readDshVersion, readLinkFile, readProjectTree, readSettings, registerSettingsNamespace, updateProject, validateProjectName, validateProjectTree, workspaceIdOf, writeProjectTree, writeSettings };
|