返回 DeepSeek-Reasonix
projectTreeRuntime.ts
根目录 / desktop / frontend / src / lib / projectTreeRuntime.ts
1 import { desktopHost } from "./desktopHost";
2 import { asArray } from "./array";
3 import { runtimeStateStore } from "./runtimeStateStore";
4 import { projectSessionIdentity, projectSessionExcluded, projectSessionKeys } from "./projectSessionIdentity";
5 import type { ProjectNode, ProjectRuntimeTopic, ProjectTreeRuntimeSnapshot } from "./types";
6
7 const noExcludedTopicIds: ReadonlySet<string> = new Set();
8
9 function withoutRuntimeState(node: ProjectNode): ProjectNode {
10 return { ...node, open: undefined, running: undefined, status: undefined,
11 children: asArray(node.children).map(withoutRuntimeState) };
12 }
13
14 function runtimeChildren(runtime: ProjectNode, catalog?: ProjectNode): ProjectNode[] {
15 const overlays = new Map(asArray(runtime.children).map(child => [projectSessionIdentity(child), child]));
16 const children = asArray(catalog?.children).map(child => {
17 const overlay = overlays.get(projectSessionIdentity(child));
18 if (overlay) overlays.delete(projectSessionIdentity(child));
19 return overlay ? { ...withoutRuntimeState(child), open: overlay.open, running: overlay.running,
20 status: overlay.status, children: runtimeChildren(overlay, child) } : withoutRuntimeState(child);
21 });
22 return [...children, ...overlays.values()];
23 }
24
25 function runtimeTopicKey(scope: string, workspaceRoot: string, node: ProjectNode): string {
26 return `${scope}\u0000${workspaceRoot}\u0000${projectSessionIdentity(node)}`;
27 }
28
29 function sameOwnFields(current: ProjectNode, next: ProjectNode): boolean {
30 const keys = new Set([...Object.keys(current), ...Object.keys(next)]);
31 keys.delete("children");
32 for (const key of keys) {
33 if (current[key as keyof ProjectNode] !== next[key as keyof ProjectNode]) return false;
34 }
35 return true;
36 }
37
38 function reconcileNode(current: ProjectNode | undefined, next: ProjectNode): ProjectNode {
39 const currentChildren = asArray(current?.children);
40 const currentByKey = new Map(currentChildren.map((child) => [child.key, child]));
41 const nextChildren = asArray(next.children).map((child) => reconcileNode(currentByKey.get(child.key), child));
42 if (current
43 && sameOwnFields(current, next)
44 && currentChildren.length === nextChildren.length
45 && currentChildren.every((child, index) => child === nextChildren[index])) {
46 return current;
47 }
48 return { ...next, children: nextChildren };
49 }
50
51 function rememberResidentTopics(
52 tree: ProjectNode[],
53 residentTopics: Map<string, ProjectNode>,
54 excludedTopicIds: ReadonlySet<string>,
55 ): Set<string> {
56 const catalogKeys = new Set<string>();
57 for (const project of tree) {
58 if (project.kind !== "project" && project.kind !== "global_folder") continue;
59 const scope = project.kind === "project" ? "project" : "global";
60 const root = scope === "project" ? project.root ?? "" : "";
61 for (const topic of asArray(project.children)) {
62 if (topic.runtimeOnly || projectSessionExcluded(topic, excludedTopicIds)) continue;
63 const key = runtimeTopicKey(scope, root, topic);
64 catalogKeys.add(key);
65 residentTopics.set(key, { ...withoutRuntimeState(topic), children: asArray(topic.children) });
66 }
67 }
68 return catalogKeys;
69 }
70
71 function activeRuntimeTopicKeys(
72 topics: ProjectRuntimeTopic[],
73 excludedTopicIds: ReadonlySet<string>,
74 ): Set<string> {
75 return new Set(topics.flatMap((topic) => {
76 if (projectSessionExcluded(topic.node, excludedTopicIds)) return [];
77 return [runtimeTopicKey(topic.scope, topic.scope === "project" ? topic.workspaceRoot ?? "" : "", topic.node)];
78 }));
79 }
80
81 function pruneResidentTopics(
82 residentTopics: Map<string, ProjectNode>,
83 catalogKeys: ReadonlySet<string>,
84 runtimeKeys: ReadonlySet<string>,
85 ) {
86 for (const key of residentTopics.keys()) {
87 if (!catalogKeys.has(key) && !runtimeKeys.has(key)) residentTopics.delete(key);
88 }
89 }
90
91 // This structural-sharing overlay is loaded after the project tree mounts so
92 // runtime reconciliation does not enlarge the first-paint bundle. Subscription
93 // still precedes the initial snapshot read, so loading the module cannot lose
94 // an ownership transition.
95 export function projectTreeApplyRuntimeTopics(
96 tree: ProjectNode[],
97 topics: ProjectRuntimeTopic[],
98 excludedTopicIds: ReadonlySet<string> = noExcludedTopicIds,
99 residentTopics?: ReadonlyMap<string, ProjectNode>,
100 ): ProjectNode[] {
101 const nextTree = tree.map((project) => {
102 if (project.kind !== "project" && project.kind !== "global_folder") return project;
103 const scope = project.kind === "project" ? "project" : "global";
104 const root = scope === "project" ? project.root ?? "" : "";
105 const available = topics
106 .filter((topic) => !projectSessionExcluded(topic.node, excludedTopicIds)
107 && topic.scope === scope
108 && (scope !== "project" || topic.workspaceRoot === root));
109 const aliases = new Map<string, string>();
110 for (const node of [...asArray(project.children), ...available.map(topic => topic.node)]) {
111 if (!node.session) continue;
112 for (const alias of projectSessionKeys(node)) aliases.set(alias, projectSessionIdentity(node));
113 }
114 const identityOf = (node: ProjectNode) => aliases.get(projectSessionIdentity(node)) ?? projectSessionIdentity(node);
115 const runtimeByTopic = new Map(available.map(topic => [identityOf(topic.node), topic]));
116 const currentChildren = asArray(project.children);
117 const base: ProjectNode[] = [];
118 for (const node of currentChildren) {
119 if (node.runtimeOnly || projectSessionExcluded(node, excludedTopicIds)) continue;
120 const identity = identityOf(node);
121 if (base.some(row => identityOf(row) === identity)) continue;
122 const candidate = runtimeByTopic.get(identity);
123 if (candidate) runtimeByTopic.delete(identity);
124 const runtime = candidate && (candidate.node.lifecycleGeneration ?? 0) >= (node.lifecycleGeneration ?? 0) ? candidate : undefined;
125 const next = runtime ? {
126 ...withoutRuntimeState(node),
127 session: node.session ?? runtime.node.session,
128 identityAliases: node.identityAliases ?? runtime.node.identityAliases,
129 open: runtime.node.open,
130 running: runtime.node.running,
131 status: runtime.node.status,
132 children: runtimeChildren(runtime.node, node),
133 } : withoutRuntimeState(node);
134 base.push(reconcileNode(node, next));
135 }
136 const runtimeOnly: ProjectNode[] = [];
137 for (const topic of runtimeByTopic.values()) {
138 const topicId = topic.node.topicId!;
139 const identity = projectSessionIdentity(topic.node);
140 const current = currentChildren.find((node) => node.runtimeOnly && projectSessionIdentity(node) === identity);
141 const resident = residentTopics?.get(runtimeTopicKey(scope, root, topic.node));
142 const stable = withoutRuntimeState(resident ?? current ?? topic.node);
143 runtimeOnly.push(reconcileNode(current, {
144 ...stable,
145 key: topic.node.key,
146 kind: topic.node.kind,
147 label: resident?.label ?? topic.node.label,
148 root: topic.node.root,
149 topicId,
150 sessionPath: stable.sessionPath || topic.node.sessionPath,
151 open: topic.node.open,
152 running: topic.node.running,
153 status: topic.node.status,
154 runtimeOnly: true,
155 children: runtimeChildren(topic.node, resident ?? current),
156 }));
157 }
158 return reconcileNode(project, { ...project, children: [...runtimeOnly, ...base] });
159 });
160 return nextTree.every((project, index) => project === tree[index]) ? tree : nextTree;
161 }
162
163 export function createProjectTreeRuntimeProjection() {
164 const residentTopics = new Map<string, ProjectNode>();
165 return {
166 apply(
167 tree: ProjectNode[],
168 topics: ProjectRuntimeTopic[],
169 excludedTopicIds: ReadonlySet<string> = noExcludedTopicIds,
170 ): ProjectNode[] {
171 const catalogKeys = rememberResidentTopics(tree, residentTopics, excludedTopicIds);
172 pruneResidentTopics(residentTopics, catalogKeys, activeRuntimeTopicKeys(topics, excludedTopicIds));
173 return projectTreeApplyRuntimeTopics(tree, topics, excludedTopicIds, residentTopics);
174 },
175 };
176 }
177
178 export function normalizeProjectTreeRuntimeSnapshot(payload: unknown): ProjectTreeRuntimeSnapshot {
179 const value = (payload ?? {}) as Partial<ProjectTreeRuntimeSnapshot>;
180 return { revision: value.revision ?? 0, topics: asArray(value.topics) };
181 }
182
183 export function onProjectTreeRuntimeChanged(cb: (event: ProjectTreeRuntimeSnapshot) => void): () => void {
184 const host = desktopHost();
185 if (host.kind === "none") return () => {};
186 return host.events.on("project-tree:runtime-changed", (payload?: unknown) => cb(normalizeProjectTreeRuntimeSnapshot(payload)));
187 }
188
189 export function bindProjectTreeRuntime(
190 setTree: (update: (tree: ProjectNode[]) => ProjectNode[]) => void,
191 getSnapshot: () => Promise<ProjectTreeRuntimeSnapshot> | undefined,
192 excludedTopicIds: () => ReadonlySet<string>,
193 ) {
194 let active = true;
195 let snapshot: ProjectTreeRuntimeSnapshot | null = null;
196 const projection = createProjectTreeRuntimeProjection();
197 const apply = (tree: ProjectNode[]) => snapshot ? projection.apply(tree, snapshot.topics, excludedTopicIds()) : tree;
198 const accept = (next: ProjectTreeRuntimeSnapshot) => {
199 if (!active || (snapshot && next.revision < snapshot.revision)) return;
200 snapshot = next;
201 setTree(apply);
202 };
203 const unified = () => {
204 const current = runtimeStateStore.getSnapshot();
205 if (current) {
206 const topics = runtimeStateStore.getFailed() ? current.topics.map(topic => ({ ...topic, node: { ...topic.node, running: false, status: "unknown" as const } })) : current.topics;
207 snapshot = null;
208 accept({ revision: current.revision, topics });
209 }
210 };
211 const stopUnified = runtimeStateStore.subscribe(unified);
212 const stop = onProjectTreeRuntimeChanged(next => { if (!runtimeStateStore.getSnapshot()) accept(next); });
213 unified();
214 if (!runtimeStateStore.getSnapshot()) void getSnapshot()?.then(next => { if (!runtimeStateStore.getSnapshot()) accept(next); }).catch(() => {});
215 return {
216 apply,
217 dispose() {
218 active = false;
219 stop();
220 stopUnified();
221 },
222 };
223 }
224
224 lines TYPESCRIPT