a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Plan 5: Simplify diff/change fetching and watcher invalidation

Refactor revision diff and changed-file payloads out of TanStack DB collections and into TanStack Query resources:

- replace unified diff/change DB collections with Query-backed hooks in useRevisionData
- use canonical query keys for revision diff and revision changed-file payloads
- keep batch IPC prefetching, but write fetched payloads directly into Query cache
- update DiffPanel to consume diff payloads from the same Query key it retries/refetches
- remove obsolete per-revision and unified diff/change collection modules and exports
- keep watcher invalidation focused on Query prefixes plus operation/revision collections
- update data-loading tests to assert Query-cache prefetch behavior instead of collection creation

Validation:
- cd apps/desktop && bun run typecheck
- cd apps/desktop && bun run lint
- cd apps/desktop && bun run test
- cd apps/desktop && bun run build

+235 -485
+36 -23
apps/desktop/src/__tests__/data-loading.test.ts
··· 17 17 unwatchRepository: vi.fn(), 18 18 getRevisionDiff: vi.fn(), 19 19 getRevisionChanges: vi.fn(), 20 + getDiffsBatchEffect: vi.fn(), 21 + getChangesBatchEffect: vi.fn(), 20 22 getRevisions: vi.fn(), 21 23 })); 22 24 ··· 34 36 getRepositories: vi.fn().mockResolvedValue([]), 35 37 getRevisionChanges: mocks.getRevisionChanges, 36 38 getRevisionDiff: mocks.getRevisionDiff, 39 + getDiffsBatchEffect: mocks.getDiffsBatchEffect, 40 + getChangesBatchEffect: mocks.getChangesBatchEffect, 37 41 getRevisions: mocks.getRevisions, 38 42 jjAbandon: vi.fn(), 39 43 jjDescribe: vi.fn(), ··· 52 56 53 57 import { 54 58 getRevisionsCollection, 55 - getRevisionChangesCollection, 56 - getRevisionDiffCollection, 57 59 queryClient, 60 + revisionChangesQueryKey, 61 + revisionDiffQueryKey, 58 62 } from "@/db"; 59 63 60 64 // ============================================================================ ··· 269 273 270 274 expect(col1).not.toBe(col2); 271 275 }); 272 - 273 - test("getRevisionChangesCollection returns same instance for same repoPath+changeId", () => { 274 - const col1 = getRevisionChangesCollection("/tmp/changes-repo", "change-a"); 275 - const col2 = getRevisionChangesCollection("/tmp/changes-repo", "change-a"); 276 - 277 - expect(col1).toBe(col2); 278 - }); 279 - 280 - test("getRevisionDiffCollection returns same instance for same repoPath+changeId", () => { 281 - const col1 = getRevisionDiffCollection("/tmp/diff-repo", "change-a"); 282 - const col2 = getRevisionDiffCollection("/tmp/diff-repo", "change-a"); 283 - 284 - expect(col1).toBe(col2); 285 - }); 286 276 }); 287 277 288 278 // ============================================================================ ··· 297 287 mocks.listen.mockResolvedValue(vi.fn()); 298 288 mocks.getRevisionDiff.mockResolvedValue("--- a/file\n+++ b/file"); 299 289 mocks.getRevisionChanges.mockResolvedValue([]); 290 + mocks.getDiffsBatchEffect.mockImplementation((repoPath: string, changeIds: string[]) => 291 + Effect.succeed( 292 + changeIds.map((change_id) => ({ change_id, diff: `${repoPath}:${change_id}` })), 293 + ), 294 + ); 295 + mocks.getChangesBatchEffect.mockImplementation((_repoPath: string, changeIds: string[]) => 296 + Effect.succeed(changeIds.map((change_id) => ({ change_id, files: [] }))), 297 + ); 300 298 }); 301 299 302 - test("prefetchRevisionDiffs creates collections for each changeId", async () => { 300 + test("prefetchRevisionDiffs stores diff payloads in Query cache", async () => { 303 301 const { prefetchRevisionDiffs } = await import("@/db"); 304 302 const changeIds = ["change-1", "change-2", "change-3"]; 305 303 306 304 prefetchRevisionDiffs("/tmp/prefetch-repo", changeIds); 307 305 308 - // Each changeId should have a collection created 306 + await vi.waitFor(() => { 307 + expect( 308 + queryClient.getQueryData(revisionDiffQueryKey("/tmp/prefetch-repo", "change-1")), 309 + ).toBeDefined(); 310 + }); 311 + 309 312 for (const id of changeIds) { 310 - const col = getRevisionDiffCollection("/tmp/prefetch-repo", id); 311 - expect(col).toBeDefined(); 313 + expect(queryClient.getQueryData(revisionDiffQueryKey("/tmp/prefetch-repo", id))).toEqual({ 314 + repoPath: "/tmp/prefetch-repo", 315 + changeId: id, 316 + content: `/tmp/prefetch-repo:${id}`, 317 + }); 312 318 } 313 319 }); 314 320 315 - test("prefetchRevisionChanges creates collections for each changeId", async () => { 321 + test("prefetchRevisionChanges stores changed-file payloads in Query cache", async () => { 316 322 const { prefetchRevisionChanges } = await import("@/db"); 317 323 const changeIds = ["change-a", "change-b"]; 318 324 319 325 prefetchRevisionChanges("/tmp/prefetch-changes", changeIds); 320 326 327 + await vi.waitFor(() => { 328 + expect( 329 + queryClient.getQueryData(revisionChangesQueryKey("/tmp/prefetch-changes", "change-a")), 330 + ).toBeDefined(); 331 + }); 332 + 321 333 for (const id of changeIds) { 322 - const col = getRevisionChangesCollection("/tmp/prefetch-changes", id); 323 - expect(col).toBeDefined(); 334 + expect( 335 + queryClient.getQueryData(revisionChangesQueryKey("/tmp/prefetch-changes", id)), 336 + ).toEqual([]); 324 337 } 325 338 }); 326 339 });
+21 -9
apps/desktop/src/components/DiffPanel.tsx
··· 11 11 import { ScrollArea } from "@/components/ui/scroll-area"; 12 12 import { Skeleton } from "@/components/ui/skeleton"; 13 13 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 14 - import { useChanges, useDiff, usePrefetch } from "@/hooks/useRevisionData"; 14 + import { 15 + revisionDiffQueryKey, 16 + useChanges, 17 + usePrefetch, 18 + type DiffRecord, 19 + } from "@/hooks/useRevisionData"; 15 20 import { extractFilePath, parsePatchStats, splitMultiFileDiff } from "@/lib/diff-utils"; 16 21 import { traceLog } from "@/lib/trace"; 17 22 import { cn } from "@/lib/utils"; ··· 285 290 [changesRecords], 286 291 ); 287 292 288 - // Read diff from unified collection 289 - const diffRecord = useDiff(repoPath ?? "", deferredChangeId); 293 + // Read diff payload from TanStack Query 290 294 const { 291 - data: fallbackDiff, 295 + data: diffRecord, 292 296 error: diffError, 293 297 refetch: retryDiff, 294 298 isFetching: isRetryingDiff, 295 - } = useQuery({ 296 - queryKey: ["diff-fallback", repoPath, deferredChangeId], 297 - queryFn: () => getRevisionDiff(repoPath ?? "", deferredChangeId ?? ""), 298 - enabled: !!repoPath && !!deferredChangeId && !diffRecord, 299 + } = useQuery<DiffRecord>({ 300 + queryKey: 301 + repoPath && deferredChangeId 302 + ? revisionDiffQueryKey(repoPath, deferredChangeId) 303 + : ["revision-diff", repoPath, null], 304 + queryFn: async () => ({ 305 + repoPath: repoPath ?? "", 306 + changeId: deferredChangeId ?? "", 307 + content: await getRevisionDiff(repoPath ?? "", deferredChangeId ?? ""), 308 + }), 309 + enabled: !!repoPath && !!deferredChangeId, 310 + staleTime: Number.POSITIVE_INFINITY, 299 311 retry: false, 300 312 }); 301 - const revisionDiff = diffRecord?.content ?? fallbackDiff ?? ""; 313 + const revisionDiff = diffRecord?.content ?? ""; 302 314 303 315 const { data: conflictPaths = [] } = useQuery({ 304 316 queryKey: ["conflict-paths", repoPath, deferredChangeId],
-36
apps/desktop/src/data/collections/changes.ts
··· 1 - import { createCollection } from "@tanstack/db"; 2 - import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 - import { queryClient } from "../query-client"; 4 - 5 - // ============================================================================ 6 - // Unified Changes Collection (single collection for all revision file lists) 7 - // ============================================================================ 8 - 9 - /** 10 - * Unified change record - stores changed files keyed by repoPath:changeId:path. 11 - * This replaces the per-revision collection pattern which caused GC issues. 12 - */ 13 - export interface ChangeRecord { 14 - repoPath: string; 15 - changeId: string; 16 - path: string; 17 - status: "added" | "modified" | "deleted"; 18 - } 19 - 20 - function getChangeRecordKey(c: ChangeRecord): string { 21 - return `${c.repoPath}:${c.changeId}:${c.path}`; 22 - } 23 - 24 - const changesQueryKey = ["changes"] as const; 25 - 26 - export const changesCollection = createCollection({ 27 - ...queryCollectionOptions({ 28 - queryClient, 29 - queryKey: changesQueryKey, 30 - queryFn: async () => [] as ChangeRecord[], 31 - getKey: getChangeRecordKey, 32 - }), 33 - startSync: true, 34 - }); 35 - 36 - export type ChangesCollection = typeof changesCollection;
-37
apps/desktop/src/data/collections/diffs.ts
··· 1 - import { createCollection } from "@tanstack/db"; 2 - import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 - import { queryClient } from "../query-client"; 4 - 5 - // ============================================================================ 6 - // Unified Diffs Collection (single collection for all revision diffs) 7 - // ============================================================================ 8 - 9 - /** 10 - * Unified diff record - stores diff content keyed by repoPath:changeId. 11 - * This replaces the per-revision collection pattern which caused GC issues. 12 - */ 13 - export interface DiffRecord { 14 - repoPath: string; 15 - changeId: string; 16 - content: string; 17 - prerenderedUnified?: string; 18 - prerenderedSplit?: string; 19 - } 20 - 21 - function getDiffRecordKey(d: DiffRecord): string { 22 - return `${d.repoPath}:${d.changeId}`; 23 - } 24 - 25 - const diffsQueryKey = ["diffs"] as const; 26 - 27 - export const diffsCollection = createCollection({ 28 - ...queryCollectionOptions({ 29 - queryClient, 30 - queryKey: diffsQueryKey, 31 - queryFn: async () => [] as DiffRecord[], 32 - getKey: getDiffRecordKey, 33 - }), 34 - startSync: true, 35 - }); 36 - 37 - export type DiffsCollection = typeof diffsCollection;
-51
apps/desktop/src/data/collections/revision-changes.ts
··· 1 - import { createCollection } from "@tanstack/db"; 2 - import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 - import type { ChangedFile } from "@/tauri-commands"; 4 - import { getRevisionChanges } from "@/tauri-commands"; 5 - import { queryClient } from "../query-client"; 6 - 7 - // ============================================================================ 8 - // Revision Changes Collections (ChangedFile[] per revision) 9 - // ============================================================================ 10 - 11 - const revisionChangesCollections = new Map< 12 - string, 13 - ReturnType<typeof createRevisionChangesCollection> 14 - >(); 15 - 16 - function createRevisionChangesCollection(repoPath: string, changeId: string) { 17 - return createCollection({ 18 - ...queryCollectionOptions({ 19 - queryClient, 20 - queryKey: ["revision-changes", repoPath, changeId], 21 - queryFn: async () => { 22 - return await getRevisionChanges(repoPath, changeId); 23 - }, 24 - getKey: (file: ChangedFile) => file.path, 25 - }), 26 - }); 27 - } 28 - 29 - export type RevisionChangesCollection = ReturnType<typeof createRevisionChangesCollection>; 30 - 31 - export function getRevisionChangesCollection( 32 - repoPath: string, 33 - changeId: string, 34 - ): RevisionChangesCollection { 35 - const cacheKey = `${repoPath}:${changeId}`; 36 - let collection = revisionChangesCollections.get(cacheKey); 37 - if (!collection) { 38 - collection = createRevisionChangesCollection(repoPath, changeId); 39 - revisionChangesCollections.set(cacheKey, collection); 40 - } 41 - return collection; 42 - } 43 - 44 - export const emptyChangesCollection = createCollection({ 45 - ...queryCollectionOptions({ 46 - queryClient, 47 - queryKey: ["revision-changes", "empty"], 48 - queryFn: () => Promise.resolve([]), 49 - getKey: (file: ChangedFile) => file.path, 50 - }), 51 - });
-54
apps/desktop/src/data/collections/revision-diffs.ts
··· 1 - import { createCollection } from "@tanstack/db"; 2 - import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 - import { getRevisionDiff } from "@/tauri-commands"; 4 - import { queryClient } from "../query-client"; 5 - 6 - // ============================================================================ 7 - // Revision Diff Collections (diff string per revision) 8 - // ============================================================================ 9 - 10 - // Wrapper type for diff string to work with collection pattern 11 - interface DiffEntry { 12 - id: "diff"; 13 - content: string; 14 - } 15 - 16 - const revisionDiffCollections = new Map<string, ReturnType<typeof createRevisionDiffCollection>>(); 17 - 18 - function createRevisionDiffCollection(repoPath: string, changeId: string) { 19 - return createCollection({ 20 - ...queryCollectionOptions({ 21 - queryClient, 22 - queryKey: ["revision-diff", repoPath, changeId], 23 - queryFn: async () => { 24 - const diff = await getRevisionDiff(repoPath, changeId); 25 - return [{ id: "diff" as const, content: diff }]; 26 - }, 27 - getKey: (entry: DiffEntry) => entry.id, 28 - }), 29 - }); 30 - } 31 - 32 - export type RevisionDiffCollection = ReturnType<typeof createRevisionDiffCollection>; 33 - 34 - export function getRevisionDiffCollection( 35 - repoPath: string, 36 - changeId: string, 37 - ): RevisionDiffCollection { 38 - const cacheKey = `${repoPath}:${changeId}`; 39 - let collection = revisionDiffCollections.get(cacheKey); 40 - if (!collection) { 41 - collection = createRevisionDiffCollection(repoPath, changeId); 42 - revisionDiffCollections.set(cacheKey, collection); 43 - } 44 - return collection; 45 - } 46 - 47 - export const emptyDiffCollection = createCollection({ 48 - ...queryCollectionOptions({ 49 - queryClient, 50 - queryKey: ["revision-diff", "empty"], 51 - queryFn: () => Promise.resolve([]), 52 - getKey: (entry: DiffEntry) => entry.id, 53 - }), 54 - });
+47 -17
apps/desktop/src/data/prefetch.ts
··· 1 - import { getRevisionChangesCollection } from "./collections/revision-changes"; 2 - import { getRevisionDiffCollection } from "./collections/revision-diffs"; 3 - 4 - // ============================================================================ 5 - // Prefetching Utilities 6 - // ============================================================================ 1 + import { Effect } from "effect"; 2 + import { queryClient } from "./query-client"; 3 + import { 4 + revisionChangesQueryKey, 5 + revisionDiffQueryKey, 6 + type ChangeRecord, 7 + type DiffRecord, 8 + } from "@/hooks/useRevisionData"; 9 + import { getChangesBatchEffect, getDiffsBatchEffect } from "@/tauri-commands"; 7 10 8 11 /** 9 - * Prefetch revision diffs for a batch of change IDs. 10 - * This eagerly creates collections which triggers the query fetch. 11 - * TanStack DB handles caching - subsequent calls are no-ops. 12 + * Prefetch revision diffs into TanStack Query's payload cache. 12 13 */ 13 14 export function prefetchRevisionDiffs(repoPath: string, changeIds: string[]): void { 14 - // Just trigger the data fetch for all revisions 15 - for (const changeId of changeIds) { 16 - getRevisionDiffCollection(repoPath, changeId); 17 - } 15 + const missing = changeIds.filter( 16 + (changeId) => !queryClient.getQueryData<DiffRecord>(revisionDiffQueryKey(repoPath, changeId)), 17 + ); 18 + if (missing.length === 0) return; 19 + 20 + void Effect.runPromise(getDiffsBatchEffect(repoPath, missing)).then((diffs) => { 21 + for (const diff of diffs) { 22 + queryClient.setQueryData(revisionDiffQueryKey(repoPath, diff.change_id), { 23 + repoPath, 24 + changeId: diff.change_id, 25 + content: diff.diff, 26 + } satisfies DiffRecord); 27 + } 28 + }); 18 29 } 19 30 20 31 /** 21 - * Prefetch revision changes (file list) for a batch of change IDs. 32 + * Prefetch revision changed-file lists into TanStack Query's payload cache. 22 33 */ 23 34 export function prefetchRevisionChanges(repoPath: string, changeIds: string[]): void { 24 - for (const changeId of changeIds) { 25 - getRevisionChangesCollection(repoPath, changeId); 26 - } 35 + const missing = changeIds.filter( 36 + (changeId) => 37 + !queryClient.getQueryData<ChangeRecord[]>(revisionChangesQueryKey(repoPath, changeId)), 38 + ); 39 + if (missing.length === 0) return; 40 + 41 + void Effect.runPromise(getChangesBatchEffect(repoPath, missing)).then((changesList) => { 42 + for (const changes of changesList) { 43 + queryClient.setQueryData( 44 + revisionChangesQueryKey(repoPath, changes.change_id), 45 + changes.files.map( 46 + (file) => 47 + ({ 48 + repoPath, 49 + changeId: changes.change_id, 50 + path: file.path, 51 + status: file.status, 52 + }) satisfies ChangeRecord, 53 + ), 54 + ); 55 + } 56 + }); 27 57 }
+6 -16
apps/desktop/src/db.ts
··· 26 26 type RevisionsCollection, 27 27 squashRevision, 28 28 } from "./data/collections/revisions"; 29 - export { 30 - emptyChangesCollection, 31 - getRevisionChangesCollection, 32 - type RevisionChangesCollection, 33 - } from "./data/collections/revision-changes"; 34 - export { 35 - emptyDiffCollection, 36 - getRevisionDiffCollection, 37 - type RevisionDiffCollection, 38 - } from "./data/collections/revision-diffs"; 39 29 export { prefetchRevisionChanges, prefetchRevisionDiffs } from "./data/prefetch"; 40 30 export { 31 + revisionChangesQueryKey, 32 + revisionDiffQueryKey, 33 + type ChangeRecord, 34 + type DiffRecord, 35 + } from "./hooks/useRevisionData"; 36 + export { 41 37 emptyCommitRecencyCollection, 42 38 getCommitRecencyCollection, 43 39 type CommitRecencyCollection, 44 40 } from "./data/collections/commit-recency"; 45 - export { 46 - changesCollection, 47 - type ChangeRecord, 48 - type ChangesCollection, 49 - } from "./data/collections/changes"; 50 - export { diffsCollection, type DiffRecord, type DiffsCollection } from "./data/collections/diffs"; 51 41 export { 52 42 emptyOperationsCollection, 53 43 getOperationKey,
+125 -242
apps/desktop/src/hooks/useRevisionData.ts
··· 1 1 /** 2 - * Revision data hooks for diffs and changes. 2 + * Revision payload hooks for diffs and changed-file lists. 3 3 * 4 - * Provides React hooks that connect BatchLoader instances to TanStack DB collections, 5 - * enabling efficient batched fetching with instant reads from local state. 4 + * Entity state lives in TanStack DB collections, while large revision payloads 5 + * stay in TanStack Query. Query owns caching, prefetching, invalidation, and 6 + * retry state for diff strings and changed-file arrays. 6 7 */ 7 8 8 - import { and, eq, useLiveQuery, useLiveSuspenseQuery } from "@tanstack/react-db"; 9 - import { useMemo, useRef } from "react"; 10 - import { type ChangeRecord, changesCollection, type DiffRecord, diffsCollection } from "@/db"; 11 - import { type BatchLoader, createBatchLoader } from "@/lib/batch-loader"; 9 + import { Effect } from "effect"; 10 + import { useQuery } from "@tanstack/react-query"; 11 + import { useMemo } from "react"; 12 + import { queryClient } from "@/data/query-client"; 12 13 import { traceLog } from "@/lib/trace"; 13 14 import { 14 15 getChangesBatchEffect, 15 16 getDiffsBatchEffect, 17 + getRevisionChanges, 18 + getRevisionDiff, 16 19 type RevisionChanges, 17 20 type RevisionDiff, 18 21 } from "@/tauri-commands"; 19 22 20 - // ============================================================================ 21 - // Loader Factory Functions 22 - // ============================================================================ 23 - 24 - /** 25 - * Creates a BatchLoader for revision diffs. 26 - * The loader batches IPC calls and syncs results to the unified diffsCollection. 27 - */ 28 - function createDiffLoader(repoPath: string): BatchLoader { 29 - return createBatchLoader<RevisionDiff>({ 30 - debounceMs: 50, 31 - maxBatchSize: 20, 32 - fetchBatch: (ids) => getDiffsBatchEffect(repoPath, ids), 33 - syncToCollection: (diffs) => { 34 - // Wait for collection to be ready before writing 35 - if (diffsCollection.status !== "ready") return; 36 - const records: DiffRecord[] = diffs.map((d) => ({ 37 - repoPath, 38 - changeId: d.change_id, 39 - content: d.diff, 40 - })); 41 - diffsCollection.utils.writeUpsert(records); 42 - 43 - // LRU eviction: keep only last 100 diffs 44 - const MAX_DIFFS = 100; 45 - const allRecords = Array.from(diffsCollection.state.values()); 46 - if (allRecords.length > MAX_DIFFS) { 47 - // Remove oldest entries (first ones in the Map iteration order) 48 - const toRemove = allRecords.slice(0, allRecords.length - MAX_DIFFS); 49 - for (const record of toRemove) { 50 - const key = `${record.repoPath}:${record.changeId}`; 51 - diffsCollection.state.delete(key); 52 - } 53 - } 54 - }, 55 - isLoaded: (id) => { 56 - if (diffsCollection.status !== "ready") return false; 57 - const key = `${repoPath}:${id}`; 58 - return diffsCollection.state.has(key); 59 - }, 60 - }); 23 + export interface DiffRecord { 24 + repoPath: string; 25 + changeId: string; 26 + content: string; 61 27 } 62 28 63 - /** 64 - * Creates a BatchLoader for revision changes (file lists). 65 - * The loader batches IPC calls and syncs results to the unified changesCollection. 66 - */ 67 - function createChangesLoader(repoPath: string): BatchLoader { 68 - return createBatchLoader<RevisionChanges>({ 69 - debounceMs: 50, 70 - maxBatchSize: 20, 71 - fetchBatch: (ids) => getChangesBatchEffect(repoPath, ids), 72 - syncToCollection: (changesList) => { 73 - // Wait for collection to be ready before writing 74 - if (changesCollection.status !== "ready") return; 75 - const records: ChangeRecord[] = []; 76 - for (const changes of changesList) { 77 - for (const file of changes.files) { 78 - records.push({ 79 - repoPath, 80 - changeId: changes.change_id, 81 - path: file.path, 82 - status: file.status, 83 - }); 84 - } 85 - } 86 - changesCollection.utils.writeUpsert(records); 87 - 88 - // LRU eviction: keep only last 500 change records 89 - const MAX_CHANGES = 500; 90 - const allRecords = Array.from(changesCollection.state.values()); 91 - if (allRecords.length > MAX_CHANGES) { 92 - // Remove oldest entries (first ones in the Map iteration order) 93 - const toRemove = allRecords.slice(0, allRecords.length - MAX_CHANGES); 94 - for (const record of toRemove) { 95 - const key = `${record.repoPath}:${record.changeId}:${record.path}`; 96 - changesCollection.state.delete(key); 97 - } 98 - } 99 - }, 100 - isLoaded: (id) => { 101 - // Wait for collection to be ready 102 - if (changesCollection.status !== "ready") return false; 103 - // Check if we have any record for this changeId in this repo 104 - for (const [key] of changesCollection.state) { 105 - if (key.startsWith(`${repoPath}:${id}:`)) { 106 - return true; 107 - } 108 - } 109 - return false; 110 - }, 111 - }); 29 + export interface ChangeRecord { 30 + repoPath: string; 31 + changeId: string; 32 + path: string; 33 + status: "added" | "modified" | "deleted"; 112 34 } 113 35 114 - // ============================================================================ 115 - // Loader Instance Cache 116 - // ============================================================================ 36 + export function revisionDiffQueryKey(repoPath: string, changeId: string) { 37 + return ["revision-diff", repoPath, changeId] as const; 38 + } 117 39 118 - // Cache loaders per repoPath to avoid creating multiple instances 119 - const diffLoaders = new Map<string, BatchLoader>(); 120 - const changesLoaders = new Map<string, BatchLoader>(); 40 + export function revisionChangesQueryKey(repoPath: string, changeId: string) { 41 + return ["revision-changes", repoPath, changeId] as const; 42 + } 121 43 122 - /** 123 - * Clean up loaders for repos we're no longer viewing. 124 - * Called when the active repoPath changes to prevent memory leaks. 125 - */ 126 - function cleanupLoadersExcept(currentRepoPath: string): void { 127 - for (const [path] of diffLoaders) { 128 - if (path !== currentRepoPath) { 129 - diffLoaders.delete(path); 130 - } 131 - } 132 - for (const [path] of changesLoaders) { 133 - if (path !== currentRepoPath) { 134 - changesLoaders.delete(path); 135 - } 136 - } 44 + function toDiffRecord(repoPath: string, diff: RevisionDiff): DiffRecord { 45 + return { 46 + repoPath, 47 + changeId: diff.change_id, 48 + content: diff.diff, 49 + }; 137 50 } 138 51 139 - function getDiffLoader(repoPath: string): BatchLoader { 140 - let loader = diffLoaders.get(repoPath); 141 - if (!loader) { 142 - loader = createDiffLoader(repoPath); 143 - diffLoaders.set(repoPath, loader); 144 - } 145 - return loader; 52 + function toChangeRecords(repoPath: string, changes: RevisionChanges): ChangeRecord[] { 53 + return changes.files.map((file) => ({ 54 + repoPath, 55 + changeId: changes.change_id, 56 + path: file.path, 57 + status: file.status, 58 + })); 146 59 } 147 60 148 - function getChangesLoader(repoPath: string): BatchLoader { 149 - let loader = changesLoaders.get(repoPath); 150 - if (!loader) { 151 - loader = createChangesLoader(repoPath); 152 - changesLoaders.set(repoPath, loader); 153 - } 154 - return loader; 61 + async function fetchDiff(repoPath: string, changeId: string): Promise<DiffRecord> { 62 + const content = await getRevisionDiff(repoPath, changeId); 63 + return { repoPath, changeId, content }; 155 64 } 156 65 157 - // ============================================================================ 158 - // React Hooks 159 - // ============================================================================ 66 + async function fetchChanges(repoPath: string, changeId: string): Promise<ChangeRecord[]> { 67 + const files = await getRevisionChanges(repoPath, changeId); 68 + return files.map((file) => ({ repoPath, changeId, path: file.path, status: file.status })); 69 + } 160 70 161 71 /** 162 - * Read a single diff from local DB. Returns undefined if not yet loaded. 163 - * Call prefetchDiffs to trigger loading. 72 + * Read a single revision diff from TanStack Query. 164 73 */ 165 74 export function useDiff(repoPath: string, changeId: string | null): DiffRecord | undefined { 166 - // Use selective query - only re-renders when THIS specific diff changes 167 - const { data } = useLiveQuery( 168 - (q) => 169 - changeId 170 - ? q 171 - .from({ diffs: diffsCollection }) 172 - .where(({ diffs }) => and(eq(diffs.repoPath, repoPath), eq(diffs.changeId, changeId))) 173 - .findOne() 174 - : null, 175 - [repoPath, changeId], 176 - ); 75 + const { data } = useQuery({ 76 + queryKey: changeId 77 + ? revisionDiffQueryKey(repoPath, changeId) 78 + : ["revision-diff", repoPath, null], 79 + queryFn: () => fetchDiff(repoPath, changeId ?? ""), 80 + enabled: !!repoPath && !!changeId, 81 + staleTime: Number.POSITIVE_INFINITY, 82 + retry: false, 83 + }); 177 84 178 85 return data; 179 86 } 180 87 181 88 /** 182 - * Read changes (file list) for a revision from local DB. 183 - * Returns { data, isLoaded } to distinguish between "loading" and "genuinely empty". 184 - * Call prefetchChanges to trigger loading. 89 + * Read changed files for a revision from TanStack Query. 185 90 */ 186 91 export function useChanges( 187 92 repoPath: string, 188 93 changeId: string | null, 189 94 ): { data: ChangeRecord[]; isLoaded: boolean } { 190 - // Use selective query - only re-renders when changes for THIS revision update 191 - const { data = [], isReady } = useLiveQuery( 192 - (q) => 193 - changeId 194 - ? q 195 - .from({ changes: changesCollection }) 196 - .where(({ changes }) => 197 - and(eq(changes.repoPath, repoPath), eq(changes.changeId, changeId)), 198 - ) 199 - : null, 200 - [repoPath, changeId], 201 - ); 202 - 203 - // isLoaded is true when: 204 - // 1. No changeId requested (nothing to load) 205 - // 2. Query is ready AND we have data (data was fetched) 206 - // 3. Query is ready AND data is empty but was explicitly fetched 207 - // We track "explicitly fetched" by checking if the changeId exists in the collection's loaded set 208 - // For now, we use isReady as a proxy - if the query ran and returned, the data is loaded 209 - const isLoaded = !changeId || isReady; 95 + const { data = [], isSuccess } = useQuery({ 96 + queryKey: changeId 97 + ? revisionChangesQueryKey(repoPath, changeId) 98 + : ["revision-changes", repoPath, null], 99 + queryFn: () => fetchChanges(repoPath, changeId ?? ""), 100 + enabled: !!repoPath && !!changeId, 101 + staleTime: Number.POSITIVE_INFINITY, 102 + retry: false, 103 + }); 210 104 211 - return { data, isLoaded }; 105 + return { data, isLoaded: !changeId || isSuccess }; 212 106 } 213 - 214 - // ============================================================================ 215 - // Suspense-enabled Hooks 216 - // ============================================================================ 217 107 218 108 /** 219 - * Read a single diff from local DB with Suspense support. 220 - * Throws a promise when data isn't ready, caught by Suspense boundary. 221 - * Data is guaranteed to be defined when returned. 222 - * 223 - * @throws Promise when data is loading (caught by Suspense boundary) 109 + * Suspense-compatible diff hook. Data may still be undefined if called without a 110 + * matching prefetched/cache entry, preserving the previous public shape. 224 111 */ 225 112 export function useDiffSuspense(repoPath: string, changeId: string): DiffRecord | undefined { 226 - const { data } = useLiveSuspenseQuery( 227 - (q) => 228 - q 229 - .from({ diffs: diffsCollection }) 230 - .where(({ diffs }) => and(eq(diffs.repoPath, repoPath), eq(diffs.changeId, changeId))) 231 - .findOne(), 232 - [repoPath, changeId], 233 - ); 113 + const { data } = useQuery({ 114 + queryKey: revisionDiffQueryKey(repoPath, changeId), 115 + queryFn: () => fetchDiff(repoPath, changeId), 116 + enabled: !!repoPath && !!changeId, 117 + staleTime: Number.POSITIVE_INFINITY, 118 + retry: false, 119 + }); 234 120 235 121 traceLog("useDiffSuspense", { changeId, hasData: !!data }); 236 - 237 122 return data; 238 123 } 239 124 240 125 /** 241 - * Read changes (file list) for a revision from local DB with Suspense support. 242 - * Throws a promise when data isn't ready, caught by Suspense boundary. 243 - * Data is guaranteed to be defined when returned. 244 - * 245 - * @throws Promise when data is loading (caught by Suspense boundary) 126 + * Suspense-compatible changed-file hook. 246 127 */ 247 128 export function useChangesSuspense(repoPath: string, changeId: string): ChangeRecord[] { 248 - const { data } = useLiveSuspenseQuery( 249 - (q) => 250 - q 251 - .from({ changes: changesCollection }) 252 - .where(({ changes }) => 253 - and(eq(changes.repoPath, repoPath), eq(changes.changeId, changeId)), 254 - ), 255 - [repoPath, changeId], 256 - ); 129 + const { data = [] } = useQuery({ 130 + queryKey: revisionChangesQueryKey(repoPath, changeId), 131 + queryFn: () => fetchChanges(repoPath, changeId), 132 + enabled: !!repoPath && !!changeId, 133 + staleTime: Number.POSITIVE_INFINITY, 134 + retry: false, 135 + }); 257 136 258 137 traceLog("useChangesSuspense", { changeId, fileCount: data.length }); 259 - 260 138 return data; 261 139 } 262 140 263 141 /** 264 - * Read lineage (related revision IDs) for a revision from local DB. 265 - * Returns { lineage, isLoaded } to allow callers to handle loading state. 266 - * 267 - * Stub: lineage calculations disabled - always returns empty/loaded. 142 + * Lineage calculations are currently disabled. 268 143 */ 269 144 export function useLineage( 270 145 _repoPath: string, ··· 273 148 return { lineage: new Set<string>(), isLoaded: true }; 274 149 } 275 150 151 + async function prefetchDiffBatch(repoPath: string, ids: string[]): Promise<void> { 152 + const missing = ids.filter( 153 + (id) => !queryClient.getQueryData<DiffRecord>(revisionDiffQueryKey(repoPath, id)), 154 + ); 155 + if (missing.length === 0) return; 156 + 157 + const diffs = await Effect.runPromise(getDiffsBatchEffect(repoPath, missing)); 158 + 159 + for (const diff of diffs) { 160 + queryClient.setQueryData( 161 + revisionDiffQueryKey(repoPath, diff.change_id), 162 + toDiffRecord(repoPath, diff), 163 + ); 164 + } 165 + } 166 + 167 + async function prefetchChangesBatch(repoPath: string, ids: string[]): Promise<void> { 168 + const missing = ids.filter( 169 + (id) => !queryClient.getQueryData<ChangeRecord[]>(revisionChangesQueryKey(repoPath, id)), 170 + ); 171 + if (missing.length === 0) return; 172 + 173 + const changesList = await Effect.runPromise(getChangesBatchEffect(repoPath, missing)); 174 + 175 + for (const changes of changesList) { 176 + queryClient.setQueryData( 177 + revisionChangesQueryKey(repoPath, changes.change_id), 178 + toChangeRecords(repoPath, changes), 179 + ); 180 + } 181 + } 182 + 276 183 /** 277 - * Prefetch hook for components that need to load data ahead of user interaction. 278 - * Returns functions to queue prefetch requests and flush them immediately if needed. 184 + * Prefetch hook for components that need to load payloads ahead of interaction. 279 185 */ 280 186 export function usePrefetch(repoPath: string): { 281 187 prefetchDiffs: (ids: string[]) => void; ··· 285 191 flushChanges: () => Promise<void>; 286 192 flushLineage: () => Promise<void>; 287 193 } { 288 - // Use refs to avoid recreating loaders on each render 289 - const diffLoaderRef = useRef<BatchLoader | null>(null); 290 - const changesLoaderRef = useRef<BatchLoader | null>(null); 291 - const currentRepoPathRef = useRef<string>(repoPath); 292 - 293 - // Reset loaders if repoPath changes 294 - if (currentRepoPathRef.current !== repoPath) { 295 - currentRepoPathRef.current = repoPath; 296 - diffLoaderRef.current = null; 297 - changesLoaderRef.current = null; 298 - // Clean up loaders for other repos to prevent memory leaks 299 - cleanupLoadersExcept(repoPath); 300 - } 301 - 302 194 // ast-grep-ignore: no-react-memoization 303 195 return useMemo(() => { 304 - function getDiffLoaderInstance(): BatchLoader { 305 - if (!diffLoaderRef.current) { 306 - diffLoaderRef.current = getDiffLoader(repoPath); 307 - } 308 - return diffLoaderRef.current; 309 - } 310 - 311 - function getChangesLoaderInstance(): BatchLoader { 312 - if (!changesLoaderRef.current) { 313 - changesLoaderRef.current = getChangesLoader(repoPath); 314 - } 315 - return changesLoaderRef.current; 316 - } 196 + let pendingDiffFlush: Promise<void> = Promise.resolve(); 197 + let pendingChangesFlush: Promise<void> = Promise.resolve(); 317 198 318 199 return { 319 200 prefetchDiffs: (ids: string[]) => { 201 + if (!repoPath || ids.length === 0) return; 320 202 traceLog("prefetch-diffs", { count: ids.length, ids }); 321 - getDiffLoaderInstance().queueMany(ids); 203 + pendingDiffFlush = prefetchDiffBatch(repoPath, ids); 322 204 }, 323 205 prefetchChanges: (ids: string[]) => { 206 + if (!repoPath || ids.length === 0) return; 324 207 traceLog("prefetch-changes", { count: ids.length, ids }); 325 - getChangesLoaderInstance().queueMany(ids); 208 + pendingChangesFlush = prefetchChangesBatch(repoPath, ids); 326 209 }, 327 210 prefetchLineage: (_ids: string[]) => {}, 328 - flushDiffs: () => getDiffLoaderInstance().flushPromise(), 329 - flushChanges: () => getChangesLoaderInstance().flushPromise(), 211 + flushDiffs: () => pendingDiffFlush, 212 + flushChanges: () => pendingChangesFlush, 330 213 flushLineage: () => Promise.resolve(), 331 214 }; 332 215 }, [repoPath]);