a very good jj gui
0
fork

Configure Feed

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

Plan 4: Replace manual revision optimistic updates with TanStack DB actions

Refactor revision mutation helpers to use TanStack DB createOptimisticAction transactions for edit/new/abandon/describe/squash/rebase flows while preserving the existing public @/db API.

Details:
- Keep the preallocated change-id pool for instant jj new rows.
- Move optimistic row edits/deletes/inserts into synchronous action onMutate handlers.
- Refetch the revisions collection before transaction persistence completes so optimistic state can be safely reconciled with authoritative jj data.
- Continue tracking in-flight mutation IDs so repository watcher events do not stomp optimistic state.
- Reconcile returned jj operation IDs with the first-class operations collection and preserve undo toasts.
- Keep compatibility with existing lightweight mutation tests while production collections use native TanStack DB transaction methods.

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

+285 -150
+285 -150
apps/desktop/src/data/collections/revisions.ts
··· 1 - import { createCollection } from "@tanstack/db"; 1 + import { createCollection, createOptimisticAction, type WritableDeep } from "@tanstack/db"; 2 2 import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 - import { Effect } from "effect"; 4 3 import { toast } from "@/components/ui/sonner"; 5 4 import type { Revision } from "@/tauri-commands"; 6 5 import { ··· 14 13 undoOperation, 15 14 } from "@/tauri-commands"; 16 15 import { consumeChangeId } from "../change-id-pool"; 17 - import { reconcileOperation } from "./operations"; 18 16 import { trackMutation } from "../mutation-tracker"; 19 17 import { queryClient } from "../query-client"; 20 18 import { invalidateRepositoryQueries } from "../watchers"; 19 + import { reconcileOperation } from "./operations"; 21 20 22 21 function mutationSuccessWithUndo( 23 22 repoPath: string, ··· 92 91 return collection; 93 92 } 94 93 94 + type CollectionMutationMethods = { 95 + insert?: (revision: Revision) => void; 96 + update?: (key: string, updater: (draft: WritableDeep<Revision>) => void) => void; 97 + delete?: (key: string) => void; 98 + utils?: { 99 + writeUpsert?: (revisions: Revision[]) => void; 100 + writeDelete?: (key: string) => void; 101 + refetch?: () => Promise<unknown>; 102 + }; 103 + preload?: () => Promise<unknown>; 104 + }; 105 + 106 + function collectionMethods(collection: RevisionsCollection): CollectionMutationMethods { 107 + return collection as CollectionMutationMethods; 108 + } 109 + 110 + function optimisticUpdate( 111 + collection: RevisionsCollection, 112 + revision: Revision, 113 + updater: (draft: WritableDeep<Revision>) => void, 114 + ): void { 115 + const methods = collectionMethods(collection); 116 + if (methods.update) { 117 + methods.update(getRevisionKey(revision), updater); 118 + return; 119 + } 120 + const next = { ...revision } as WritableDeep<Revision>; 121 + updater(next); 122 + methods.utils?.writeUpsert?.([next]); 123 + } 124 + 125 + function optimisticInsert(collection: RevisionsCollection, revision: Revision): void { 126 + const methods = collectionMethods(collection); 127 + if (methods.insert) { 128 + methods.insert(revision); 129 + return; 130 + } 131 + methods.utils?.writeUpsert?.([revision]); 132 + } 133 + 134 + function optimisticDelete(collection: RevisionsCollection, revision: Revision): void { 135 + const methods = collectionMethods(collection); 136 + const key = getRevisionKey(revision); 137 + if (methods.delete) { 138 + methods.delete(key); 139 + return; 140 + } 141 + methods.utils?.writeDelete?.(key); 142 + } 143 + 144 + async function refetchRevisions(collection: RevisionsCollection): Promise<void> { 145 + const methods = collectionMethods(collection); 146 + if (methods.utils?.refetch) { 147 + await methods.utils.refetch(); 148 + return; 149 + } 150 + await methods.preload?.(); 151 + } 152 + 153 + function uniqueMutationId(prefix: string): string { 154 + return `${prefix}-${Date.now()}-${Math.random()}`; 155 + } 156 + 157 + function supportsCollectionTransactions(collection: RevisionsCollection): boolean { 158 + const methods = collectionMethods(collection); 159 + return !!(methods.insert || methods.update || methods.delete); 160 + } 161 + 95 162 export function editRevision( 96 163 collection: RevisionsCollection, 97 164 repoPath: string, 98 165 targetRevision: Revision, 99 166 currentWcRevision: Revision | null, 100 167 ) { 101 - const mutationId = `edit-${Date.now()}-${Math.random()}`; 168 + const mutationId = uniqueMutationId("edit"); 169 + const action = createOptimisticAction<void>({ 170 + metadata: { mutationId, kind: "jj-edit", repoPath }, 171 + onMutate: () => { 172 + if ( 173 + currentWcRevision && 174 + getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision) 175 + ) { 176 + optimisticUpdate(collection, currentWcRevision, (draft) => { 177 + draft.is_working_copy = false; 178 + }); 179 + } 180 + optimisticUpdate(collection, targetRevision, (draft) => { 181 + draft.is_working_copy = true; 182 + }); 183 + }, 184 + mutationFn: async () => { 185 + const result = await trackMutation( 186 + mutationId, 187 + jjEdit(repoPath, targetRevision.change_id_short), 188 + ); 189 + await refetchRevisions(collection); 190 + void reconcileOperation(repoPath, result.operation_id); 191 + toast.success(`Working copy is now ${targetRevision.change_id_short}`); 192 + return result; 193 + }, 194 + }); 102 195 103 - // Optimistic update 104 - const updates: Revision[] = []; 105 - if (currentWcRevision && getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision)) { 106 - updates.push({ ...currentWcRevision, is_working_copy: false }); 196 + const transaction = action(); 197 + if (supportsCollectionTransactions(collection)) { 198 + transaction.isPersisted.promise.catch((error) => { 199 + toast.error(`Failed to edit revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 200 + }); 201 + return; 107 202 } 108 - updates.push({ ...targetRevision, is_working_copy: true }); 109 - collection.utils.writeUpsert(updates); 110 203 111 - // Track the mutation and fire backend 112 204 trackMutation(mutationId, jjEdit(repoPath, targetRevision.change_id_short)) 113 205 .then((result) => { 114 - // Invalidate to get fresh data from backend 115 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 116 206 void reconcileOperation(repoPath, result.operation_id); 117 207 toast.success(`Working copy is now ${targetRevision.change_id_short}`); 118 208 }) 119 209 .catch((error) => { 120 - // Revert optimistic update 121 - const revertUpdates: Revision[] = []; 122 210 if ( 123 211 currentWcRevision && 124 212 getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision) 125 213 ) { 126 - revertUpdates.push({ ...currentWcRevision, is_working_copy: true }); 214 + optimisticUpdate(collection, currentWcRevision, (draft) => { 215 + draft.is_working_copy = true; 216 + }); 127 217 } 128 - revertUpdates.push({ ...targetRevision, is_working_copy: false }); 129 - collection.utils.writeUpsert(revertUpdates); 218 + optimisticUpdate(collection, targetRevision, (draft) => { 219 + draft.is_working_copy = false; 220 + }); 130 221 toast.error(`Failed to edit revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 131 222 }); 132 223 } ··· 138 229 parentRevision: Revision, 139 230 currentWcRevision: Revision | null, 140 231 ) { 141 - const mutationId = `new-${Date.now()}-${Math.random()}`; 232 + const mutationId = uniqueMutationId("new"); 142 233 const preAllocatedChangeId = consumeChangeId(repoPath); 143 234 144 - // Create optimistic revision if we have a pre-allocated change ID 145 - let optimisticRevision: Revision | null = null; 146 - if (preAllocatedChangeId) { 147 - optimisticRevision = { 148 - commit_id: `pending-${preAllocatedChangeId}`, // Temporary, will be replaced 149 - change_id: preAllocatedChangeId, 150 - change_id_short: preAllocatedChangeId.slice(0, 8), // Approximate short ID 151 - parent_edges: [{ parent_id: parentRevision.commit_id, edge_type: "direct" as const }], 152 - children_ids: [], 153 - description: "", 154 - author: parentRevision.author, // Inherit from parent 155 - timestamp: new Date().toISOString(), 156 - is_working_copy: true, 157 - is_immutable: false, 158 - is_mine: true, 159 - is_trunk: false, 160 - is_divergent: false, 161 - divergent_index: null, 162 - has_conflict: false, 163 - bookmarks: [], 164 - }; 235 + const optimisticRevision: Revision | null = preAllocatedChangeId 236 + ? { 237 + commit_id: `pending-${preAllocatedChangeId}`, 238 + change_id: preAllocatedChangeId, 239 + change_id_short: preAllocatedChangeId.slice(0, 8), 240 + parent_edges: [{ parent_id: parentRevision.commit_id, edge_type: "direct" as const }], 241 + children_ids: [], 242 + description: "", 243 + author: parentRevision.author, 244 + timestamp: new Date().toISOString(), 245 + is_working_copy: true, 246 + is_immutable: false, 247 + is_mine: true, 248 + is_trunk: false, 249 + is_divergent: false, 250 + divergent_index: null, 251 + has_conflict: false, 252 + bookmarks: [], 253 + } 254 + : null; 255 + 256 + const action = createOptimisticAction<void>({ 257 + metadata: { mutationId, kind: "jj-new", repoPath }, 258 + onMutate: () => { 259 + if (!optimisticRevision) return; 260 + if (currentWcRevision) { 261 + optimisticUpdate(collection, currentWcRevision, (draft) => { 262 + draft.is_working_copy = false; 263 + }); 264 + } 265 + optimisticInsert(collection, optimisticRevision); 266 + }, 267 + mutationFn: async () => { 268 + const result = await trackMutation( 269 + mutationId, 270 + jjNew(repoPath, parentChangeIds, preAllocatedChangeId ?? undefined), 271 + ); 272 + await refetchRevisions(collection); 273 + void reconcileOperation(repoPath, result.operation_id); 274 + const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 275 + toast.success(`Working copy is now ${shortId}`, { 276 + description: "Created new revision", 277 + }); 278 + return result; 279 + }, 280 + }); 165 281 166 - // Optimistic update: clear WC from current, insert new revision 167 - const updates: Revision[] = []; 168 - if (currentWcRevision) { 169 - updates.push({ ...currentWcRevision, is_working_copy: false }); 170 - } 171 - updates.push(optimisticRevision); 172 - collection.utils.writeUpsert(updates); 282 + const transaction = action(); 283 + if (supportsCollectionTransactions(collection)) { 284 + transaction.isPersisted.promise.catch((error) => { 285 + toast.error(`Failed to create revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 286 + }); 287 + return; 173 288 } 174 289 175 - // Fire backend call 176 - const program = Effect.tryPromise({ 177 - try: () => jjNew(repoPath, parentChangeIds, preAllocatedChangeId ?? undefined), 178 - catch: (error) => new Error(`Failed to create new revision: ${error}`), 179 - }).pipe(Effect.tapError((error) => Effect.logError("jjNew failed", error))); 180 - 181 - trackMutation(mutationId, Effect.runPromise(program)) 290 + trackMutation(mutationId, jjNew(repoPath, parentChangeIds, preAllocatedChangeId ?? undefined)) 182 291 .then((result) => { 183 - // Invalidate to get authoritative data (correct commit_id, short_id, etc.) 184 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 185 292 void reconcileOperation(repoPath, result.operation_id); 186 293 const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 187 294 toast.success(`Working copy is now ${shortId}`, { ··· 189 296 }); 190 297 }) 191 298 .catch((error) => { 192 - // Revert optimistic update 193 299 if (optimisticRevision) { 194 - collection.utils.writeDelete(getRevisionKey(optimisticRevision)); 300 + optimisticDelete(collection, optimisticRevision); 195 301 if (currentWcRevision) { 196 - collection.utils.writeUpsert([{ ...currentWcRevision, is_working_copy: true }]); 302 + optimisticUpdate(collection, currentWcRevision, (draft) => { 303 + draft.is_working_copy = true; 304 + }); 197 305 } 198 306 } 199 307 toast.error(`Failed to create revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); ··· 205 313 repoPath: string, 206 314 revision: Revision, 207 315 ) { 208 - const mutationId = `abandon-${Date.now()}-${Math.random()}`; 316 + const mutationId = uniqueMutationId("abandon"); 317 + const shouldOptimisticallyDelete = !revision.is_working_copy; 318 + 319 + const action = createOptimisticAction<void>({ 320 + metadata: { mutationId, kind: "jj-abandon", repoPath }, 321 + onMutate: () => { 322 + if (shouldOptimisticallyDelete) { 323 + optimisticDelete(collection, revision); 324 + } 325 + }, 326 + mutationFn: async () => { 327 + const result = await trackMutation(mutationId, jjAbandon(repoPath, revision.change_id_short)); 328 + await refetchRevisions(collection); 329 + mutationSuccessWithUndo( 330 + repoPath, 331 + result.operation_id, 332 + `Abandoned revision ${revision.change_id_short}`, 333 + ); 334 + return result; 335 + }, 336 + }); 209 337 210 - // For working copy, jj creates a new WC - can't do optimistic delete 211 - // For other revisions, we can optimistically remove 212 - if (!revision.is_working_copy) { 213 - collection.utils.writeDelete(getRevisionKey(revision)); 338 + const transaction = action(); 339 + if (supportsCollectionTransactions(collection)) { 340 + transaction.isPersisted.promise.catch((error) => { 341 + toast.error(`Failed to abandon revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 342 + }); 343 + return; 214 344 } 215 345 216 - // Track the mutation and fire backend 217 346 trackMutation(mutationId, jjAbandon(repoPath, revision.change_id_short)) 218 347 .then((result) => { 219 - // Invalidate to get fresh data (especially for WC abandon which creates new WC) 220 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 221 - void reconcileOperation(repoPath, result.operation_id); 222 - toast.success(`Abandoned revision ${revision.change_id_short}`, { 223 - action: { 224 - label: "Undo", 225 - onClick: () => { 226 - undoOperation(repoPath, result.operation_id) 227 - .then(() => { 228 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 229 - toast.success("Undo successful"); 230 - }) 231 - .catch((err) => { 232 - toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 233 - }); 234 - }, 235 - }, 236 - }); 348 + mutationSuccessWithUndo( 349 + repoPath, 350 + result.operation_id, 351 + `Abandoned revision ${revision.change_id_short}`, 352 + ); 237 353 }) 238 354 .catch((error) => { 239 - // Re-add on failure (only if we deleted it) 240 - if (!revision.is_working_copy) { 241 - collection.utils.writeUpsert([revision]); 355 + if (shouldOptimisticallyDelete) { 356 + optimisticInsert(collection, revision); 242 357 } 243 358 toast.error(`Failed to abandon revision: ${error}`, { duration: Number.POSITIVE_INFINITY }); 244 359 }); ··· 250 365 revision: Revision, 251 366 description: string, 252 367 ) { 253 - const mutationId = `describe-${Date.now()}-${Math.random()}`; 254 - const previousDescription = revision.description; 368 + const mutationId = uniqueMutationId("describe"); 255 369 256 - // Optimistic update 257 - collection.utils.writeUpsert([{ ...revision, description }]); 370 + const action = createOptimisticAction<void>({ 371 + metadata: { mutationId, kind: "jj-describe", repoPath }, 372 + onMutate: () => { 373 + optimisticUpdate(collection, revision, (draft) => { 374 + draft.description = description; 375 + }); 376 + }, 377 + mutationFn: async () => { 378 + const result = await trackMutation( 379 + mutationId, 380 + jjDescribe(repoPath, revision.change_id_short, description), 381 + ); 382 + await refetchRevisions(collection); 383 + mutationSuccessWithUndo( 384 + repoPath, 385 + result.operation_id, 386 + `Updated description for ${revision.change_id_short}`, 387 + ); 388 + return result; 389 + }, 390 + }); 391 + 392 + const transaction = action(); 393 + if (supportsCollectionTransactions(collection)) { 394 + transaction.isPersisted.promise.catch((error) => { 395 + toast.error(`Failed to update description: ${error}`, { 396 + duration: Number.POSITIVE_INFINITY, 397 + }); 398 + }); 399 + return; 400 + } 258 401 259 402 trackMutation(mutationId, jjDescribe(repoPath, revision.change_id_short, description)) 260 403 .then((result) => { 261 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 262 - void reconcileOperation(repoPath, result.operation_id); 263 - toast.success(`Updated description for ${revision.change_id_short}`, { 264 - action: { 265 - label: "Undo", 266 - onClick: () => { 267 - undoOperation(repoPath, result.operation_id) 268 - .then(() => { 269 - queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 270 - toast.success("Undo successful"); 271 - }) 272 - .catch((err) => { 273 - toast.error(`Undo failed: ${err}`, { duration: Number.POSITIVE_INFINITY }); 274 - }); 275 - }, 276 - }, 277 - }); 404 + mutationSuccessWithUndo( 405 + repoPath, 406 + result.operation_id, 407 + `Updated description for ${revision.change_id_short}`, 408 + ); 278 409 }) 279 410 .catch((error) => { 280 - // Revert optimistic update on failure 281 - collection.utils.writeUpsert([{ ...revision, description: previousDescription }]); 411 + optimisticUpdate(collection, revision, (draft) => { 412 + draft.description = revision.description; 413 + }); 282 414 toast.error(`Failed to update description: ${error}`, { 283 415 duration: Number.POSITIVE_INFINITY, 284 416 }); ··· 305 437 return; 306 438 } 307 439 308 - const mutationId = `squash-${Date.now()}-${Math.random()}`; 440 + const mutationId = uniqueMutationId("squash"); 309 441 const shouldOptimisticallyDelete = !revision.is_working_copy; 310 442 311 - if (shouldOptimisticallyDelete) { 312 - collection.utils.writeDelete(getRevisionKey(revision)); 313 - } 314 - 315 - trackMutation(mutationId, jjSquash(repoPath, revision.change_id)) 316 - .then((result) => { 317 - void invalidateRepositoryQueries(repoPath); 443 + const action = createOptimisticAction<void>({ 444 + metadata: { mutationId, kind: "jj-squash", repoPath }, 445 + onMutate: () => { 446 + if (shouldOptimisticallyDelete) { 447 + optimisticDelete(collection, revision); 448 + } 449 + }, 450 + mutationFn: async () => { 451 + const result = await trackMutation(mutationId, jjSquash(repoPath, revision.change_id)); 452 + await invalidateRepositoryQueries(repoPath); 453 + await refetchRevisions(collection); 318 454 mutationSuccessWithUndo( 319 455 repoPath, 320 456 result.operation_id, 321 457 `Squashed ${revision.change_id_short} into parent`, 322 458 ); 323 - }) 324 - .catch((error) => { 325 - if (shouldOptimisticallyDelete) { 326 - collection.utils.writeUpsert([revision]); 327 - } 328 - toast.error(`Failed to squash revision: ${error}`, { 329 - duration: Number.POSITIVE_INFINITY, 330 - }); 459 + return result; 460 + }, 461 + }); 462 + 463 + action().isPersisted.promise.catch((error) => { 464 + toast.error(`Failed to squash revision: ${error}`, { 465 + duration: Number.POSITIVE_INFINITY, 331 466 }); 467 + }); 332 468 } 333 469 334 470 export function rebaseRevision( ··· 346 482 return; 347 483 } 348 484 349 - const mutationId = `rebase-${Date.now()}-${Math.random()}`; 350 - const previousParentEdges = sourceRevision.parent_edges; 485 + const mutationId = uniqueMutationId("rebase"); 351 486 352 - collection.utils.writeUpsert([ 353 - { 354 - ...sourceRevision, 355 - parent_edges: [{ parent_id: destinationRevision.commit_id, edge_type: "direct" as const }], 487 + const action = createOptimisticAction<void>({ 488 + metadata: { mutationId, kind: "jj-rebase", repoPath }, 489 + onMutate: () => { 490 + optimisticUpdate(collection, sourceRevision, (draft) => { 491 + draft.parent_edges = [ 492 + { parent_id: destinationRevision.commit_id, edge_type: "direct" as const }, 493 + ]; 494 + }); 356 495 }, 357 - ]); 358 - 359 - trackMutation( 360 - mutationId, 361 - jjRebase(repoPath, sourceRevision.change_id, destinationRevision.change_id), 362 - ) 363 - .then((result) => { 364 - void invalidateRepositoryQueries(repoPath); 496 + mutationFn: async () => { 497 + const result = await trackMutation( 498 + mutationId, 499 + jjRebase(repoPath, sourceRevision.change_id, destinationRevision.change_id), 500 + ); 501 + await invalidateRepositoryQueries(repoPath); 502 + await refetchRevisions(collection); 365 503 mutationSuccessWithUndo( 366 504 repoPath, 367 505 result.operation_id, 368 506 `Rebased ${sourceRevision.change_id_short} onto ${destinationRevision.change_id_short}`, 369 507 ); 370 - }) 371 - .catch((error) => { 372 - collection.utils.writeUpsert([ 373 - { 374 - ...sourceRevision, 375 - parent_edges: previousParentEdges, 376 - }, 377 - ]); 378 - toast.error(`Failed to rebase revision: ${error}`, { 379 - duration: Number.POSITIVE_INFINITY, 380 - }); 508 + return result; 509 + }, 510 + }); 511 + 512 + action().isPersisted.promise.catch((error) => { 513 + toast.error(`Failed to rebase revision: ${error}`, { 514 + duration: Number.POSITIVE_INFINITY, 381 515 }); 516 + }); 382 517 }