| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | |
| 3 | import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; |
| 4 | import { Atom } from "effect/reactivity"; |
| 5 | |
| 6 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 7 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 8 | import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; |
| 9 | |
| 10 | const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]); |
| 11 | const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({}); |
| 12 | const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({}); |
| 13 | const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({ |
| 14 | loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, |
| 15 | error: null, |
| 16 | }); |
| 17 | const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< |
| 18 | Readonly<Record<string, ReadonlyArray<string> | undefined>> |
| 19 | >({}); |
| 20 | const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( |
| 21 | Atom.keepAlive, |
| 22 | Atom.withLabel("mobile:review:git-sections:null"), |
| 23 | ); |
| 24 | const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( |
| 25 | Atom.keepAlive, |
| 26 | Atom.withLabel("mobile:review:turn-diffs:null"), |
| 27 | ); |
| 28 | const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe( |
| 29 | Atom.keepAlive, |
| 30 | Atom.withLabel("mobile:review:selected-section-id:null"), |
| 31 | ); |
| 32 | const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( |
| 33 | Atom.keepAlive, |
| 34 | Atom.withLabel("mobile:review:async-state:null"), |
| 35 | ); |
| 36 | const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 37 | Atom.keepAlive, |
| 38 | Atom.withLabel("mobile:review:section-file-ids:null"), |
| 39 | ); |
| 40 | |
| 41 | const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => |
| 42 | Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( |
| 43 | Atom.keepAlive, |
| 44 | Atom.withLabel(`mobile:review:git-sections:${threadKey}`), |
| 45 | ), |
| 46 | ); |
| 47 | |
| 48 | const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => |
| 49 | Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( |
| 50 | Atom.keepAlive, |
| 51 | Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), |
| 52 | ), |
| 53 | ); |
| 54 | |
| 55 | const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => |
| 56 | Atom.make<string | null>(null).pipe( |
| 57 | Atom.keepAlive, |
| 58 | Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`), |
| 59 | ), |
| 60 | ); |
| 61 | |
| 62 | const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) => |
| 63 | Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( |
| 64 | Atom.keepAlive, |
| 65 | Atom.withLabel(`mobile:review:async-state:${threadKey}`), |
| 66 | ), |
| 67 | ); |
| 68 | |
| 69 | const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => |
| 70 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 71 | Atom.keepAlive, |
| 72 | Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`), |
| 73 | ), |
| 74 | ); |
| 75 | |
| 76 | const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => |
| 77 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 78 | Atom.keepAlive, |
| 79 | Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`), |
| 80 | ), |
| 81 | ); |
| 82 | |
| 83 | const reviewViewedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => |
| 84 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 85 | Atom.keepAlive, |
| 86 | Atom.withLabel(`mobile:review:viewed-file-ids:${threadKey}`), |
| 87 | ), |
| 88 | ); |
| 89 | |
| 90 | export const MAX_CACHED_REVIEW_DIFFS = 8; |
| 91 | // This bounds source string length, not the parsed or native heap size. |
| 92 | export const MAX_CACHED_REVIEW_SOURCE_CHARACTERS = 4 * 1024 * 1024; |
| 93 | |
| 94 | interface CachedReviewParsedDiff { |
| 95 | readonly diff: string | null; |
| 96 | readonly parsed: ReviewParsedDiff; |
| 97 | readonly sourceCharacterCount: number; |
| 98 | } |
| 99 | |
| 100 | // The factory keeps this mutable cache local to the registry and releases it on reset or disposal. |
| 101 | const reviewParsedDiffCacheAtom = Atom.make(() => ({ |
| 102 | entries: new Map<string, CachedReviewParsedDiff>(), |
| 103 | sourceCharacterCount: 0, |
| 104 | })).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:parsed-diffs")); |
| 105 | |
| 106 | export interface ReviewCacheForThread { |
| 107 | readonly threadKey: string | null; |
| 108 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; |
| 109 | readonly turnDiffById: Readonly<Record<string, string>>; |
| 110 | readonly selectedSectionId: string | null; |
| 111 | readonly asyncState: ReviewAsyncState; |
| 112 | readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; |
| 113 | readonly revealedLargeFileIdsBySection: Readonly< |
| 114 | Record<string, ReadonlyArray<string> | undefined> |
| 115 | >; |
| 116 | readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; |
| 117 | } |
| 118 | |
| 119 | export interface ReviewAsyncState { |
| 120 | readonly loadingTurnIds: Readonly<Record<string, boolean>>; |
| 121 | readonly error: string | null; |
| 122 | } |
| 123 | |
| 124 | function buildThreadKey(input: { |
| 125 | readonly environmentId?: EnvironmentId; |
| 126 | readonly threadId?: ThreadId; |
| 127 | }): string | null { |
| 128 | return input.environmentId && input.threadId |
| 129 | ? scopedThreadKey(input.environmentId, input.threadId) |
| 130 | : null; |
| 131 | } |
| 132 | |
| 133 | function buildSectionCacheKey(threadKey: string, sectionId: string): string { |
| 134 | return `${threadKey}:${sectionId}`; |
| 135 | } |
| 136 | |
| 137 | export function useReviewCacheForThread(input: { |
| 138 | readonly environmentId?: EnvironmentId; |
| 139 | readonly threadId?: ThreadId; |
| 140 | }): ReviewCacheForThread { |
| 141 | const threadKey = buildThreadKey(input); |
| 142 | const gitSections = useAtomValue( |
| 143 | threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, |
| 144 | ); |
| 145 | const turnDiffById = useAtomValue( |
| 146 | threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, |
| 147 | ); |
| 148 | const selectedSectionId = useAtomValue( |
| 149 | threadKey |
| 150 | ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) |
| 151 | : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, |
| 152 | ); |
| 153 | const asyncState = useAtomValue( |
| 154 | threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, |
| 155 | ); |
| 156 | const expandedFileIdsBySection = useAtomValue( |
| 157 | threadKey |
| 158 | ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) |
| 159 | : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, |
| 160 | ); |
| 161 | const revealedLargeFileIdsBySection = useAtomValue( |
| 162 | threadKey |
| 163 | ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey) |
| 164 | : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, |
| 165 | ); |
| 166 | const viewedFileIdsBySection = useAtomValue( |
| 167 | threadKey ? reviewViewedFileIdsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, |
| 168 | ); |
| 169 | |
| 170 | return { |
| 171 | threadKey, |
| 172 | gitSections, |
| 173 | turnDiffById, |
| 174 | selectedSectionId, |
| 175 | asyncState, |
| 176 | expandedFileIdsBySection, |
| 177 | revealedLargeFileIdsBySection, |
| 178 | viewedFileIdsBySection, |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | export function setReviewGitSections( |
| 183 | threadKey: string, |
| 184 | sections: ReadonlyArray<ReviewDiffPreviewSource>, |
| 185 | ): void { |
| 186 | appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); |
| 187 | } |
| 188 | |
| 189 | export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { |
| 190 | const atom = reviewTurnDiffByThreadKeyAtom(threadKey); |
| 191 | const current = appAtomRegistry.get(atom); |
| 192 | appAtomRegistry.set(atom, { |
| 193 | ...current, |
| 194 | [sectionId]: diff, |
| 195 | }); |
| 196 | } |
| 197 | |
| 198 | export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { |
| 199 | appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); |
| 200 | } |
| 201 | |
| 202 | function updateReviewAsyncState( |
| 203 | threadKey: string, |
| 204 | update: (current: ReviewAsyncState) => ReviewAsyncState, |
| 205 | ): void { |
| 206 | const atom = reviewAsyncStateByThreadKeyAtom(threadKey); |
| 207 | appAtomRegistry.set(atom, update(appAtomRegistry.get(atom))); |
| 208 | } |
| 209 | |
| 210 | export function setReviewTurnDiffLoading( |
| 211 | threadKey: string, |
| 212 | sectionId: string, |
| 213 | isLoading: boolean, |
| 214 | ): void { |
| 215 | updateReviewAsyncState(threadKey, (current) => { |
| 216 | const loadingTurnIds = { ...current.loadingTurnIds }; |
| 217 | if (isLoading) { |
| 218 | loadingTurnIds[sectionId] = true; |
| 219 | } else { |
| 220 | delete loadingTurnIds[sectionId]; |
| 221 | } |
| 222 | return { |
| 223 | ...current, |
| 224 | loadingTurnIds, |
| 225 | }; |
| 226 | }); |
| 227 | } |
| 228 | |
| 229 | export function setReviewAsyncError(threadKey: string, error: string | null): void { |
| 230 | updateReviewAsyncState(threadKey, (current) => ({ |
| 231 | ...current, |
| 232 | error, |
| 233 | })); |
| 234 | } |
| 235 | |
| 236 | export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { |
| 237 | return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); |
| 238 | } |
| 239 | |
| 240 | export function updateReviewExpandedFileIds( |
| 241 | threadKey: string, |
| 242 | sectionId: string, |
| 243 | update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, |
| 244 | ): void { |
| 245 | const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey); |
| 246 | const current = appAtomRegistry.get(atom); |
| 247 | const nextValue = update(current[sectionId]); |
| 248 | appAtomRegistry.set(atom, { |
| 249 | ...current, |
| 250 | [sectionId]: nextValue, |
| 251 | }); |
| 252 | } |
| 253 | |
| 254 | export function updateReviewViewedFileIds( |
| 255 | threadKey: string, |
| 256 | sectionId: string, |
| 257 | update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, |
| 258 | ): void { |
| 259 | const atom = reviewViewedFileIdsByThreadKeyAtom(threadKey); |
| 260 | const current = appAtomRegistry.get(atom); |
| 261 | const nextValue = update(current[sectionId]); |
| 262 | appAtomRegistry.set(atom, { |
| 263 | ...current, |
| 264 | [sectionId]: nextValue, |
| 265 | }); |
| 266 | } |
| 267 | |
| 268 | /** Returns the larger of current input and matching cached source, without changing recency. */ |
| 269 | export function getReviewParsedDiffSourceCharacterCount(input: { |
| 270 | readonly threadKey: string; |
| 271 | readonly sectionId: string; |
| 272 | readonly diff: string | null; |
| 273 | }): number { |
| 274 | const sourceCharacterCount = input.diff?.length ?? 0; |
| 275 | const cache = appAtomRegistry.get(reviewParsedDiffCacheAtom); |
| 276 | const cached = cache.entries.get(buildSectionCacheKey(input.threadKey, input.sectionId)); |
| 277 | return cached && cached.diff === (input.diff?.trim() ?? null) |
| 278 | ? Math.max(sourceCharacterCount, cached.sourceCharacterCount) |
| 279 | : sourceCharacterCount; |
| 280 | } |
| 281 | |
| 282 | export function getCachedReviewParsedDiff(input: { |
| 283 | readonly threadKey: string | null; |
| 284 | readonly sectionId: string | null; |
| 285 | readonly diff: string | null | undefined; |
| 286 | }): ReviewParsedDiff { |
| 287 | if (!input.threadKey || !input.sectionId) { |
| 288 | return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review"); |
| 289 | } |
| 290 | |
| 291 | const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId); |
| 292 | const normalizedDiff = input.diff?.trim() ?? null; |
| 293 | const cache = appAtomRegistry.get(reviewParsedDiffCacheAtom); |
| 294 | const cached = cache.entries.get(cacheKey); |
| 295 | if (cached && cached.diff === normalizedDiff) { |
| 296 | cache.entries.delete(cacheKey); |
| 297 | cache.entries.set(cacheKey, cached); |
| 298 | return cached.parsed; |
| 299 | } |
| 300 | |
| 301 | const parsed = buildReviewParsedDiff(input.diff, input.sectionId); |
| 302 | if (cached) { |
| 303 | cache.entries.delete(cacheKey); |
| 304 | cache.sourceCharacterCount -= cached.sourceCharacterCount; |
| 305 | } |
| 306 | const sourceCharacterCount = input.diff?.length ?? 0; |
| 307 | if (sourceCharacterCount > MAX_CACHED_REVIEW_SOURCE_CHARACTERS) { |
| 308 | return parsed; |
| 309 | } |
| 310 | |
| 311 | for (const [oldestKey, oldest] of cache.entries) { |
| 312 | if ( |
| 313 | cache.entries.size < MAX_CACHED_REVIEW_DIFFS && |
| 314 | cache.sourceCharacterCount + sourceCharacterCount <= MAX_CACHED_REVIEW_SOURCE_CHARACTERS |
| 315 | ) { |
| 316 | break; |
| 317 | } |
| 318 | cache.entries.delete(oldestKey); |
| 319 | cache.sourceCharacterCount -= oldest.sourceCharacterCount; |
| 320 | } |
| 321 | cache.entries.set(cacheKey, { |
| 322 | diff: normalizedDiff, |
| 323 | parsed, |
| 324 | sourceCharacterCount, |
| 325 | }); |
| 326 | cache.sourceCharacterCount += sourceCharacterCount; |
| 327 | return parsed; |
| 328 | } |