a very good jj gui
0
fork

Configure Feed

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

Plan 3: Model jj operation log as a first-class collection

- Add a TanStack DB operations collection keyed by jj operation id, with cached per-repository collection instances and an empty fallback collection.
- Add operation invalidation/reconciliation helpers so mutation results can refresh operation-log state around returned operation_id values.
- Wire repository watcher invalidation and revision/sync mutation success paths through operation reconciliation.
- Move the OperationsLog dialog from direct TanStack Query use to the new operations collection while preserving refresh and undo behavior.

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

+122 -27
+19 -24
apps/desktop/src/components/OperationsLog.tsx
··· 1 + import { useLiveQuery } from "@tanstack/react-db"; 1 2 import { RefreshCw } from "lucide-react"; 2 3 import { useMemo, useState } from "react"; 3 - import { invalidateRepositoryQueries, queryClient } from "@/db"; 4 - import { getOperations, type Operation, undoOperation } from "@/tauri-commands"; 4 + import { 5 + emptyOperationsCollection, 6 + getOperationsCollection, 7 + invalidateRepositoryQueries, 8 + } from "@/db"; 9 + import type { Operation } from "@/tauri-commands"; 10 + import { undoOperation } from "@/tauri-commands"; 5 11 import { Button } from "@/components/ui/button"; 6 12 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 7 13 import { toast } from "@/components/ui/sonner"; 8 - import { useQuery } from "@tanstack/react-query"; 9 14 10 15 interface OperationsLogProps { 11 16 repoPath: string | null; ··· 48 53 export function OperationsLog({ repoPath, open, onOpenChange }: OperationsLogProps) { 49 54 const [undoingOperationId, setUndoingOperationId] = useState<string | null>(null); 50 55 56 + const operationsCollection = 57 + repoPath && open ? getOperationsCollection(repoPath) : emptyOperationsCollection; 51 58 const { 52 59 data: operations = [], 53 60 isLoading, 54 - error, 55 - isFetching, 56 - refetch, 57 - } = useQuery({ 58 - queryKey: ["operations", repoPath], 59 - queryFn: () => getOperations(repoPath ?? "", 50), 60 - enabled: open && !!repoPath, 61 - retry: false, 62 - }); 61 + isError, 62 + collection, 63 + } = useLiveQuery(operationsCollection); 64 + const isFetching = isLoading; 63 65 64 66 const sortedOperations = useMemo(() => { 65 67 const copy = [...operations]; ··· 73 75 setUndoingOperationId(operation.id); 74 76 try { 75 77 await undoOperation(repoPath, operation.id); 76 - await Promise.all([ 77 - invalidateRepositoryQueries(repoPath), 78 - queryClient.invalidateQueries({ queryKey: ["operations", repoPath] }), 79 - ]); 78 + await invalidateRepositoryQueries(repoPath); 80 79 toast.success(`Undid operation ${operation.id.slice(0, 8)}`); 81 - void refetch(); 80 + void collection.preload(); 82 81 } catch (undoError) { 83 82 const message = undoError instanceof Error ? undoError.message : String(undoError); 84 83 const hint = getUndoHint(message); ··· 101 100 variant="ghost" 102 101 size="xs" 103 102 onClick={() => { 104 - void refetch(); 103 + void collection.preload(); 105 104 }} 106 105 disabled={!repoPath || isFetching} 107 106 > ··· 115 114 <p className="p-3 text-xs text-muted-foreground">Select a repository first.</p> 116 115 ) : isLoading ? ( 117 116 <p className="p-3 text-xs text-muted-foreground">Loading operations...</p> 118 - ) : error ? ( 119 - <p className="p-3 text-xs text-destructive"> 120 - {error instanceof Error 121 - ? error.message 122 - : `Failed to load operations: ${String(error)}`} 123 - </p> 117 + ) : isError ? ( 118 + <p className="p-3 text-xs text-destructive">Failed to load operations.</p> 124 119 ) : sortedOperations.length === 0 ? ( 125 120 <p className="p-3 text-xs text-muted-foreground">No operations found.</p> 126 121 ) : (
+5 -2
apps/desktop/src/data/actions/repo-actions.ts
··· 1 1 import { toast } from "@/components/ui/sonner"; 2 2 import { getRevisions, jjGitFetch, jjGitPush } from "@/tauri-commands"; 3 + import { reconcileOperation } from "../collections/operations"; 3 4 import { invalidateRepositoryQueries } from "../watchers"; 4 5 5 6 function isAuthError(errorText: string): boolean { ··· 18 19 const limit = preset === "full_history" ? 10000 : 100; 19 20 20 21 try { 21 - await jjGitFetch(repoPath, "origin"); 22 + const fetchResult = await jjGitFetch(repoPath, "origin"); 22 23 await invalidateRepositoryQueries(repoPath); 24 + void reconcileOperation(repoPath, fetchResult.operation_id); 23 25 24 26 const revisions = await getRevisions(repoPath, limit, undefined, preset); 25 27 const aheadBookmarks = Array.from( ··· 33 35 ); 34 36 35 37 if (aheadBookmarks.length > 0) { 36 - await jjGitPush(repoPath, aheadBookmarks, "origin"); 38 + const pushResult = await jjGitPush(repoPath, aheadBookmarks, "origin"); 37 39 await invalidateRepositoryQueries(repoPath); 40 + void reconcileOperation(repoPath, pushResult.operation_id); 38 41 } 39 42 } catch (error) { 40 43 const message = error instanceof Error ? error.message : String(error);
+80
apps/desktop/src/data/collections/operations.ts
··· 1 + import { createCollection } from "@tanstack/db"; 2 + import { queryCollectionOptions } from "@tanstack/query-db-collection"; 3 + import { getOperations, type Operation } from "@/tauri-commands"; 4 + import { queryClient } from "../query-client"; 5 + 6 + // ============================================================================ 7 + // Operations Collection 8 + // ============================================================================ 9 + 10 + const DEFAULT_OPERATIONS_LIMIT = 50; 11 + 12 + function operationsQueryKey(repoPath: string, limit = DEFAULT_OPERATIONS_LIMIT) { 13 + return ["operations", repoPath, limit] as const; 14 + } 15 + 16 + export function getOperationKey(operation: Operation): string { 17 + return operation.id; 18 + } 19 + 20 + export const emptyOperationsCollection = createCollection({ 21 + ...queryCollectionOptions({ 22 + queryClient, 23 + queryKey: ["operations", "empty"], 24 + queryFn: () => Promise.resolve([] as Operation[]), 25 + getKey: getOperationKey, 26 + }), 27 + }); 28 + 29 + const operationCollections = new Map<string, ReturnType<typeof createOperationsCollection>>(); 30 + 31 + function createOperationsCollection(repoPath: string, limit = DEFAULT_OPERATIONS_LIMIT) { 32 + return createCollection({ 33 + ...queryCollectionOptions({ 34 + queryClient, 35 + queryKey: operationsQueryKey(repoPath, limit), 36 + queryFn: () => getOperations(repoPath, limit), 37 + getKey: getOperationKey, 38 + }), 39 + }); 40 + } 41 + 42 + export type OperationsCollection = ReturnType<typeof createOperationsCollection>; 43 + 44 + export function getOperationsCollection( 45 + repoPath: string, 46 + limit = DEFAULT_OPERATIONS_LIMIT, 47 + ): OperationsCollection { 48 + const cacheKey = `${repoPath}:${limit}`; 49 + let collection = operationCollections.get(cacheKey); 50 + if (!collection) { 51 + collection = createOperationsCollection(repoPath, limit); 52 + operationCollections.set(cacheKey, collection); 53 + } 54 + return collection; 55 + } 56 + 57 + export async function invalidateOperations(repoPath: string): Promise<void> { 58 + await queryClient.invalidateQueries({ queryKey: ["operations", repoPath] }); 59 + } 60 + 61 + /** 62 + * Refresh operation-log state after a jj mutation returns an operation ID. 63 + * 64 + * This keeps the operation log as the reconciliation backbone without blocking 65 + * the optimistic mutation's perceived responsiveness: callers can fire and 66 + * forget this after their primary revision/status invalidation. 67 + */ 68 + export async function reconcileOperation(repoPath: string, operationId: string): Promise<void> { 69 + await invalidateOperations(repoPath); 70 + 71 + const knownCollections = [...operationCollections.entries()].filter(([key]) => 72 + key.startsWith(`${repoPath}:`), 73 + ); 74 + for (const [, collection] of knownCollections) { 75 + await collection.preload(); 76 + if (collection.state.has(operationId)) { 77 + return; 78 + } 79 + } 80 + }
+7 -1
apps/desktop/src/data/collections/revisions.ts
··· 14 14 undoOperation, 15 15 } from "@/tauri-commands"; 16 16 import { consumeChangeId } from "../change-id-pool"; 17 + import { reconcileOperation } from "./operations"; 17 18 import { trackMutation } from "../mutation-tracker"; 18 19 import { queryClient } from "../query-client"; 19 20 import { invalidateRepositoryQueries } from "../watchers"; ··· 24 25 title: string, 25 26 description?: string, 26 27 ) { 28 + void reconcileOperation(repoPath, operationId); 27 29 toast.success(title, { 28 30 description, 29 31 action: { ··· 108 110 109 111 // Track the mutation and fire backend 110 112 trackMutation(mutationId, jjEdit(repoPath, targetRevision.change_id_short)) 111 - .then((_result) => { 113 + .then((result) => { 112 114 // Invalidate to get fresh data from backend 113 115 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 116 + void reconcileOperation(repoPath, result.operation_id); 114 117 toast.success(`Working copy is now ${targetRevision.change_id_short}`); 115 118 }) 116 119 .catch((error) => { ··· 179 182 .then((result) => { 180 183 // Invalidate to get authoritative data (correct commit_id, short_id, etc.) 181 184 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 185 + void reconcileOperation(repoPath, result.operation_id); 182 186 const shortId = result.change_id?.slice(0, 8) ?? "unknown"; 183 187 toast.success(`Working copy is now ${shortId}`, { 184 188 description: "Created new revision", ··· 214 218 .then((result) => { 215 219 // Invalidate to get fresh data (especially for WC abandon which creates new WC) 216 220 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 221 + void reconcileOperation(repoPath, result.operation_id); 217 222 toast.success(`Abandoned revision ${revision.change_id_short}`, { 218 223 action: { 219 224 label: "Undo", ··· 254 259 trackMutation(mutationId, jjDescribe(repoPath, revision.change_id_short, description)) 255 260 .then((result) => { 256 261 queryClient.invalidateQueries({ queryKey: ["revisions", repoPath] }); 262 + void reconcileOperation(repoPath, result.operation_id); 257 263 toast.success(`Updated description for ${revision.change_id_short}`, { 258 264 action: { 259 265 label: "Undo",
+3
apps/desktop/src/data/watchers.ts
··· 1 1 import { listen } from "@tauri-apps/api/event"; 2 2 import { unwatchRepository, watchRepository } from "@/tauri-commands"; 3 + import { invalidateOperations } from "./collections/operations"; 3 4 import { inFlightMutations } from "./mutation-tracker"; 4 5 import { queryClient } from "./query-client"; 5 6 ··· 31 32 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 32 33 await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 33 34 await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 35 + await invalidateOperations(repoPath); 34 36 } 35 37 }); 36 38 ··· 60 62 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 61 63 await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 62 64 await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 65 + await invalidateOperations(repoPath); 63 66 }
+8
apps/desktop/src/db.ts
··· 48 48 type ChangesCollection, 49 49 } from "./data/collections/changes"; 50 50 export { diffsCollection, type DiffRecord, type DiffsCollection } from "./data/collections/diffs"; 51 + export { 52 + emptyOperationsCollection, 53 + getOperationKey, 54 + getOperationsCollection, 55 + invalidateOperations, 56 + reconcileOperation, 57 + type OperationsCollection, 58 + } from "./data/collections/operations";