| 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 |