| 1 | import type { ProjectNode } from "./types"; |
| 2 | import type { TopicRemovalTarget, TopicRemovalRequest, TopicRemovalInspection, TopicRemovalResult, TrashEntry } from "../generated/desktopContract.generated"; |
| 3 | import { mockProjectGroups, notifyMockProjectTreeChanged } from "./mockProjectTreeOrganization"; |
| 4 | |
| 5 | type Receipt = { request: string; result: TopicRemovalResult; parent: ProjectNode; node: ProjectNode; order: number; entry?: TrashEntry }; |
| 6 | const states = new WeakMap<ProjectNode[], { automatic: Set<string>; receipts: Map<string, Receipt>; generation: number }>(); |
| 7 | function state(tree: ProjectNode[]) { |
| 8 | let value = states.get(tree); |
| 9 | if (!value) { value = { automatic: new Set(), receipts: new Map(), generation: 1 }; states.set(tree, value); } |
| 10 | return value; |
| 11 | } |
| 12 | export function markTopic(tree: ProjectNode[], id: string, automatic: boolean) { |
| 13 | if (automatic) state(tree).automatic.add(id); else state(tree).automatic.delete(id); |
| 14 | } |
| 15 | function owner(tree: ProjectNode[], id: string) { |
| 16 | const parents = tree.filter(parent => parent.children?.some(node => node.topicId === id)); |
| 17 | if (parents.length !== 1) throw new Error("Topic ownership conflict"); |
| 18 | const parent = parents[0], order = parent.children!.findIndex(node => node.topicId === id), node = parent.children![order]; |
| 19 | const workspaceId = parent.kind === "global_folder" ? "global" : `project-${(parent.root || parent.key).replace(/[^a-zA-Z0-9._-]/g, "-")}`; |
| 20 | return { parent, order, node, workspaceId }; |
| 21 | } |
| 22 | export function inspect(tree: ProjectNode[], target: TopicRemovalTarget): TopicRemovalInspection { |
| 23 | const { parent, node, workspaceId } = owner(tree, target.topicId); |
| 24 | if (target.workspaceId && target.workspaceId !== workspaceId) throw new Error("Topic ownership conflict"); |
| 25 | const groups = mockProjectGroups(parent.kind === "global_folder" ? "global" : "project", parent.root || ""); |
| 26 | const organized = groups.some(group => group.topicIds?.includes(target.topicId)); |
| 27 | const disposition = node.session ? "archive_sessions" : state(tree).automatic.has(target.topicId) && !node.pinned && !organized ? "discard_placeholder" : "archive_placeholder"; |
| 28 | return { target: { ...target, workspaceId }, disposition, allowed: !node.running, reason: node.running ? "busy" : undefined, token: JSON.stringify([node, groups, disposition]) }; |
| 29 | } |
| 30 | export function remove(tree: ProjectNode[], request: TopicRemovalRequest): TopicRemovalResult { |
| 31 | const saved = state(tree), encoded = JSON.stringify(request), previous = saved.receipts.get(request.operationId); |
| 32 | const conflict = { committed: false, disposition: "", retryable: false, errorCode: "state_conflict" }; |
| 33 | if (previous) return previous.request === encoded ? previous.result : conflict; |
| 34 | const view = inspect(tree, request.target); |
| 35 | if (!view.allowed || view.token !== request.expectedToken) return conflict; |
| 36 | const { parent, node, order, workspaceId } = owner(tree, request.target.topicId); |
| 37 | const result: TopicRemovalResult = { committed: true, disposition: view.disposition, retryable: false }; |
| 38 | const receipt: Receipt = { request: encoded, result, parent, node: structuredClone(node), order }; |
| 39 | if (view.disposition !== "discard_placeholder") { |
| 40 | result.recoveryEntryId = `topic-removal:${request.operationId}`; |
| 41 | receipt.entry = { id: result.recoveryEntryId, recoveryEntryId: result.recoveryEntryId, workspaceId, workspaceTitle: parent.label, title: node.label, |
| 42 | archivedAt: Date.now(), health: "ready", canPreview: false, canRestore: true, canPurge: true }; |
| 43 | } |
| 44 | parent.children!.splice(order, 1); |
| 45 | saved.receipts.set(request.operationId, receipt); saved.generation++; |
| 46 | notifyMockProjectTreeChanged(); |
| 47 | return result; |
| 48 | } |
| 49 | export function trash(tree: ProjectNode[]) { |
| 50 | const saved = state(tree); |
| 51 | return { generation: saved.generation, items: [...saved.receipts.values()].flatMap(receipt => receipt.entry ? [receipt.entry] : []) }; |
| 52 | } |
| 53 | export function recover(tree: ProjectNode[], id: string, workspaceId: string, action: string) { |
| 54 | const saved = state(tree), receipt = saved.receipts.get(id.replace(/^topic-removal:/, "")); |
| 55 | if (!receipt?.entry || receipt.entry.workspaceId !== workspaceId) throw new Error("Recovery conflict"); |
| 56 | if (action === "restore") { |
| 57 | if (!tree.includes(receipt.parent) || tree.some(parent => parent.children?.some(node => node.topicId === receipt.node.topicId))) throw new Error("Restore conflict"); |
| 58 | (receipt.parent.children ??= []).splice(receipt.order, 0, structuredClone(receipt.node)); |
| 59 | } else if (action !== "purge") throw new Error("Invalid metadata lifecycle action"); |
| 60 | receipt.entry = undefined; saved.generation++; |
| 61 | notifyMockProjectTreeChanged(); |
| 62 | } |
| 63 |