返回 DeepSeek-Reasonix
useProjectTreeReadActivity.ts
根目录 / desktop / frontend / src / components / useProjectTreeReadActivity.ts
1 import { useCallback, useEffect, useMemo, useState } from "react";
2 import { app } from "../lib/bridge";
3 import type { ProjectNode } from "../lib/types";
4 import { projectTreeReadActivityKey } from "../lib/projectTreeTopic";
5 import { markSessionRead, mergeReadStores, readActivityValues, repairReadBaseline, seedSessionReads, type ReadRecord, type ReadStore } from "../lib/sessionReadActivity";
6
7 const STORE_KEY = "projectTree:readActivity:v3";
8
9 function loadStore(): ReadStore {
10 const empty: ReadStore = { version: 3, baselineAt: Date.now(), records: {} };
11 try {
12 const stored = localStorage.getItem(STORE_KEY);
13 if (stored) {
14 const data = JSON.parse(stored) as ReadStore;
15 if (data.version === 3 && data.records) return data;
16 }
17 const legacy = JSON.parse(localStorage.getItem("projectTree:readActivity") || "{}") as Record<string, unknown>;
18 const baseline = Number(localStorage.getItem("projectTree:readActivityBaselineAt"));
19 if (baseline > 0) empty.baselineAt = baseline;
20 for (const [key, value] of Object.entries(legacy)) {
21 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;
22 // Topic timestamps are retained solely as compatibility import data;
23 // source rows use their own keys and the existing time baseline.
24 const canonical = key.startsWith("session\u001f");
25 const parts = key.split("\u001f");
26 const target = canonical ? `ref\u0000${parts[1] || "local"}\u0000${parts[2]}` : key;
27 empty.records[target] = { metric: canonical ? "result" : "time", value, revision: 0, imported: true };
28 }
29 localStorage.setItem(STORE_KEY, JSON.stringify(empty));
30 } catch { /* Storage may be unavailable. */ }
31 return empty;
32 }
33
34 function persist(store: ReadStore): ReadStore {
35 try {
36 const merged = mergeReadStores(loadStore(), store);
37 localStorage.setItem(STORE_KEY, JSON.stringify(merged));
38 return merged;
39 } catch { return store; }
40 }
41
42 // Deduplicate verification across mounted trees; Query also bounds cold rebuilds.
43 const verifications = new Map<string, ReturnType<typeof app.GetSessionActivityBaseline>>();
44 let activeVerifications = 0;
45 const waitingVerifications: (() => void)[] = [];
46 function verifyBaseline(node: ProjectNode, key: string) {
47 const existing = verifications.get(key);
48 if (existing) return existing;
49 const request = new Promise<Awaited<ReturnType<typeof app.GetSessionActivityBaseline>>>((resolve, reject) => {
50 const run = () => {
51 activeVerifications++;
52 void app.GetSessionActivityBaseline({ ref: node.session }).then(resolve, reject).finally(() => {
53 activeVerifications--;
54 waitingVerifications.shift()?.();
55 });
56 };
57 if (activeVerifications < 2) run(); else waitingVerifications.push(run);
58 });
59 verifications.set(key, request);
60 void request.finally(() => verifications.delete(key)).catch(() => {});
61 return request;
62 }
63
64 export function useProjectTreeReadActivity(nodes: readonly ProjectNode[]) {
65 const [store, setStore] = useState<ReadStore>(loadStore);
66 const [verificationEpoch, setVerificationEpoch] = useState(0);
67 const readActivity = useMemo(() => readActivityValues(store), [store]);
68 const markNodeRead = useCallback((node: ProjectNode) => {
69 setStore(current => {
70 const next = markSessionRead(current, node);
71 return next === current ? current : persist(next);
72 });
73 }, []);
74
75 useEffect(() => {
76 setStore(current => {
77 const next = seedSessionReads(current, nodes);
78 return next === current ? current : persist(next);
79 });
80 }, [nodes]);
81
82 useEffect(() => {
83 let alive = true;
84 let retry: ReturnType<typeof setTimeout> | undefined;
85 const retryLater = () => {
86 if (alive && !retry) retry = setTimeout(() => { if (alive) setVerificationEpoch(value => value + 1); }, 10_000);
87 };
88 const candidates: { node: ProjectNode; key: string; record: ReadRecord }[] = [];
89 const visit = (node: ProjectNode) => {
90 const key = projectTreeReadActivityKey(node), record = key ? store.records[key] : undefined;
91 if (node.session && (!node.session.hostId || node.session.hostId === "local") && key && record?.metric === "result"
92 && (record.needsBaseline || record.imported && !record.repairVersion)) candidates.push({ node, key, record });
93 node.children?.forEach(visit);
94 };
95 nodes.forEach(visit);
96 for (const { node, key, record } of candidates) {
97 const request = verifyBaseline(node, key);
98 void request.then(observation => {
99 if (!alive || observation.ref.hostId !== node.session?.hostId || observation.ref.sessionId !== node.session?.sessionId) return;
100 if (!observation.complete || observation.resultSequence === 0) retryLater();
101 setStore(current => {
102 const next = repairReadBaseline(current, key, record, observation);
103 return next === current ? current : persist(next);
104 });
105 }).catch(retryLater);
106 }
107 return () => { alive = false; if (retry) clearTimeout(retry); };
108 }, [nodes, store, verificationEpoch]);
109
110 useEffect(() => {
111 const changed = (event: StorageEvent) => {
112 if (event.key !== STORE_KEY || !event.newValue) return;
113 try {
114 const incoming = JSON.parse(event.newValue) as ReadStore;
115 if (incoming.version === 3 && incoming.records) setStore(current => mergeReadStores(current, incoming));
116 } catch { /* Ignore invalid external storage. */ }
117 };
118 window.addEventListener("storage", changed);
119 return () => window.removeEventListener("storage", changed);
120 }, []);
121 return { readActivity, readBaselineAt: store.baselineAt, markNodeRead };
122 }
123
123 lines TYPESCRIPT