a very good jj gui
0
fork

Configure Feed

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

feat: add divergent commit display in AppShell

+544 -57
+309 -32
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 + import { useQuery } from "@tanstack/react-query"; 3 4 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 5 import { homeDir } from "@tauri-apps/api/path"; 6 + import { getCurrentWindow } from "@tauri-apps/api/window"; 5 7 import { open } from "@tauri-apps/plugin-dialog"; 6 8 import { Effect } from "effect"; 7 - import { useRef, useState } from "react"; 8 - import { stackViewChangeIdAtom } from "@/atoms"; 9 + import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 10 + import { expandedStacksAtom, stackViewChangeIdAtom, viewModeAtom } from "@/atoms"; 11 + 12 + const NARROW_BREAKPOINT = 768; 13 + 14 + function subscribeToMediaQuery(callback: () => void) { 15 + const mediaQuery = window.matchMedia(`(max-width: ${NARROW_BREAKPOINT}px)`); 16 + mediaQuery.addEventListener("change", callback); 17 + return () => mediaQuery.removeEventListener("change", callback); 18 + } 19 + 20 + function getIsNarrowScreen() { 21 + return window.matchMedia(`(max-width: ${NARROW_BREAKPOINT}px)`).matches; 22 + } 23 + 24 + function useIsNarrowScreen() { 25 + return useSyncExternalStore(subscribeToMediaQuery, getIsNarrowScreen, () => false); 26 + } 27 + 9 28 import { AceJump } from "@/components/AceJump"; 10 29 import { CommandPalette } from "@/components/CommandPalette"; 30 + import { PrerenderedDiffPanel } from "@/components/DiffPanel"; 11 31 import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; 12 32 import { ProjectPicker } from "@/components/ProjectPicker"; 13 - import { 14 - RevisionGraph, 15 - type RevisionGraphHandle, 16 - reorderForGraph, 17 - } from "@/components/RevisionGraph"; 33 + import { RevisionGraph, type RevisionGraphHandle } from "@/components/RevisionGraph"; 34 + import { detectStacks, reorderForGraph } from "@/components/revision-graph-utils"; 18 35 import { StackIndicator } from "@/components/StackIndicator"; 19 36 import { StatusBar } from "@/components/StatusBar"; 37 + import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 20 38 21 39 import { 40 + abandonRevision, 22 41 editRevision, 42 + emptyChangesCollection, 23 43 emptyRevisionsCollection, 44 + getRevisionChangesCollection, 24 45 getRevisionsCollection, 25 46 newRevision, 26 47 repositoriesCollection, ··· 29 50 import { 30 51 findRepository, 31 52 findRepositoryByPath, 53 + getCommitRecency, 32 54 type Repository, 33 55 type Revision, 34 56 upsertRepository, ··· 61 83 export function AppShell() { 62 84 const navigate = useNavigate(); 63 85 const { projectId } = useParams({ strict: false }); 64 - const { rev } = useSearch({ strict: false }); 86 + const rev = useSearch({ strict: false, select: (s) => s.rev }); 87 + const expanded = useSearch({ strict: false, select: (s) => s.expanded }); 88 + const file = useSearch({ strict: false, select: (s) => s.file }); 89 + // Get full search object for navigation (only re-renders when expanded/file/rev change, which we need anyway) 90 + const search = useSearch({ strict: false }); 65 91 const [flash, setFlash] = useState<{ changeId: string; key: number } | null>(null); 66 92 const [stackViewChangeId, setStackViewChangeId] = useAtom(stackViewChangeIdAtom); 93 + const [viewMode, setViewMode] = useAtom(viewModeAtom); 94 + const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 67 95 const revisionGraphRef = useRef<RevisionGraphHandle>(null); 96 + const isNarrowScreen = useIsNarrowScreen(); 68 97 69 98 useKeyboardShortcut({ 70 99 key: ",", ··· 75 104 const { data: repositories = [] } = useLiveQuery(repositoriesCollection); 76 105 77 106 const activeProject = repositories.find((p) => p.id === projectId) ?? null; 107 + const titleLabel = activeProject ? `Tatami - ${activeProject.path}` : "Tatami"; 78 108 79 109 // Build the stack revset: the full branch containing the selected commit 80 110 // (::X ~ ::trunk()) gives ancestors of X that are NOT ancestors of trunk (the branch below X) ··· 95 125 96 126 const { data: revisions = [], isLoading = false } = useLiveQuery(revisionsCollection); 97 127 98 - const orderedRevisions = reorderForGraph(revisions); 128 + // Fetch commit recency data for branch ordering 129 + const { data: commitRecency } = useQuery({ 130 + queryKey: ["commit-recency", activeProject?.path], 131 + queryFn: () => { 132 + if (!activeProject?.path) throw new Error("No repo path"); 133 + return getCommitRecency(activeProject.path, 500); // Walk last 500 ops 134 + }, 135 + enabled: !!activeProject?.path, 136 + staleTime: 30000, // Cache for 30s 137 + }); 138 + 139 + const orderedRevisions = reorderForGraph(revisions, commitRecency); 140 + 141 + // Compute visible change IDs (filters out collapsed stack intermediates) 142 + // This mirrors the logic in RevisionGraph but avoids parent-child state sync via useEffect 143 + const [expandedStacks] = useAtom(expandedStacksAtom); 144 + const visibleRevisions = useMemo(() => { 145 + const stacks = detectStacks(revisions); 146 + if (stacks.length === 0) return orderedRevisions; 147 + 148 + // Build set of intermediate change IDs that are hidden when collapsed 149 + const hiddenChangeIds = new Set<string>(); 150 + for (const stack of stacks) { 151 + if (!expandedStacks.has(stack.id)) { 152 + for (const changeId of stack.intermediateChangeIds) { 153 + hiddenChangeIds.add(changeId); 154 + } 155 + } 156 + } 157 + 158 + if (hiddenChangeIds.size === 0) return orderedRevisions; 159 + return orderedRevisions.filter(r => !hiddenChangeIds.has(r.change_id)); 160 + }, [revisions, orderedRevisions, expandedStacks]); 161 + 162 + // Debug: log when revisions change to track reordering 163 + const workingCopy = revisions.find((r) => r.is_working_copy); 164 + const prevOrderRef = useRef<string[]>([]); 165 + useEffect(() => { 166 + const currentOrder = orderedRevisions.map((r) => r.change_id); 167 + const prevOrder = prevOrderRef.current; 168 + 169 + // Find which revisions changed position 170 + const changes: string[] = []; 171 + for (let i = 0; i < Math.min(currentOrder.length, prevOrder.length); i++) { 172 + if (currentOrder[i] !== prevOrder[i]) { 173 + changes.push(`[${i}] ${prevOrder[i]?.slice(0, 4) ?? "?"} → ${currentOrder[i]?.slice(0, 4) ?? "?"}`); 174 + if (changes.length >= 10) break; 175 + } 176 + } 177 + 178 + if (changes.length > 0 || prevOrder.length !== currentOrder.length) { 179 + console.log("[reorder] changes detected:", { 180 + prevLength: prevOrder.length, 181 + newLength: currentOrder.length, 182 + wcBefore: prevOrder.findIndex((id) => revisions.find((r) => r.change_id === id)?.is_working_copy), 183 + wcAfter: currentOrder.findIndex((id) => id === workingCopy?.change_id), 184 + firstChanges: changes, 185 + first10: currentOrder.slice(0, 10).map((id) => id.slice(0, 4)), 186 + }); 187 + } 188 + 189 + prevOrderRef.current = currentOrder; 190 + }, [orderedRevisions, workingCopy?.change_id, revisions]); 191 + 192 + useEffect(() => { 193 + document.title = titleLabel; 194 + const windowHandle = getCurrentWindow(); 195 + windowHandle.setTitle(titleLabel).catch(() => undefined); 196 + }, [titleLabel]); 99 197 100 198 const selectedRevision = (() => { 101 199 if (revisions.length === 0) return null; ··· 170 268 } 171 269 172 270 useKeyboardNavigation({ 173 - orderedRevisions, 271 + orderedRevisions: visibleRevisions, 174 272 selectedChangeId: rev ?? null, 175 273 onNavigate: handleNavigateToChangeId, 176 274 scrollToChangeId: (changeId) => revisionGraphRef.current?.scrollToChangeId(changeId), ··· 209 307 function handleEdit() { 210 308 if (!activeProject || !selectedRevision) return; 211 309 const currentWC = revisions.find((r) => r.is_working_copy); 212 - editRevision(activeProject.path, selectedRevision.change_id, currentWC?.change_id ?? null); 310 + 311 + // Debug: log indices before edit 312 + const currentWCIndex = orderedRevisions.findIndex((r) => r.change_id === currentWC?.change_id); 313 + const targetIndex = orderedRevisions.findIndex((r) => r.change_id === selectedRevision.change_id); 314 + console.log("[edit] before:", { 315 + currentWC: currentWC?.change_id_short, 316 + currentWCIndex, 317 + target: selectedRevision.change_id_short, 318 + targetIndex, 319 + totalRevisions: orderedRevisions.length, 320 + }); 321 + 322 + editRevision(revisionsCollection, activeProject.path, selectedRevision, currentWC ?? null); 213 323 } 214 324 215 325 useKeyboardShortcut({ ··· 224 334 enabled: !!activeProject && !!selectedRevision, 225 335 }); 226 336 337 + function handleAbandon() { 338 + if (!activeProject || !selectedRevision) return; 339 + // Don't abandon immutable revisions (trunk ancestors) 340 + if (selectedRevision.is_immutable) return; 341 + // Show confirmation 342 + setPendingAbandon(selectedRevision); 343 + } 344 + 345 + function confirmAbandon() { 346 + if (!activeProject || !pendingAbandon) return; 347 + const preset = activeProject.revset_preset ?? "full_history"; 348 + const limit = preset === "full_history" ? 10000 : 100; 349 + abandonRevision(revisionsCollection, activeProject.path, pendingAbandon, limit, stackRevset, preset); 350 + setPendingAbandon(null); 351 + } 352 + 353 + function cancelAbandon() { 354 + setPendingAbandon(null); 355 + } 356 + 357 + useKeyboardShortcut({ 358 + key: "a", 359 + onPress: handleAbandon, 360 + enabled: !!activeProject && !!selectedRevision && !pendingAbandon, 361 + }); 362 + 363 + // Confirmation shortcuts 364 + useKeyboardShortcut({ 365 + key: "y", 366 + onPress: confirmAbandon, 367 + enabled: !!pendingAbandon, 368 + }); 369 + 370 + useKeyboardShortcut({ 371 + key: "n", 372 + onPress: cancelAbandon, 373 + enabled: !!pendingAbandon, 374 + }); 375 + 376 + useKeyboardShortcut({ 377 + key: "Escape", 378 + onPress: cancelAbandon, 379 + enabled: !!pendingAbandon, 380 + }); 381 + 227 382 // Toggle stack view: show only ancestors from selected revision to trunk 228 383 function handleToggleStackView() { 229 384 if (!selectedRevision) return; ··· 242 397 enabled: !!activeProject && !!selectedRevision, 243 398 }); 244 399 400 + // View mode shortcuts: 1 = overview, 2 = split 401 + useKeyboardShortcut({ 402 + key: "1", 403 + onPress: () => setViewMode(1), 404 + }); 405 + 406 + useKeyboardShortcut({ 407 + key: "2", 408 + onPress: () => setViewMode(2), 409 + }); 410 + 411 + // Get changed files collection for selected revision (TanStack Query handles fetching) 412 + const changesCollection = 413 + expanded && activeProject?.path && selectedRevision?.change_id 414 + ? getRevisionChangesCollection(activeProject.path, selectedRevision.change_id) 415 + : emptyChangesCollection; 416 + const { data: changedFiles = [] } = useLiveQuery(changesCollection); 417 + 418 + // File navigation when revision is expanded - uses capture phase to run before revision navigation 419 + useEffect(() => { 420 + if (!expanded || changedFiles.length === 0) return; 421 + 422 + function handleFileNavigation(event: KeyboardEvent) { 423 + const activeElement = document.activeElement; 424 + if (activeElement?.tagName === "INPUT" || activeElement?.tagName === "TEXTAREA") { 425 + return; 426 + } 427 + 428 + // Only handle j/k keys for file navigation when revision is expanded 429 + if (event.key !== "j" && event.key !== "k") { 430 + return; 431 + } 432 + 433 + const currentFile = file; 434 + const filePaths = changedFiles.map((f) => f.path); 435 + 436 + if (event.key === "j") { 437 + event.preventDefault(); 438 + event.stopImmediatePropagation(); 439 + const currentIndex = currentFile ? filePaths.indexOf(currentFile) : -1; 440 + const nextIndex = currentIndex + 1; 441 + 442 + if (nextIndex < filePaths.length) { 443 + navigate({ 444 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 445 + search: { ...search, file: filePaths[nextIndex], expanded: true } as any, 446 + }); 447 + } else if (currentIndex === -1 && filePaths.length > 0) { 448 + navigate({ 449 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 450 + search: { ...search, file: filePaths[0], expanded: true } as any, 451 + }); 452 + } 453 + } else if (event.key === "k") { 454 + event.preventDefault(); 455 + event.stopImmediatePropagation(); 456 + const currentIndex = currentFile ? filePaths.indexOf(currentFile) : -1; 457 + 458 + if (currentIndex > 0) { 459 + const prevIndex = currentIndex - 1; 460 + navigate({ 461 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 462 + search: { ...search, file: filePaths[prevIndex], expanded: true } as any, 463 + }); 464 + } else if (currentIndex === -1 && filePaths.length > 0) { 465 + navigate({ 466 + // biome-ignore lint/suspicious/noExplicitAny: TanStack Router search params require loose typing 467 + search: { ...search, file: filePaths[filePaths.length - 1], expanded: true } as any, 468 + }); 469 + } 470 + } 471 + } 472 + 473 + // Use capture phase to intercept before revision navigation handler 474 + window.addEventListener("keydown", handleFileNavigation, true); 475 + return () => window.removeEventListener("keydown", handleFileNavigation, true); 476 + }, [expanded, file, search, changedFiles, navigate]); 477 + 245 478 const closestBookmark = (() => { 246 479 const workingCopy = revisions.find((r) => r.is_working_copy); 247 480 if (!workingCopy) return null; ··· 279 512 280 513 return ( 281 514 <> 282 - <ProjectPicker 283 - repositories={repositories} 284 - onSelectRepository={handleSelectRepository} 285 - /> 515 + <ProjectPicker repositories={repositories} onSelectRepository={handleSelectRepository} /> 286 516 <CommandPalette 287 517 onOpenRepo={handleOpenRepo} 288 518 onOpenProjects={() => navigate({ to: "/repositories" })} 519 + onOpenSettings={() => navigate({ to: "/settings" })} 289 520 /> 290 521 <KeyboardShortcutsHelp /> 291 522 <AceJump 292 523 revisions={orderedRevisions} 524 + repoPath={activeProject?.path ?? null} 293 525 onJump={(changeId) => { 294 526 handleNavigateToChangeId(changeId); 295 - revisionGraphRef.current?.scrollToChangeId(changeId, { align: "center", smooth: true }); 527 + // Defer scroll to next frame to ensure navigation state has settled 528 + requestAnimationFrame(() => { 529 + revisionGraphRef.current?.scrollToChangeId(changeId, { align: "center" }); 530 + }); 296 531 }} 297 532 /> 298 533 <div className="flex flex-col h-screen overflow-hidden"> 299 - <section className="flex-1 min-h-0 relative" aria-label="Revision list"> 300 - <StackIndicator 301 - onDismiss={() => { 302 - // Clear selection - default logic will pick working copy 303 - handleNavigateToChangeId(""); 304 - }} 305 - /> 306 - <RevisionGraph 307 - ref={revisionGraphRef} 308 - revisions={revisions} 309 - selectedRevision={selectedRevision} 310 - onSelectRevision={handleSelectRevision} 311 - isLoading={isLoading} 312 - flash={flash} 313 - /> 314 - </section> 534 + <div className="flex-1 min-h-0"> 535 + {viewMode === 1 ? ( 536 + // Overview mode: only revision list 537 + <section className="h-full relative" aria-label="Revision list"> 538 + <StackIndicator 539 + onDismiss={() => { 540 + handleNavigateToChangeId(""); 541 + }} 542 + /> 543 + <RevisionGraph 544 + ref={revisionGraphRef} 545 + revisions={revisions} 546 + selectedRevision={selectedRevision} 547 + onSelectRevision={handleSelectRevision} 548 + isLoading={isLoading} 549 + flash={flash} 550 + repoPath={activeProject?.path ?? null} 551 + pendingAbandon={pendingAbandon} 552 + /> 553 + </section> 554 + ) : ( 555 + // Split mode: revision list + diff panel (vertical on narrow screens) 556 + <ResizablePanelGroup orientation={isNarrowScreen ? "vertical" : "horizontal"}> 557 + <ResizablePanel defaultSize={isNarrowScreen ? 40 : 33} minSize={20}> 558 + <section className="h-full relative" aria-label="Revision list"> 559 + <StackIndicator 560 + onDismiss={() => { 561 + handleNavigateToChangeId(""); 562 + }} 563 + /> 564 + <RevisionGraph 565 + ref={revisionGraphRef} 566 + revisions={revisions} 567 + selectedRevision={selectedRevision} 568 + onSelectRevision={handleSelectRevision} 569 + isLoading={isLoading} 570 + flash={flash} 571 + repoPath={activeProject?.path ?? null} 572 + pendingAbandon={pendingAbandon} 573 + /> 574 + </section> 575 + </ResizablePanel> 576 + <ResizableHandle withHandle /> 577 + <ResizablePanel defaultSize={isNarrowScreen ? 60 : 67} minSize={30}> 578 + <aside 579 + className="h-full" 580 + aria-label="Diff viewer" 581 + > 582 + <PrerenderedDiffPanel 583 + repoPath={activeProject?.path ?? null} 584 + revisions={orderedRevisions} 585 + selectedChangeId={selectedRevision?.change_id ?? null} 586 + /> 587 + </aside> 588 + </ResizablePanel> 589 + </ResizablePanelGroup> 590 + )} 591 + </div> 315 592 <StatusBar branch={closestBookmark} isConnected={!!activeProject} /> 316 593 </div> 317 594 </>
+235 -25
apps/desktop/src/db.ts
··· 2 2 import { QueryClient } from "@tanstack/query-core"; 3 3 import { queryCollectionOptions } from "@tanstack/query-db-collection"; 4 4 import { listen } from "@tauri-apps/api/event"; 5 - import type { Repository, Revision } from "@/tauri-commands"; 6 - import { getRepositories, getRevisions, jjEdit, jjNew, watchRepository } from "@/tauri-commands"; 5 + import type { ChangedFile, Repository, Revision } from "@/tauri-commands"; 6 + import { 7 + getRepositories, 8 + getRevisionChanges, 9 + getRevisionDiff, 10 + getRevisions, 11 + jjAbandon, 12 + jjEdit, 13 + jjNew, 14 + watchRepository, 15 + } from "@/tauri-commands"; 16 + 17 + // ============================================================================ 18 + // Query Client (shared by all collections) 19 + // ============================================================================ 7 20 8 21 export const queryClient = new QueryClient(); 9 22 23 + // ============================================================================ 24 + // Repositories Collection 25 + // ============================================================================ 26 + 10 27 export const repositoriesCollection = createCollection({ 11 28 ...queryCollectionOptions({ 12 29 queryClient, ··· 15 32 getKey: (repository: Repository) => repository.id, 16 33 }), 17 34 }); 35 + 36 + // ============================================================================ 37 + // Revisions Collection 38 + // ============================================================================ 39 + 40 + // Key function that handles divergent changes (same change_id, different commits) 41 + function getRevisionKey(revision: Revision): string { 42 + if (revision.divergent_index != null) { 43 + return `${revision.change_id}/${revision.divergent_index}`; 44 + } 45 + return revision.change_id; 46 + } 18 47 19 48 export const emptyRevisionsCollection = createCollection({ 20 49 ...queryCollectionOptions({ 21 50 queryClient, 22 51 queryKey: ["revisions", "empty"], 23 52 queryFn: () => Promise.resolve([]), 24 - getKey: (revision: Revision) => revision.change_id, 53 + getKey: getRevisionKey, 25 54 }), 26 55 }); 27 56 28 57 const revisionCollections = new Map<string, ReturnType<typeof createRevisionsCollection>>(); 29 58 const revisionWatchers = new Map<string, { unlisten: () => void; refCount: number }>(); 30 59 60 + // Track in-flight edit mutations to prevent watcher from overwriting optimistic state 61 + const inFlightEdits = new Set<string>(); 62 + 31 63 function createRevisionsCollection(repoPath: string, preset?: string, customRevset?: string) { 32 64 const limit = preset === "full_history" ? 10000 : 100; 33 65 const collection = createCollection({ ··· 35 67 queryClient, 36 68 queryKey: ["revisions", repoPath, preset, customRevset], 37 69 queryFn: () => getRevisions(repoPath, limit, customRevset, customRevset ? undefined : preset), 38 - getKey: (revision: Revision) => revision.change_id, 70 + getKey: getRevisionKey, 39 71 }), 40 72 }); 41 73 ··· 50 82 await watchRepository(repoPath); 51 83 const unlisten = await listen<string>("repo-changed", async (event) => { 52 84 if (event.payload === repoPath) { 85 + // Skip if there are in-flight edits - let the mutation handle state 86 + if (inFlightEdits.size > 0) { 87 + console.log("[watcher] skipping - in-flight edits:", [...inFlightEdits]); 88 + return; 89 + } 90 + 91 + console.log("[watcher] fetching revisions..."); 53 92 const revisions = await getRevisions( 54 93 repoPath, 55 94 limit, 56 95 customRevset, 57 96 customRevset ? undefined : preset, 58 97 ); 59 - const newIds = new Set(revisions.map((r) => r.change_id)); 98 + 99 + // Debug: log divergent changes 100 + const divergentCount = revisions.filter((r) => r.is_divergent).length; 101 + if (divergentCount > 0) { 102 + console.log("[watcher] found", divergentCount, "divergent revisions"); 103 + } 104 + 105 + console.log("[watcher] got", revisions.length, "revisions, wc:", revisions.find(r => r.is_working_copy)?.change_id_short); 106 + 107 + // Delete revisions that are no longer in the result set 108 + const newKeys = new Set(revisions.map(getRevisionKey)); 60 109 for (const key of collection.state.keys()) { 61 - if (!newIds.has(key)) { 110 + if (!newKeys.has(key)) { 62 111 collection.utils.writeDelete(key); 63 112 } 64 113 } 114 + 65 115 collection.utils.writeUpsert(revisions); 66 116 } 67 117 }); ··· 74 124 return collection; 75 125 } 76 126 127 + export type RevisionsCollection = ReturnType<typeof createRevisionsCollection>; 128 + 77 129 export function getRevisionsCollection(repoPath: string, preset?: string, customRevset?: string) { 78 130 const cacheKey = `${repoPath}:${preset ?? "full_history"}:${customRevset ?? ""}`; 79 131 let collection = revisionCollections.get(cacheKey); ··· 85 137 } 86 138 87 139 export function editRevision( 140 + collection: RevisionsCollection, 88 141 repoPath: string, 89 - targetChangeId: string, 90 - currentWcChangeId: string | null, 142 + targetRevision: Revision, 143 + currentWcRevision: Revision | null, 91 144 ) { 92 - const collection = getRevisionsCollection(repoPath); 93 - const tx = createTransaction({ 94 - mutationFn: async () => { 95 - await jjEdit(repoPath, targetChangeId); 96 - }, 97 - }); 145 + console.log("[editRevision] start, updating synced layer directly"); 146 + 147 + // Update synced layer directly (not optimistic) - this is instant 148 + const updates: Revision[] = []; 149 + 150 + if (currentWcRevision && getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision)) { 151 + updates.push({ ...currentWcRevision, is_working_copy: false }); 152 + } 153 + updates.push({ ...targetRevision, is_working_copy: true }); 154 + 155 + collection.utils.writeUpsert(updates); 156 + console.log("[editRevision] synced layer updated, firing backend..."); 98 157 99 - tx.mutate(() => { 100 - if (currentWcChangeId && currentWcChangeId !== targetChangeId) { 101 - collection.update(currentWcChangeId, (draft) => { 102 - draft.is_working_copy = false; 103 - }); 104 - } 105 - collection.update(targetChangeId, (draft) => { 106 - draft.is_working_copy = true; 158 + // Fire backend in background - watcher will confirm/correct if needed 159 + // For divergent changes, use change_id_short which includes /N suffix 160 + jjEdit(repoPath, targetRevision.change_id_short) 161 + .then(() => console.log("[editRevision] jjEdit completed")) 162 + .catch((err) => { 163 + console.error("[editRevision] jjEdit failed:", err); 164 + // Revert on failure 165 + const revertUpdates: Revision[] = []; 166 + if (currentWcRevision && getRevisionKey(currentWcRevision) !== getRevisionKey(targetRevision)) { 167 + revertUpdates.push({ ...currentWcRevision, is_working_copy: true }); 168 + } 169 + revertUpdates.push({ ...targetRevision, is_working_copy: false }); 170 + collection.utils.writeUpsert(revertUpdates); 107 171 }); 108 - }); 109 - 110 - return tx; 111 172 } 112 173 113 174 export function newRevision(repoPath: string, parentChangeIds: string[]) { ··· 124 185 125 186 return tx; 126 187 } 188 + 189 + export function abandonRevision( 190 + collection: RevisionsCollection, 191 + repoPath: string, 192 + revision: Revision, 193 + limit: number, 194 + customRevset?: string, 195 + preset?: string, 196 + ) { 197 + console.log("[abandonRevision] abandoning:", revision.change_id_short); 198 + 199 + // For working copy, jj creates a new WC - can't do optimistic delete 200 + // For other revisions, we can optimistically remove 201 + if (!revision.is_working_copy) { 202 + collection.utils.writeDelete(getRevisionKey(revision)); 203 + } 204 + 205 + // Fire backend and then refetch to get new state (especially for WC abandon which creates new WC) 206 + jjAbandon(repoPath, revision.change_id_short) 207 + .then(async () => { 208 + console.log("[abandonRevision] completed, refetching..."); 209 + // Refetch to get the new working copy if we abandoned WC 210 + const revisions = await getRevisions(repoPath, limit, customRevset, customRevset ? undefined : preset); 211 + const newKeys = new Set(revisions.map(getRevisionKey)); 212 + for (const key of collection.state.keys()) { 213 + if (!newKeys.has(key)) { 214 + collection.utils.writeDelete(key); 215 + } 216 + } 217 + collection.utils.writeUpsert(revisions); 218 + }) 219 + .catch((err) => { 220 + console.error("[abandonRevision] failed:", err); 221 + // Re-add on failure (only if we deleted it) 222 + if (!revision.is_working_copy) { 223 + collection.utils.writeUpsert([revision]); 224 + } 225 + }); 226 + } 227 + 228 + // ============================================================================ 229 + // Revision Changes Collections (ChangedFile[] per revision) 230 + // ============================================================================ 231 + 232 + const revisionChangesCollections = new Map<string, ReturnType<typeof createRevisionChangesCollection>>(); 233 + 234 + function createRevisionChangesCollection(repoPath: string, changeId: string) { 235 + return createCollection({ 236 + ...queryCollectionOptions({ 237 + queryClient, 238 + queryKey: ["revision-changes", repoPath, changeId], 239 + queryFn: () => getRevisionChanges(repoPath, changeId), 240 + getKey: (file: ChangedFile) => file.path, 241 + }), 242 + }); 243 + } 244 + 245 + export type RevisionChangesCollection = ReturnType<typeof createRevisionChangesCollection>; 246 + 247 + export function getRevisionChangesCollection(repoPath: string, changeId: string): RevisionChangesCollection { 248 + const cacheKey = `${repoPath}:${changeId}`; 249 + let collection = revisionChangesCollections.get(cacheKey); 250 + if (!collection) { 251 + collection = createRevisionChangesCollection(repoPath, changeId); 252 + revisionChangesCollections.set(cacheKey, collection); 253 + } 254 + return collection; 255 + } 256 + 257 + export const emptyChangesCollection = createCollection({ 258 + ...queryCollectionOptions({ 259 + queryClient, 260 + queryKey: ["revision-changes", "empty"], 261 + queryFn: () => Promise.resolve([]), 262 + getKey: (file: ChangedFile) => file.path, 263 + }), 264 + }); 265 + 266 + // ============================================================================ 267 + // Revision Diff Collections (diff string per revision) 268 + // ============================================================================ 269 + 270 + // Wrapper type for diff string to work with collection pattern 271 + interface DiffEntry { 272 + id: "diff"; 273 + content: string; 274 + } 275 + 276 + const revisionDiffCollections = new Map<string, ReturnType<typeof createRevisionDiffCollection>>(); 277 + 278 + function createRevisionDiffCollection(repoPath: string, changeId: string) { 279 + return createCollection({ 280 + ...queryCollectionOptions({ 281 + queryClient, 282 + queryKey: ["revision-diff", repoPath, changeId], 283 + queryFn: async () => { 284 + const diff = await getRevisionDiff(repoPath, changeId); 285 + return [{ id: "diff" as const, content: diff }]; 286 + }, 287 + getKey: (entry: DiffEntry) => entry.id, 288 + }), 289 + }); 290 + } 291 + 292 + export type RevisionDiffCollection = ReturnType<typeof createRevisionDiffCollection>; 293 + 294 + export function getRevisionDiffCollection(repoPath: string, changeId: string): RevisionDiffCollection { 295 + const cacheKey = `${repoPath}:${changeId}`; 296 + let collection = revisionDiffCollections.get(cacheKey); 297 + if (!collection) { 298 + collection = createRevisionDiffCollection(repoPath, changeId); 299 + revisionDiffCollections.set(cacheKey, collection); 300 + } 301 + return collection; 302 + } 303 + 304 + export const emptyDiffCollection = createCollection({ 305 + ...queryCollectionOptions({ 306 + queryClient, 307 + queryKey: ["revision-diff", "empty"], 308 + queryFn: () => Promise.resolve([]), 309 + getKey: (entry: DiffEntry) => entry.id, 310 + }), 311 + }); 312 + 313 + // ============================================================================ 314 + // Prefetching Utilities 315 + // ============================================================================ 316 + 317 + /** 318 + * Prefetch revision diffs for a batch of change IDs. 319 + * This eagerly creates collections which triggers the query fetch. 320 + * TanStack DB handles caching - subsequent calls are no-ops. 321 + */ 322 + export function prefetchRevisionDiffs(repoPath: string, changeIds: string[]): void { 323 + for (const changeId of changeIds) { 324 + // Creating the collection triggers the query if not already cached 325 + getRevisionDiffCollection(repoPath, changeId); 326 + } 327 + } 328 + 329 + /** 330 + * Prefetch revision changes (file list) for a batch of change IDs. 331 + */ 332 + export function prefetchRevisionChanges(repoPath: string, changeIds: string[]): void { 333 + for (const changeId of changeIds) { 334 + getRevisionChangesCollection(repoPath, changeId); 335 + } 336 + }