a very good jj gui
0
fork

Configure Feed

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

Plan 2: Split data layer into focused modules

Split the monolithic frontend db.ts into focused data modules while preserving the public @/db barrel API:

- data/query-client.ts owns the shared TanStack Query client.
- data/mutation-tracker.ts owns in-flight mutation tracking.
- data/change-id-pool.ts owns preallocated jj change IDs.
- data/watchers.ts owns repository watcher ref-counting and query invalidation.
- data/actions/repo-actions.ts owns repository sync behavior.
- data/collections/* owns TanStack DB collections for repositories, revisions, revision changes, revision diffs, commit recency, and unified diff/change records.
- data/prefetch.ts owns revision diff/change prefetch helpers.

No call sites were changed; existing imports continue to use @/db, which now re-exports from the new module structure.

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

+968 -900
+53
apps/desktop/src/data/actions/repo-actions.ts
··· 1 + import { toast } from "@/components/ui/sonner"; 2 + import { getRevisions, jjGitFetch, jjGitPush } from "@/tauri-commands"; 3 + import { invalidateRepositoryQueries } from "../watchers"; 4 + 5 + function isAuthError(errorText: string): boolean { 6 + const text = errorText.toLowerCase(); 7 + return ( 8 + text.includes("auth") || 9 + text.includes("authentication") || 10 + text.includes("permission denied") || 11 + text.includes("publickey") || 12 + text.includes("credential") || 13 + text.includes("forbidden") 14 + ); 15 + } 16 + 17 + export async function syncRepository(repoPath: string, preset?: string): Promise<void> { 18 + const limit = preset === "full_history" ? 10000 : 100; 19 + 20 + try { 21 + await jjGitFetch(repoPath, "origin"); 22 + await invalidateRepositoryQueries(repoPath); 23 + 24 + const revisions = await getRevisions(repoPath, limit, undefined, preset); 25 + const aheadBookmarks = Array.from( 26 + new Set( 27 + revisions.flatMap((revision) => 28 + revision.bookmarks 29 + .filter((bookmark) => bookmark.is_ahead) 30 + .map((bookmark) => bookmark.name), 31 + ), 32 + ), 33 + ); 34 + 35 + if (aheadBookmarks.length > 0) { 36 + await jjGitPush(repoPath, aheadBookmarks, "origin"); 37 + await invalidateRepositoryQueries(repoPath); 38 + } 39 + } catch (error) { 40 + const message = error instanceof Error ? error.message : String(error); 41 + if (isAuthError(message)) { 42 + toast.error("Sync failed: authentication error. Check SSH keys or credential helper.", { 43 + description: message, 44 + duration: Number.POSITIVE_INFINITY, 45 + }); 46 + return; 47 + } 48 + 49 + toast.error(`Sync failed: ${message}`, { 50 + duration: Number.POSITIVE_INFINITY, 51 + }); 52 + } 53 + }
+65
apps/desktop/src/data/change-id-pool.ts
··· 1 + import { generateChangeIds } from "@/tauri-commands"; 2 + import { queryClient } from "./query-client"; 3 + 4 + // ============================================================================ 5 + // Change ID Pool Collection (pre-allocated IDs for optimistic updates) 6 + // ============================================================================ 7 + 8 + const POOL_SIZE = 10; 9 + const POOL_REFILL_THRESHOLD = 3; 10 + 11 + interface ChangeIdPool { 12 + repoPath: string; 13 + ids: string[]; 14 + } 15 + 16 + function changeIdPoolQueryKey(repoPath: string) { 17 + return ["change-id-pool", repoPath] as const; 18 + } 19 + 20 + async function fetchChangeIdPool(repoPath: string): Promise<ChangeIdPool> { 21 + const ids = await generateChangeIds(repoPath, POOL_SIZE); 22 + return { repoPath, ids }; 23 + } 24 + 25 + /** Ensure the change ID pool is loaded. Call from router beforeLoad. */ 26 + export async function ensureChangeIdPool(repoPath: string): Promise<void> { 27 + // Fast path: if already cached, return immediately without async work 28 + const existing = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 29 + if (existing && existing.ids.length > 0) { 30 + return; 31 + } 32 + 33 + await queryClient.ensureQueryData({ 34 + queryKey: changeIdPoolQueryKey(repoPath), 35 + queryFn: () => fetchChangeIdPool(repoPath), 36 + }); 37 + } 38 + 39 + /** Consume a change ID from the pool, triggering refill if needed */ 40 + export function consumeChangeId(repoPath: string): string | null { 41 + const poolEntry = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 42 + if (!poolEntry || poolEntry.ids.length === 0) return null; 43 + 44 + const [id, ...remaining] = poolEntry.ids; 45 + 46 + // Update the cache directly 47 + queryClient.setQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath), { 48 + repoPath, 49 + ids: remaining, 50 + }); 51 + 52 + // Trigger refill if running low 53 + if (remaining.length < POOL_REFILL_THRESHOLD) { 54 + generateChangeIds(repoPath, POOL_SIZE).then((newIds) => { 55 + const current = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 56 + const currentIds = current?.ids ?? []; 57 + queryClient.setQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath), { 58 + repoPath, 59 + ids: [...currentIds, ...newIds], 60 + }); 61 + }); 62 + } 63 + 64 + return id; 65 + }
+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;
+55
apps/desktop/src/data/collections/commit-recency.ts
··· 1 + import { createCollection } from "@tanstack/db"; 2 + import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 + import { getCommitRecency } from "@/tauri-commands"; 4 + import { queryClient } from "../query-client"; 5 + 6 + // ============================================================================ 7 + // Commit Recency Collection (for branch ordering) 8 + // ============================================================================ 9 + 10 + // Wrapper type for commit recency data to work with collection pattern 11 + interface CommitRecencyEntry { 12 + id: "recency"; 13 + data: Record<string, number>; 14 + } 15 + 16 + const commitRecencyCollections = new Map< 17 + string, 18 + ReturnType<typeof createCommitRecencyCollection> 19 + >(); 20 + 21 + function createCommitRecencyCollection(repoPath: string) { 22 + return createCollection({ 23 + ...queryCollectionOptions({ 24 + queryClient, 25 + queryKey: ["commit-recency", repoPath], 26 + queryFn: async () => { 27 + const recency = await getCommitRecency(repoPath, 500); 28 + return [{ id: "recency" as const, data: recency }]; 29 + }, 30 + getKey: (entry: CommitRecencyEntry) => entry.id, 31 + staleTime: 30_000, // 30 seconds - this one uses time-based staleness 32 + }), 33 + }); 34 + } 35 + 36 + export type CommitRecencyCollection = ReturnType<typeof createCommitRecencyCollection>; 37 + 38 + export function getCommitRecencyCollection(repoPath: string): CommitRecencyCollection { 39 + const cacheKey = repoPath; 40 + let collection = commitRecencyCollections.get(cacheKey); 41 + if (!collection) { 42 + collection = createCommitRecencyCollection(repoPath); 43 + commitRecencyCollections.set(cacheKey, collection); 44 + } 45 + return collection; 46 + } 47 + 48 + export const emptyCommitRecencyCollection = createCollection({ 49 + ...queryCollectionOptions({ 50 + queryClient, 51 + queryKey: ["commit-recency", "empty"], 52 + queryFn: () => Promise.resolve([]), 53 + getKey: (entry: CommitRecencyEntry) => entry.id, 54 + }), 55 + });
+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;
+81
apps/desktop/src/data/collections/repositories.ts
··· 1 + import { createCollection } from "@tanstack/db"; 2 + import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 + import type { Repository } from "@/tauri-commands"; 4 + import { getRepositories, removeRepository, upsertRepository } from "@/tauri-commands"; 5 + import { queryClient } from "../query-client"; 6 + 7 + // ============================================================================ 8 + // Repositories Collection 9 + // ============================================================================ 10 + 11 + const repositoriesQueryKey = ["repositories"] as const; 12 + 13 + export const repositoriesCollection = createCollection({ 14 + ...queryCollectionOptions({ 15 + queryClient, 16 + queryKey: repositoriesQueryKey, 17 + queryFn: getRepositories, 18 + getKey: (repository: Repository) => repository.id, 19 + }), 20 + }); 21 + 22 + export type RepositoriesCollection = typeof repositoriesCollection; 23 + 24 + /** Ensure repositories are loaded. Returns the list. */ 25 + export async function ensureRepositories(): Promise<Repository[]> { 26 + return queryClient.ensureQueryData({ 27 + queryKey: repositoriesQueryKey, 28 + queryFn: getRepositories, 29 + }); 30 + } 31 + 32 + export async function addRepository(collection: RepositoriesCollection, repository: Repository) { 33 + // Optimistic update first 34 + collection.utils.writeUpsert([repository]); 35 + 36 + try { 37 + await upsertRepository(repository); 38 + } catch (err) { 39 + // Revert on failure 40 + collection.utils.writeDelete(repository.id); 41 + throw err; 42 + } 43 + } 44 + 45 + export async function updateRepository(collection: RepositoriesCollection, repository: Repository) { 46 + // Get current state for potential revert 47 + const current = collection.state.get(repository.id); 48 + 49 + // Optimistic update 50 + collection.utils.writeUpsert([repository]); 51 + 52 + try { 53 + await upsertRepository(repository); 54 + } catch (err) { 55 + // Revert on failure 56 + if (current) { 57 + collection.utils.writeUpsert([current]); 58 + } else { 59 + collection.utils.writeDelete(repository.id); 60 + } 61 + throw err; 62 + } 63 + } 64 + 65 + export async function deleteRepository(collection: RepositoriesCollection, repositoryId: string) { 66 + // Get current state for potential revert 67 + const current = collection.state.get(repositoryId); 68 + 69 + // Optimistic delete 70 + collection.utils.writeDelete(repositoryId); 71 + 72 + try { 73 + await removeRepository(repositoryId); 74 + } catch (err) { 75 + // Revert on failure 76 + if (current) { 77 + collection.utils.writeUpsert([current]); 78 + } 79 + throw err; 80 + } 81 + }
+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 + });
+376
apps/desktop/src/data/collections/revisions.ts
··· 1 + import { createCollection } from "@tanstack/db"; 2 + import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 + import { Effect } from "effect"; 4 + import { toast } from "@/components/ui/sonner"; 5 + import type { Revision } from "@/tauri-commands"; 6 + import { 7 + getRevisions, 8 + jjAbandon, 9 + jjDescribe, 10 + jjEdit, 11 + jjNew, 12 + jjRebase, 13 + jjSquash, 14 + undoOperation, 15 + } from "@/tauri-commands"; 16 + import { consumeChangeId } from "../change-id-pool"; 17 + import { trackMutation } from "../mutation-tracker"; 18 + import { queryClient } from "../query-client"; 19 + import { invalidateRepositoryQueries } from "../watchers"; 20 + 21 + function mutationSuccessWithUndo( 22 + repoPath: string, 23 + operationId: string, 24 + title: string, 25 + description?: string, 26 + ) { 27 + toast.success(title, { 28 + description, 29 + action: { 30 + label: "Undo", 31 + onClick: () => { 32 + undoOperation(repoPath, operationId) 33 + .then(() => { 34 + void invalidateRepositoryQueries(repoPath); 35 + toast.success("Undo successful"); 36 + }) 37 + .catch((error) => { 38 + toast.error(`Undo failed: ${error}`, { duration: Number.POSITIVE_INFINITY }); 39 + }); 40 + }, 41 + }, 42 + }); 43 + } 44 + 45 + // ============================================================================ 46 + // Revisions Collection 47 + // ============================================================================ 48 + 49 + // Key function that handles divergent changes (same change_id, different commits) 50 + export function getRevisionKey(revision: Revision): string { 51 + if (revision.divergent_index != null) { 52 + return `${revision.change_id}/${revision.divergent_index}`; 53 + } 54 + return revision.change_id; 55 + } 56 + 57 + export const emptyRevisionsCollection = createCollection({ 58 + ...queryCollectionOptions({ 59 + queryClient, 60 + queryKey: ["revisions", "empty"], 61 + queryFn: () => Promise.resolve([]), 62 + getKey: getRevisionKey, 63 + }), 64 + }); 65 + 66 + const revisionCollections = new Map<string, ReturnType<typeof createRevisionsCollection>>(); 67 + 68 + function createRevisionsCollection(repoPath: string, preset?: string) { 69 + const limit = preset === "full_history" ? 10000 : 100; 70 + 71 + return createCollection({ 72 + ...queryCollectionOptions({ 73 + queryClient, 74 + queryKey: ["revisions", repoPath, preset], 75 + queryFn: () => getRevisions(repoPath, limit, undefined, preset), 76 + getKey: getRevisionKey, 77 + }), 78 + }); 79 + } 80 + 81 + export type RevisionsCollection = ReturnType<typeof createRevisionsCollection>; 82 + 83 + export function getRevisionsCollection(repoPath: string, preset?: string) { 84 + const cacheKey = `${repoPath}:${preset ?? "full_history"}`; 85 + let collection = revisionCollections.get(cacheKey); 86 + if (!collection) { 87 + collection = createRevisionsCollection(repoPath, preset); 88 + revisionCollections.set(cacheKey, collection); 89 + } 90 + return collection; 91 + } 92 + 93 + export function editRevision( 94 + collection: RevisionsCollection, 95 + repoPath: string, 96 + targetRevision: Revision, 97 + currentWcRevision: Revision | null, 98 + ) { 99 + const mutationId = `edit-${Date.now()}-${Math.random()}`; 100 + 101 + // Optimistic update 102 + const updates: Revision[] = []; 103 + if (currentWcRevision && getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision)) { 104 + updates.push({ ...currentWcRevision, is_working_copy: false }); 105 + } 106 + updates.push({ ...targetRevision, is_working_copy: true }); 107 + collection.utils.writeUpsert(updates); 108 + 109 + // Track the mutation and fire backend 110 + trackMutation(mutationId, jjEdit(repoPath, targetRevision.change_id_short)) 111 + .then((_result) => { 112 + // Invalidate to get fresh data from backend 113 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 114 + toast.success(`Working copy is now ${targetRevision.change_id_short}`); 115 + }) 116 + .catch((error) => { 117 + // Revert optimistic update 118 + const revertUpdates: Revision[] = []; 119 + if ( 120 + currentWcRevision && 121 + getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision) 122 + ) { 123 + revertUpdates.push({ ...currentWcRevision, is_working_copy: true }); 124 + } 125 + revertUpdates.push({ ...targetRevision, is_working_copy: false }); 126 + collection.utils.writeUpsert(revertUpdates); 127 + toast.error(`Failed to edit revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 128 + }); 129 + } 130 + 131 + export function newRevision( 132 + collection: RevisionsCollection, 133 + repoPath: string, 134 + parentChangeIds: string[], 135 + parentRevision: Revision, 136 + currentWcRevision: Revision | null, 137 + ) { 138 + const mutationId = `new-${Date.now()}-${Math.random()}`; 139 + const preAllocatedChangeId = consumeChangeId(repoPath); 140 + 141 + // Create optimistic revision if we have a pre-allocated change ID 142 + let optimisticRevision: Revision | null = null; 143 + if (preAllocatedChangeId) { 144 + optimisticRevision = { 145 + commit_id: `pending-${preAllocatedChangeId}`, // Temporary, will be replaced 146 + change_id: preAllocatedChangeId, 147 + change_id_short: preAllocatedChangeId.slice(0, 8), // Approximate short ID 148 + parent_edges: [{ parent_id: parentRevision.commit_id, edge_type: "direct" as const }], 149 + children_ids: [], 150 + description: "", 151 + author: parentRevision.author, // Inherit from parent 152 + timestamp: new Date().toISOString(), 153 + is_working_copy: true, 154 + is_immutable: false, 155 + is_mine: true, 156 + is_trunk: false, 157 + is_divergent: false, 158 + divergent_index: null, 159 + has_conflict: false, 160 + bookmarks: [], 161 + }; 162 + 163 + // Optimistic update: clear WC from current, insert new revision 164 + const updates: Revision[] = []; 165 + if (currentWcRevision) { 166 + updates.push({ ...currentWcRevision, is_working_copy: false }); 167 + } 168 + updates.push(optimisticRevision); 169 + collection.utils.writeUpsert(updates); 170 + } 171 + 172 + // Fire backend call 173 + const program = Effect.tryPromise({ 174 + try: () => jjNew(repoPath, parentChangeIds, preAllocatedChangeId ?? undefined), 175 + catch: (error) => new Error(`Failed to create new revision: ${error}`), 176 + }).pipe(Effect.tapError((error) => Effect.logError("jjNew failed", error))); 177 + 178 + trackMutation(mutationId, Effect.runPromise(program)) 179 + .then((result) => { 180 + // Invalidate to get authoritative data (correct commit_id, short_id, etc.) 181 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 182 + const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 183 + toast.success(`Working copy is now ${shortId}`, { 184 + description: "Created new revision", 185 + }); 186 + }) 187 + .catch((error) => { 188 + // Revert optimistic update 189 + if (optimisticRevision) { 190 + collection.utils.writeDelete(getRevisionKey(optimisticRevision)); 191 + if (currentWcRevision) { 192 + collection.utils.writeUpsert([{ ...currentWcRevision, is_working_copy: true }]); 193 + } 194 + } 195 + toast.error(`Failed to create revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 196 + }); 197 + } 198 + 199 + export function abandonRevision( 200 + collection: RevisionsCollection, 201 + repoPath: string, 202 + revision: Revision, 203 + ) { 204 + const mutationId = `abandon-${Date.now()}-${Math.random()}`; 205 + 206 + // For working copy, jj creates a new WC - can't do optimistic delete 207 + // For other revisions, we can optimistically remove 208 + if (!revision.is_working_copy) { 209 + collection.utils.writeDelete(getRevisionKey(revision)); 210 + } 211 + 212 + // Track the mutation and fire backend 213 + trackMutation(mutationId, jjAbandon(repoPath, revision.change_id_short)) 214 + .then((result) => { 215 + // Invalidate to get fresh data (especially for WC abandon which creates new WC) 216 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 217 + toast.success(`Abandoned revision ${revision.change_id_short}`, { 218 + action: { 219 + label: "Undo", 220 + onClick: () => { 221 + undoOperation(repoPath, result.operation_id) 222 + .then(() => { 223 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 224 + toast.success("Undo successful"); 225 + }) 226 + .catch((err) => { 227 + toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 228 + }); 229 + }, 230 + }, 231 + }); 232 + }) 233 + .catch((error) => { 234 + // Re-add on failure (only if we deleted it) 235 + if (!revision.is_working_copy) { 236 + collection.utils.writeUpsert([revision]); 237 + } 238 + toast.error(`Failed to abandon revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 239 + }); 240 + } 241 + 242 + export function describeRevision( 243 + collection: RevisionsCollection, 244 + repoPath: string, 245 + revision: Revision, 246 + description: string, 247 + ) { 248 + const mutationId = `describe-${Date.now()}-${Math.random()}`; 249 + const previousDescription = revision.description; 250 + 251 + // Optimistic update 252 + collection.utils.writeUpsert([{ ...revision, description }]); 253 + 254 + trackMutation(mutationId, jjDescribe(repoPath, revision.change_id_short, description)) 255 + .then((result) => { 256 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 257 + toast.success(`Updated description for ${revision.change_id_short}`, { 258 + action: { 259 + label: "Undo", 260 + onClick: () => { 261 + undoOperation(repoPath, result.operation_id) 262 + .then(() => { 263 + queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 264 + toast.success("Undo successful"); 265 + }) 266 + .catch((err) => { 267 + toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 268 + }); 269 + }, 270 + }, 271 + }); 272 + }) 273 + .catch((error) => { 274 + // Revert optimistic update on failure 275 + collection.utils.writeUpsert([{ ...revision, description: previousDescription }]); 276 + toast.error(`Failed to update description: ${error}`, { 277 + duration: Number.POSITIVE_INFINITY, 278 + }); 279 + }); 280 + } 281 + 282 + export function squashRevision( 283 + collection: RevisionsCollection, 284 + repoPath: string, 285 + revision: Revision, 286 + ) { 287 + if (revision.is_immutable) { 288 + toast.error("Cannot squash immutable revision", { duration: Number.POSITIVE_INFINITY }); 289 + return; 290 + } 291 + if (revision.parent_edges.length === 0) { 292 + toast.error("Cannot squash root revision", { duration: Number.POSITIVE_INFINITY }); 293 + return; 294 + } 295 + if (revision.parent_edges.length > 1) { 296 + toast.error("Cannot squash merge revision with multiple parents", { 297 + duration: Number.POSITIVE_INFINITY, 298 + }); 299 + return; 300 + } 301 + 302 + const mutationId = `squash-${Date.now()}-${Math.random()}`; 303 + const shouldOptimisticallyDelete = !revision.is_working_copy; 304 + 305 + if (shouldOptimisticallyDelete) { 306 + collection.utils.writeDelete(getRevisionKey(revision)); 307 + } 308 + 309 + trackMutation(mutationId, jjSquash(repoPath, revision.change_id)) 310 + .then((result) => { 311 + void invalidateRepositoryQueries(repoPath); 312 + mutationSuccessWithUndo( 313 + repoPath, 314 + result.operation_id, 315 + `Squashed ${revision.change_id_short} into parent`, 316 + ); 317 + }) 318 + .catch((error) => { 319 + if (shouldOptimisticallyDelete) { 320 + collection.utils.writeUpsert([revision]); 321 + } 322 + toast.error(`Failed to squash revision: ${error}`, { 323 + duration: Number.POSITIVE_INFINITY, 324 + }); 325 + }); 326 + } 327 + 328 + export function rebaseRevision( 329 + collection: RevisionsCollection, 330 + repoPath: string, 331 + sourceRevision: Revision, 332 + destinationRevision: Revision, 333 + ) { 334 + if (sourceRevision.is_immutable) { 335 + toast.error("Cannot rebase immutable revision", { duration: Number.POSITIVE_INFINITY }); 336 + return; 337 + } 338 + if (sourceRevision.change_id === destinationRevision.change_id) { 339 + toast.error("Cannot rebase revision onto itself", { duration: Number.POSITIVE_INFINITY }); 340 + return; 341 + } 342 + 343 + const mutationId = `rebase-${Date.now()}-${Math.random()}`; 344 + const previousParentEdges = sourceRevision.parent_edges; 345 + 346 + collection.utils.writeUpsert([ 347 + { 348 + ...sourceRevision, 349 + parent_edges: [{ parent_id: destinationRevision.commit_id, edge_type: "direct" as const }], 350 + }, 351 + ]); 352 + 353 + trackMutation( 354 + mutationId, 355 + jjRebase(repoPath, sourceRevision.change_id, destinationRevision.change_id), 356 + ) 357 + .then((result) => { 358 + void invalidateRepositoryQueries(repoPath); 359 + mutationSuccessWithUndo( 360 + repoPath, 361 + result.operation_id, 362 + `Rebased ${sourceRevision.change_id_short} onto ${destinationRevision.change_id_short}`, 363 + ); 364 + }) 365 + .catch((error) => { 366 + collection.utils.writeUpsert([ 367 + { 368 + ...sourceRevision, 369 + parent_edges: previousParentEdges, 370 + }, 371 + ]); 372 + toast.error(`Failed to rebase revision: ${error}`, { 373 + duration: Number.POSITIVE_INFINITY, 374 + }); 375 + }); 376 + }
+8
apps/desktop/src/data/mutation-tracker.ts
··· 1 + export const inFlightMutations = new Set<string>(); 2 + 3 + export function trackMutation<T>(mutationId: string, promise: Promise<T>): Promise<T> { 4 + inFlightMutations.add(mutationId); 5 + return promise.finally(() => { 6 + inFlightMutations.delete(mutationId); 7 + }); 8 + }
+27
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 + // ============================================================================ 7 + 8 + /** 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 + */ 13 + 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 + } 18 + } 19 + 20 + /** 21 + * Prefetch revision changes (file list) for a batch of change IDs. 22 + */ 23 + export function prefetchRevisionChanges(repoPath: string, changeIds: string[]): void { 24 + for (const changeId of changeIds) { 25 + getRevisionChangesCollection(repoPath, changeId); 26 + } 27 + }
+12
apps/desktop/src/data/query-client.ts
··· 1 + import { QueryClient } from "@tanstack/query-core"; 2 + 3 + export const queryClient = new QueryClient({ 4 + defaultOptions: { 5 + queries: { 6 + staleTime: Number.POSITIVE_INFINITY, // Data fresh until watcher invalidates 7 + gcTime: 5 * 60 * 1000, // 5 minutes 8 + refetchOnWindowFocus: false, // Watcher handles this 9 + refetchOnMount: false, // Already have data from watcher 10 + }, 11 + }, 12 + });
+63
apps/desktop/src/data/watchers.ts
··· 1 + import { listen } from "@tauri-apps/api/event"; 2 + import { unwatchRepository, watchRepository } from "@/tauri-commands"; 3 + import { inFlightMutations } from "./mutation-tracker"; 4 + import { queryClient } from "./query-client"; 5 + 6 + // ============================================================================ 7 + // Shared Repository Watcher (one per repo, invalidates all queries) 8 + // ============================================================================ 9 + 10 + const repoWatchers = new Map<string, { unlisten: () => void; refCount: number }>(); 11 + 12 + export async function setupRepoWatcher(repoPath: string): Promise<void> { 13 + const existing = repoWatchers.get(repoPath); 14 + if (existing) { 15 + existing.refCount++; 16 + return; 17 + } 18 + 19 + await watchRepository(repoPath); 20 + const unlisten = await listen<string>("repo-changed", async (event) => { 21 + if (event.payload === repoPath) { 22 + // Skip if there are in-flight mutations - let the mutation handle state 23 + if (inFlightMutations.size > 0) { 24 + return; 25 + } 26 + 27 + // Invalidate ALL queries for this repo - TanStack Query will refetch 28 + await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 29 + await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 30 + await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 31 + await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 32 + await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 33 + await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 34 + } 35 + }); 36 + 37 + repoWatchers.set(repoPath, { unlisten, refCount: 1 }); 38 + } 39 + 40 + export async function teardownRepoWatcher(repoPath: string): Promise<void> { 41 + const existing = repoWatchers.get(repoPath); 42 + if (!existing) { 43 + return; 44 + } 45 + 46 + existing.refCount--; 47 + if (existing.refCount > 0) { 48 + return; 49 + } 50 + 51 + repoWatchers.delete(repoPath); 52 + existing.unlisten(); 53 + await unwatchRepository(repoPath); 54 + } 55 + 56 + export async function invalidateRepositoryQueries(repoPath: string): Promise<void> { 57 + await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 58 + await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 59 + await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 60 + await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 61 + await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 62 + await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 63 + }
+50 -900
apps/desktop/src/db.ts
··· 1 - import { createCollection } from "@tanstack/db"; 2 - import { QueryClient } from "@tanstack/query-core"; 3 - import { queryCollectionOptions } from "@tanstack/query-db-collection"; 4 - import { listen } from "@tauri-apps/api/event"; 5 - import { Effect } from "effect"; 6 - import { toast } from "@/components/ui/sonner"; 7 - import type { ChangedFile, Repository, Revision } from "@/tauri-commands"; 8 - import { 9 - generateChangeIds, 10 - getCommitRecency, 11 - getRepositories, 12 - getRevisionChanges, 13 - getRevisionDiff, 14 - getRevisions, 15 - jjAbandon, 16 - jjDescribe, 17 - jjEdit, 18 - jjGitFetch, 19 - jjGitPush, 20 - jjNew, 21 - jjRebase, 22 - jjSquash, 23 - removeRepository, 24 - undoOperation, 25 - unwatchRepository, 26 - upsertRepository, 27 - watchRepository, 28 - } from "@/tauri-commands"; 29 - 30 - // ============================================================================ 31 - // Query Client (shared by all collections) 32 - // ============================================================================ 33 - 34 - export const queryClient = new QueryClient({ 35 - defaultOptions: { 36 - queries: { 37 - staleTime: Number.POSITIVE_INFINITY, // Data fresh until watcher invalidates 38 - gcTime: 5 * 60 * 1000, // 5 minutes 39 - refetchOnWindowFocus: false, // Watcher handles this 40 - refetchOnMount: false, // Already have data from watcher 41 - }, 42 - }, 43 - }); 44 - 45 - // ============================================================================ 46 - // In-flight Mutation Tracking 47 - // ============================================================================ 48 - 49 - const inFlightMutations = new Set<string>(); 50 - 51 - function trackMutation<T>(mutationId: string, promise: Promise<T>): Promise<T> { 52 - inFlightMutations.add(mutationId); 53 - return promise.finally(() => { 54 - inFlightMutations.delete(mutationId); 55 - }); 56 - } 57 - 58 - // ============================================================================ 59 - // Change ID Pool Collection (pre-allocated IDs for optimistic updates) 60 - // ============================================================================ 61 - 62 - const POOL_SIZE = 10; 63 - const POOL_REFILL_THRESHOLD = 3; 64 - 65 - interface ChangeIdPool { 66 - repoPath: string; 67 - ids: string[]; 68 - } 69 - 70 - function changeIdPoolQueryKey(repoPath: string) { 71 - return ["change-id-pool", repoPath] as const; 72 - } 73 - 74 - async function fetchChangeIdPool(repoPath: string): Promise<ChangeIdPool> { 75 - const ids = await generateChangeIds(repoPath, POOL_SIZE); 76 - return { repoPath, ids }; 77 - } 78 - 79 - /** Ensure the change ID pool is loaded. Call from router beforeLoad. */ 80 - export async function ensureChangeIdPool(repoPath: string): Promise<void> { 81 - // Fast path: if already cached, return immediately without async work 82 - const existing = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 83 - if (existing && existing.ids.length > 0) { 84 - return; 85 - } 86 - 87 - await queryClient.ensureQueryData({ 88 - queryKey: changeIdPoolQueryKey(repoPath), 89 - queryFn: () => fetchChangeIdPool(repoPath), 90 - }); 91 - } 92 - 93 - /** Consume a change ID from the pool, triggering refill if needed */ 94 - function consumeChangeId(repoPath: string): string | null { 95 - const poolEntry = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 96 - if (!poolEntry || poolEntry.ids.length === 0) return null; 97 - 98 - const [id, ...remaining] = poolEntry.ids; 99 - 100 - // Update the cache directly 101 - queryClient.setQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath), { 102 - repoPath, 103 - ids: remaining, 104 - }); 105 - 106 - // Trigger refill if running low 107 - if (remaining.length < POOL_REFILL_THRESHOLD) { 108 - generateChangeIds(repoPath, POOL_SIZE).then((newIds) => { 109 - const current = queryClient.getQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath)); 110 - const currentIds = current?.ids ?? []; 111 - queryClient.setQueryData<ChangeIdPool>(changeIdPoolQueryKey(repoPath), { 112 - repoPath, 113 - ids: [...currentIds, ...newIds], 114 - }); 115 - }); 116 - } 117 - 118 - return id; 119 - } 120 - 121 - // ============================================================================ 122 - // Shared Repository Watcher (one per repo, invalidates all queries) 123 - // ============================================================================ 124 - 125 - const repoWatchers = new Map<string, { unlisten: () => void; refCount: number }>(); 126 - 127 - export async function setupRepoWatcher(repoPath: string): Promise<void> { 128 - const existing = repoWatchers.get(repoPath); 129 - if (existing) { 130 - existing.refCount++; 131 - return; 132 - } 133 - 134 - await watchRepository(repoPath); 135 - const unlisten = await listen<string>("repo-changed", async (event) => { 136 - if (event.payload === repoPath) { 137 - // Skip if there are in-flight mutations - let the mutation handle state 138 - if (inFlightMutations.size > 0) { 139 - return; 140 - } 141 - 142 - // Invalidate ALL queries for this repo - TanStack Query will refetch 143 - await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 144 - await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 145 - await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 146 - await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 147 - await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 148 - await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 149 - } 150 - }); 151 - 152 - repoWatchers.set(repoPath, { unlisten, refCount: 1 }); 153 - } 154 - 155 - export async function teardownRepoWatcher(repoPath: string): Promise<void> { 156 - const existing = repoWatchers.get(repoPath); 157 - if (!existing) { 158 - return; 159 - } 160 - 161 - existing.refCount--; 162 - if (existing.refCount > 0) { 163 - return; 164 - } 165 - 166 - repoWatchers.delete(repoPath); 167 - existing.unlisten(); 168 - await unwatchRepository(repoPath); 169 - } 170 - 171 - function isAuthError(errorText: string): boolean { 172 - const text = errorText.toLowerCase(); 173 - return ( 174 - text.includes("auth") || 175 - text.includes("authentication") || 176 - text.includes("permission denied") || 177 - text.includes("publickey") || 178 - text.includes("credential") || 179 - text.includes("forbidden") 180 - ); 181 - } 182 - 183 - export async function invalidateRepositoryQueries(repoPath: string): Promise<void> { 184 - await queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 185 - await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 186 - await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 187 - await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 188 - await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 189 - await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 190 - } 191 - 192 - function mutationSuccessWithUndo( 193 - repoPath: string, 194 - operationId: string, 195 - title: string, 196 - description?: string, 197 - ) { 198 - toast.success(title, { 199 - description, 200 - action: { 201 - label: "Undo", 202 - onClick: () => { 203 - undoOperation(repoPath, operationId) 204 - .then(() => { 205 - void invalidateRepositoryQueries(repoPath); 206 - toast.success("Undo successful"); 207 - }) 208 - .catch((error) => { 209 - toast.error(`Undo failed: ${error}`, { duration: Number.POSITIVE_INFINITY }); 210 - }); 211 - }, 212 - }, 213 - }); 214 - } 215 - 216 - export async function syncRepository(repoPath: string, preset?: string): Promise<void> { 217 - const limit = preset === "full_history" ? 10000 : 100; 218 - 219 - try { 220 - await jjGitFetch(repoPath, "origin"); 221 - await invalidateRepositoryQueries(repoPath); 222 - 223 - const revisions = await getRevisions(repoPath, limit, undefined, preset); 224 - const aheadBookmarks = Array.from( 225 - new Set( 226 - revisions.flatMap((revision) => 227 - revision.bookmarks 228 - .filter((bookmark) => bookmark.is_ahead) 229 - .map((bookmark) => bookmark.name), 230 - ), 231 - ), 232 - ); 233 - 234 - if (aheadBookmarks.length > 0) { 235 - await jjGitPush(repoPath, aheadBookmarks, "origin"); 236 - await invalidateRepositoryQueries(repoPath); 237 - } 238 - } catch (error) { 239 - const message = error instanceof Error ? error.message : String(error); 240 - if (isAuthError(message)) { 241 - toast.error("Sync failed: authentication error. Check SSH keys or credential helper.", { 242 - description: message, 243 - duration: Number.POSITIVE_INFINITY, 244 - }); 245 - return; 246 - } 247 - 248 - toast.error(`Sync failed: ${message}`, { 249 - duration: Number.POSITIVE_INFINITY, 250 - }); 251 - } 252 - } 253 - 254 - // ============================================================================ 255 - // Repositories Collection 256 - // ============================================================================ 257 - 258 - const repositoriesQueryKey = ["repositories"] as const; 259 - 260 - export const repositoriesCollection = createCollection({ 261 - ...queryCollectionOptions({ 262 - queryClient, 263 - queryKey: repositoriesQueryKey, 264 - queryFn: getRepositories, 265 - getKey: (repository: Repository) => repository.id, 266 - }), 267 - }); 268 - 269 - export type RepositoriesCollection = typeof repositoriesCollection; 270 - 271 - /** Ensure repositories are loaded. Returns the list. */ 272 - export async function ensureRepositories(): Promise<Repository[]> { 273 - return queryClient.ensureQueryData({ 274 - queryKey: repositoriesQueryKey, 275 - queryFn: getRepositories, 276 - }); 277 - } 278 - 279 - export async function addRepository(collection: RepositoriesCollection, repository: Repository) { 280 - // Optimistic update first 281 - collection.utils.writeUpsert([repository]); 282 - 283 - try { 284 - await upsertRepository(repository); 285 - } catch (err) { 286 - // Revert on failure 287 - collection.utils.writeDelete(repository.id); 288 - throw err; 289 - } 290 - } 291 - 292 - export async function updateRepository(collection: RepositoriesCollection, repository: Repository) { 293 - // Get current state for potential revert 294 - const current = collection.state.get(repository.id); 295 - 296 - // Optimistic update 297 - collection.utils.writeUpsert([repository]); 298 - 299 - try { 300 - await upsertRepository(repository); 301 - } catch (err) { 302 - // Revert on failure 303 - if (current) { 304 - collection.utils.writeUpsert([current]); 305 - } else { 306 - collection.utils.writeDelete(repository.id); 307 - } 308 - throw err; 309 - } 310 - } 311 - 312 - export async function deleteRepository(collection: RepositoriesCollection, repositoryId: string) { 313 - // Get current state for potential revert 314 - const current = collection.state.get(repositoryId); 315 - 316 - // Optimistic delete 317 - collection.utils.writeDelete(repositoryId); 318 - 319 - try { 320 - await removeRepository(repositoryId); 321 - } catch (err) { 322 - // Revert on failure 323 - if (current) { 324 - collection.utils.writeUpsert([current]); 325 - } 326 - throw err; 327 - } 328 - } 329 - 330 - // ============================================================================ 331 - // Revisions Collection 332 - // ============================================================================ 333 - 334 - // Key function that handles divergent changes (same change_id, different commits) 335 - export function getRevisionKey(revision: Revision): string { 336 - if (revision.divergent_index != null) { 337 - return `${revision.change_id}/${revision.divergent_index}`; 338 - } 339 - return revision.change_id; 340 - } 341 - 342 - export const emptyRevisionsCollection = createCollection({ 343 - ...queryCollectionOptions({ 344 - queryClient, 345 - queryKey: ["revisions", "empty"], 346 - queryFn: () => Promise.resolve([]), 347 - getKey: getRevisionKey, 348 - }), 349 - }); 350 - 351 - const revisionCollections = new Map<string, ReturnType<typeof createRevisionsCollection>>(); 352 - 353 - function createRevisionsCollection(repoPath: string, preset?: string) { 354 - const limit = preset === "full_history" ? 10000 : 100; 355 - 356 - return createCollection({ 357 - ...queryCollectionOptions({ 358 - queryClient, 359 - queryKey: ["revisions", repoPath, preset], 360 - queryFn: () => getRevisions(repoPath, limit, undefined, preset), 361 - getKey: getRevisionKey, 362 - }), 363 - }); 364 - } 365 - 366 - export type RevisionsCollection = ReturnType<typeof createRevisionsCollection>; 367 - 368 - export function getRevisionsCollection(repoPath: string, preset?: string) { 369 - const cacheKey = `${repoPath}:${preset ?? "full_history"}`; 370 - let collection = revisionCollections.get(cacheKey); 371 - if (!collection) { 372 - collection = createRevisionsCollection(repoPath, preset); 373 - revisionCollections.set(cacheKey, collection); 374 - } 375 - return collection; 376 - } 377 - 378 - export function editRevision( 379 - collection: RevisionsCollection, 380 - repoPath: string, 381 - targetRevision: Revision, 382 - currentWcRevision: Revision | null, 383 - ) { 384 - const mutationId = `edit-${Date.now()}-${Math.random()}`; 385 - 386 - // Optimistic update 387 - const updates: Revision[] = []; 388 - if (currentWcRevision && getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision)) { 389 - updates.push({ ...currentWcRevision, is_working_copy: false }); 390 - } 391 - updates.push({ ...targetRevision, is_working_copy: true }); 392 - collection.utils.writeUpsert(updates); 393 - 394 - // Track the mutation and fire backend 395 - trackMutation(mutationId, jjEdit(repoPath, targetRevision.change_id_short)) 396 - .then((_result) => { 397 - // Invalidate to get fresh data from backend 398 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 399 - toast.success(`Working copy is now ${targetRevision.change_id_short}`); 400 - }) 401 - .catch((error) => { 402 - // Revert optimistic update 403 - const revertUpdates: Revision[] = []; 404 - if ( 405 - currentWcRevision && 406 - getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision) 407 - ) { 408 - revertUpdates.push({ ...currentWcRevision, is_working_copy: true }); 409 - } 410 - revertUpdates.push({ ...targetRevision, is_working_copy: false }); 411 - collection.utils.writeUpsert(revertUpdates); 412 - toast.error(`Failed to edit revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 413 - }); 414 - } 415 - 416 - export function newRevision( 417 - collection: RevisionsCollection, 418 - repoPath: string, 419 - parentChangeIds: string[], 420 - parentRevision: Revision, 421 - currentWcRevision: Revision | null, 422 - ) { 423 - const mutationId = `new-${Date.now()}-${Math.random()}`; 424 - const preAllocatedChangeId = consumeChangeId(repoPath); 425 - 426 - // Create optimistic revision if we have a pre-allocated change ID 427 - let optimisticRevision: Revision | null = null; 428 - if (preAllocatedChangeId) { 429 - optimisticRevision = { 430 - commit_id: `pending-${preAllocatedChangeId}`, // Temporary, will be replaced 431 - change_id: preAllocatedChangeId, 432 - change_id_short: preAllocatedChangeId.slice(0, 8), // Approximate short ID 433 - parent_edges: [{ parent_id: parentRevision.commit_id, edge_type: "direct" as const }], 434 - children_ids: [], 435 - description: "", 436 - author: parentRevision.author, // Inherit from parent 437 - timestamp: new Date().toISOString(), 438 - is_working_copy: true, 439 - is_immutable: false, 440 - is_mine: true, 441 - is_trunk: false, 442 - is_divergent: false, 443 - divergent_index: null, 444 - has_conflict: false, 445 - bookmarks: [], 446 - }; 447 - 448 - // Optimistic update: clear WC from current, insert new revision 449 - const updates: Revision[] = []; 450 - if (currentWcRevision) { 451 - updates.push({ ...currentWcRevision, is_working_copy: false }); 452 - } 453 - updates.push(optimisticRevision); 454 - collection.utils.writeUpsert(updates); 455 - } 456 - 457 - // Fire backend call 458 - const program = Effect.tryPromise({ 459 - try: () => jjNew(repoPath, parentChangeIds, preAllocatedChangeId ?? undefined), 460 - catch: (error) => new Error(`Failed to create new revision: ${error}`), 461 - }).pipe(Effect.tapError((error) => Effect.logError("jjNew failed", error))); 462 - 463 - trackMutation(mutationId, Effect.runPromise(program)) 464 - .then((result) => { 465 - // Invalidate to get authoritative data (correct commit_id, short_id, etc.) 466 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 467 - const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 468 - toast.success(`Working copy is now ${shortId}`, { 469 - description: "Created new revision", 470 - }); 471 - }) 472 - .catch((error) => { 473 - // Revert optimistic update 474 - if (optimisticRevision) { 475 - collection.utils.writeDelete(getRevisionKey(optimisticRevision)); 476 - if (currentWcRevision) { 477 - collection.utils.writeUpsert([{ ...currentWcRevision, is_working_copy: true }]); 478 - } 479 - } 480 - toast.error(`Failed to create revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 481 - }); 482 - } 483 - 484 - export function abandonRevision( 485 - collection: RevisionsCollection, 486 - repoPath: string, 487 - revision: Revision, 488 - ) { 489 - const mutationId = `abandon-${Date.now()}-${Math.random()}`; 490 - 491 - // For working copy, jj creates a new WC - can't do optimistic delete 492 - // For other revisions, we can optimistically remove 493 - if (!revision.is_working_copy) { 494 - collection.utils.writeDelete(getRevisionKey(revision)); 495 - } 496 - 497 - // Track the mutation and fire backend 498 - trackMutation(mutationId, jjAbandon(repoPath, revision.change_id_short)) 499 - .then((result) => { 500 - // Invalidate to get fresh data (especially for WC abandon which creates new WC) 501 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 502 - toast.success(`Abandoned revision ${revision.change_id_short}`, { 503 - action: { 504 - label: "Undo", 505 - onClick: () => { 506 - undoOperation(repoPath, result.operation_id) 507 - .then(() => { 508 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 509 - toast.success("Undo successful"); 510 - }) 511 - .catch((err) => { 512 - toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 513 - }); 514 - }, 515 - }, 516 - }); 517 - }) 518 - .catch((error) => { 519 - // Re-add on failure (only if we deleted it) 520 - if (!revision.is_working_copy) { 521 - collection.utils.writeUpsert([revision]); 522 - } 523 - toast.error(`Failed to abandon revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 524 - }); 525 - } 526 - 527 - export function describeRevision( 528 - collection: RevisionsCollection, 529 - repoPath: string, 530 - revision: Revision, 531 - description: string, 532 - ) { 533 - const mutationId = `describe-${Date.now()}-${Math.random()}`; 534 - const previousDescription = revision.description; 535 - 536 - // Optimistic update 537 - collection.utils.writeUpsert([{ ...revision, description }]); 538 - 539 - trackMutation(mutationId, jjDescribe(repoPath, revision.change_id_short, description)) 540 - .then((result) => { 541 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 542 - toast.success(`Updated description for ${revision.change_id_short}`, { 543 - action: { 544 - label: "Undo", 545 - onClick: () => { 546 - undoOperation(repoPath, result.operation_id) 547 - .then(() => { 548 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 549 - toast.success("Undo successful"); 550 - }) 551 - .catch((err) => { 552 - toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 553 - }); 554 - }, 555 - }, 556 - }); 557 - }) 558 - .catch((error) => { 559 - // Revert optimistic update on failure 560 - collection.utils.writeUpsert([{ ...revision, description: previousDescription }]); 561 - toast.error(`Failed to update description: ${error}`, { 562 - duration: Number.POSITIVE_INFINITY, 563 - }); 564 - }); 565 - } 566 - 567 - export function squashRevision( 568 - collection: RevisionsCollection, 569 - repoPath: string, 570 - revision: Revision, 571 - ) { 572 - if (revision.is_immutable) { 573 - toast.error("Cannot squash immutable revision", { duration: Number.POSITIVE_INFINITY }); 574 - return; 575 - } 576 - if (revision.parent_edges.length === 0) { 577 - toast.error("Cannot squash root revision", { duration: Number.POSITIVE_INFINITY }); 578 - return; 579 - } 580 - if (revision.parent_edges.length > 1) { 581 - toast.error("Cannot squash merge revision with multiple parents", { 582 - duration: Number.POSITIVE_INFINITY, 583 - }); 584 - return; 585 - } 586 - 587 - const mutationId = `squash-${Date.now()}-${Math.random()}`; 588 - const shouldOptimisticallyDelete = !revision.is_working_copy; 589 - 590 - if (shouldOptimisticallyDelete) { 591 - collection.utils.writeDelete(getRevisionKey(revision)); 592 - } 593 - 594 - trackMutation(mutationId, jjSquash(repoPath, revision.change_id)) 595 - .then((result) => { 596 - void invalidateRepositoryQueries(repoPath); 597 - mutationSuccessWithUndo( 598 - repoPath, 599 - result.operation_id, 600 - `Squashed ${revision.change_id_short} into parent`, 601 - ); 602 - }) 603 - .catch((error) => { 604 - if (shouldOptimisticallyDelete) { 605 - collection.utils.writeUpsert([revision]); 606 - } 607 - toast.error(`Failed to squash revision: ${error}`, { 608 - duration: Number.POSITIVE_INFINITY, 609 - }); 610 - }); 611 - } 612 - 613 - export function rebaseRevision( 614 - collection: RevisionsCollection, 615 - repoPath: string, 616 - sourceRevision: Revision, 617 - destinationRevision: Revision, 618 - ) { 619 - if (sourceRevision.is_immutable) { 620 - toast.error("Cannot rebase immutable revision", { duration: Number.POSITIVE_INFINITY }); 621 - return; 622 - } 623 - if (sourceRevision.change_id === destinationRevision.change_id) { 624 - toast.error("Cannot rebase revision onto itself", { duration: Number.POSITIVE_INFINITY }); 625 - return; 626 - } 627 - 628 - const mutationId = `rebase-${Date.now()}-${Math.random()}`; 629 - const previousParentEdges = sourceRevision.parent_edges; 630 - 631 - collection.utils.writeUpsert([ 632 - { 633 - ...sourceRevision, 634 - parent_edges: [{ parent_id: destinationRevision.commit_id, edge_type: "direct" as const }], 635 - }, 636 - ]); 637 - 638 - trackMutation( 639 - mutationId, 640 - jjRebase(repoPath, sourceRevision.change_id, destinationRevision.change_id), 641 - ) 642 - .then((result) => { 643 - void invalidateRepositoryQueries(repoPath); 644 - mutationSuccessWithUndo( 645 - repoPath, 646 - result.operation_id, 647 - `Rebased ${sourceRevision.change_id_short} onto ${destinationRevision.change_id_short}`, 648 - ); 649 - }) 650 - .catch((error) => { 651 - collection.utils.writeUpsert([ 652 - { 653 - ...sourceRevision, 654 - parent_edges: previousParentEdges, 655 - }, 656 - ]); 657 - toast.error(`Failed to rebase revision: ${error}`, { 658 - duration: Number.POSITIVE_INFINITY, 659 - }); 660 - }); 661 - } 662 - 663 - // ============================================================================ 664 - // Revision Changes Collections (ChangedFile[] per revision) 665 - // ============================================================================ 666 - 667 - const revisionChangesCollections = new Map< 668 - string, 669 - ReturnType<typeof createRevisionChangesCollection> 670 - >(); 671 - 672 - function createRevisionChangesCollection(repoPath: string, changeId: string) { 673 - return createCollection({ 674 - ...queryCollectionOptions({ 675 - queryClient, 676 - queryKey: ["revision-changes", repoPath, changeId], 677 - queryFn: async () => { 678 - return await getRevisionChanges(repoPath, changeId); 679 - }, 680 - getKey: (file: ChangedFile) => file.path, 681 - }), 682 - }); 683 - } 684 - 685 - export type RevisionChangesCollection = ReturnType<typeof createRevisionChangesCollection>; 686 - 687 - export function getRevisionChangesCollection( 688 - repoPath: string, 689 - changeId: string, 690 - ): RevisionChangesCollection { 691 - const cacheKey = `${repoPath}:${changeId}`; 692 - let collection = revisionChangesCollections.get(cacheKey); 693 - if (!collection) { 694 - collection = createRevisionChangesCollection(repoPath, changeId); 695 - revisionChangesCollections.set(cacheKey, collection); 696 - } 697 - return collection; 698 - } 699 - 700 - export const emptyChangesCollection = createCollection({ 701 - ...queryCollectionOptions({ 702 - queryClient, 703 - queryKey: ["revision-changes", "empty"], 704 - queryFn: () => Promise.resolve([]), 705 - getKey: (file: ChangedFile) => file.path, 706 - }), 707 - }); 708 - 709 - // ============================================================================ 710 - // Revision Diff Collections (diff string per revision) 711 - // ============================================================================ 712 - 713 - // Wrapper type for diff string to work with collection pattern 714 - interface DiffEntry { 715 - id: "diff"; 716 - content: string; 717 - } 718 - 719 - const revisionDiffCollections = new Map<string, ReturnType<typeof createRevisionDiffCollection>>(); 720 - 721 - function createRevisionDiffCollection(repoPath: string, changeId: string) { 722 - return createCollection({ 723 - ...queryCollectionOptions({ 724 - queryClient, 725 - queryKey: ["revision-diff", repoPath, changeId], 726 - queryFn: async () => { 727 - const diff = await getRevisionDiff(repoPath, changeId); 728 - return [{ id: "diff" as const, content: diff }]; 729 - }, 730 - getKey: (entry: DiffEntry) => entry.id, 731 - }), 732 - }); 733 - } 734 - 735 - export type RevisionDiffCollection = ReturnType<typeof createRevisionDiffCollection>; 736 - 737 - export function getRevisionDiffCollection( 738 - repoPath: string, 739 - changeId: string, 740 - ): RevisionDiffCollection { 741 - const cacheKey = `${repoPath}:${changeId}`; 742 - let collection = revisionDiffCollections.get(cacheKey); 743 - if (!collection) { 744 - collection = createRevisionDiffCollection(repoPath, changeId); 745 - revisionDiffCollections.set(cacheKey, collection); 746 - } 747 - return collection; 748 - } 749 - 750 - export const emptyDiffCollection = createCollection({ 751 - ...queryCollectionOptions({ 752 - queryClient, 753 - queryKey: ["revision-diff", "empty"], 754 - queryFn: () => Promise.resolve([]), 755 - getKey: (entry: DiffEntry) => entry.id, 756 - }), 757 - }); 758 - 759 - // ============================================================================ 760 - // Prefetching Utilities 761 - // ============================================================================ 762 - 763 - /** 764 - * Prefetch revision diffs for a batch of change IDs. 765 - * This eagerly creates collections which triggers the query fetch. 766 - * TanStack DB handles caching - subsequent calls are no-ops. 767 - */ 768 - export function prefetchRevisionDiffs(repoPath: string, changeIds: string[]): void { 769 - // Just trigger the data fetch for all revisions 770 - for (const changeId of changeIds) { 771 - getRevisionDiffCollection(repoPath, changeId); 772 - } 773 - } 774 - 775 - /** 776 - * Prefetch revision changes (file list) for a batch of change IDs. 777 - */ 778 - export function prefetchRevisionChanges(repoPath: string, changeIds: string[]): void { 779 - for (const changeId of changeIds) { 780 - getRevisionChangesCollection(repoPath, changeId); 781 - } 782 - } 783 - 784 - // ============================================================================ 785 - // Commit Recency Collection (for branch ordering) 786 - // ============================================================================ 787 - 788 - // Wrapper type for commit recency data to work with collection pattern 789 - interface CommitRecencyEntry { 790 - id: "recency"; 791 - data: Record<string, number>; 792 - } 793 - 794 - const commitRecencyCollections = new Map< 795 - string, 796 - ReturnType<typeof createCommitRecencyCollection> 797 - >(); 798 - 799 - function createCommitRecencyCollection(repoPath: string) { 800 - return createCollection({ 801 - ...queryCollectionOptions({ 802 - queryClient, 803 - queryKey: ["commit-recency", repoPath], 804 - queryFn: async () => { 805 - const recency = await getCommitRecency(repoPath, 500); 806 - return [{ id: "recency" as const, data: recency }]; 807 - }, 808 - getKey: (entry: CommitRecencyEntry) => entry.id, 809 - staleTime: 30_000, // 30 seconds - this one uses time-based staleness 810 - }), 811 - }); 812 - } 813 - 814 - export type CommitRecencyCollection = ReturnType<typeof createCommitRecencyCollection>; 815 - 816 - export function getCommitRecencyCollection(repoPath: string): CommitRecencyCollection { 817 - const cacheKey = repoPath; 818 - let collection = commitRecencyCollections.get(cacheKey); 819 - if (!collection) { 820 - collection = createCommitRecencyCollection(repoPath); 821 - commitRecencyCollections.set(cacheKey, collection); 822 - } 823 - return collection; 824 - } 825 - 826 - export const emptyCommitRecencyCollection = createCollection({ 827 - ...queryCollectionOptions({ 828 - queryClient, 829 - queryKey: ["commit-recency", "empty"], 830 - queryFn: () => Promise.resolve([]), 831 - getKey: (entry: CommitRecencyEntry) => entry.id, 832 - }), 833 - }); 834 - 835 - // ============================================================================ 836 - // Unified Diffs Collection (single collection for all revision diffs) 837 - // ============================================================================ 838 - 839 - /** 840 - * Unified diff record - stores diff content keyed by repoPath:changeId. 841 - * This replaces the per-revision collection pattern which caused GC issues. 842 - */ 843 - export interface DiffRecord { 844 - repoPath: string; 845 - changeId: string; 846 - content: string; 847 - prerenderedUnified?: string; 848 - prerenderedSplit?: string; 849 - } 850 - 851 - function getDiffRecordKey(d: DiffRecord): string { 852 - return `${d.repoPath}:${d.changeId}`; 853 - } 854 - 855 - const diffsQueryKey = ["diffs"] as const; 856 - 857 - export const diffsCollection = createCollection({ 858 - ...queryCollectionOptions({ 859 - queryClient, 860 - queryKey: diffsQueryKey, 861 - queryFn: async () => [] as DiffRecord[], 862 - getKey: getDiffRecordKey, 863 - }), 864 - startSync: true, 865 - }); 866 - 867 - export type DiffsCollection = typeof diffsCollection; 868 - 869 - // ============================================================================ 870 - // Unified Changes Collection (single collection for all revision file lists) 871 - // ============================================================================ 872 - 873 - /** 874 - * Unified change record - stores changed files keyed by repoPath:changeId:path. 875 - * This replaces the per-revision collection pattern which caused GC issues. 876 - */ 877 - export interface ChangeRecord { 878 - repoPath: string; 879 - changeId: string; 880 - path: string; 881 - status: "added" | "modified" | "deleted"; 882 - } 883 - 884 - function getChangeRecordKey(c: ChangeRecord): string { 885 - return `${c.repoPath}:${c.changeId}:${c.path}`; 886 - } 887 - 888 - const changesQueryKey = ["changes"] as const; 889 - 890 - export const changesCollection = createCollection({ 891 - ...queryCollectionOptions({ 892 - queryClient, 893 - queryKey: changesQueryKey, 894 - queryFn: async () => [] as ChangeRecord[], 895 - getKey: getChangeRecordKey, 896 - }), 897 - startSync: true, 898 - }); 899 - 900 - export type ChangesCollection = typeof changesCollection; 1 + export { queryClient } from "./data/query-client"; 2 + export { ensureChangeIdPool } from "./data/change-id-pool"; 3 + export { 4 + invalidateRepositoryQueries, 5 + setupRepoWatcher, 6 + teardownRepoWatcher, 7 + } from "./data/watchers"; 8 + export { syncRepository } from "./data/actions/repo-actions"; 9 + export { 10 + addRepository, 11 + deleteRepository, 12 + ensureRepositories, 13 + repositoriesCollection, 14 + type RepositoriesCollection, 15 + updateRepository, 16 + } from "./data/collections/repositories"; 17 + export { 18 + abandonRevision, 19 + describeRevision, 20 + editRevision, 21 + emptyRevisionsCollection, 22 + getRevisionKey, 23 + getRevisionsCollection, 24 + newRevision, 25 + rebaseRevision, 26 + type RevisionsCollection, 27 + squashRevision, 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 + export { prefetchRevisionChanges, prefetchRevisionDiffs } from "./data/prefetch"; 40 + export { 41 + emptyCommitRecencyCollection, 42 + getCommitRecencyCollection, 43 + type CommitRecencyCollection, 44 + } 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";