a very good jj gui
0
fork

Configure Feed

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

+911 -514
+45
.fp/FP_CLAUDE.md
··· 1 + <!-- DO NOT EDIT .fp/FP_CLAUDE.md: This file is managed by fp. Run 'fp setup claude' to update. --> 2 + 3 + ## FP Issue Tracking 4 + 5 + This project uses **fp** for issue tracking. AI agents must follow these rules. 6 + 7 + ### Task Tracking 8 + 9 + - Use `fp issue` for all task tracking - do not use built-in todo tools 10 + - Create subissues with `--parent` flag - never use markdown checklists (`- [ ]`) 11 + - Break work into atomic tasks (1-3 hours each) 12 + 13 + ### Work Session Flow 14 + 15 + 1. `fp issue list --status todo` - find available work 16 + 2. `fp issue update --status in-progress <id>` - claim it before starting 17 + 3. Work and commit frequently 18 + 4. `fp comment <id> "progress..."` - log at every milestone 19 + 5. `fp issue assign <id> --rev <commit>` - attach commits to the issue 20 + 6. `fp issue update --status done <id>` - mark complete when finished 21 + 22 + ### Commit Discipline 23 + 24 + - Commit early and often with descriptive messages 25 + - Always commit before session ends 26 + - Always commit before context compaction 27 + 28 + ### Progress Logging 29 + 30 + - Run `fp comment <id> "..."` at every milestone 31 + - Log after significant commits 32 + - Always leave a final comment before ending session 33 + 34 + ### Commands Reference 35 + 36 + ```bash 37 + fp tree # View issue hierarchy 38 + fp issue list --status X # Filter by status (todo/in-progress/done) 39 + fp issue create --title "..." --parent X 40 + fp issue update --status X <id> 41 + fp issue assign <id> --rev X # Attach commit(s) to issue 42 + fp comment <id> "message" 43 + fp issue diff <id> # See changes since task started 44 + fp context <id> # Load full issue context 45 + ```
+2
CLAUDE.md
··· 70 70 - **Tauri** (2.1) - Desktop application framework 71 71 - **SQLx** - SQLite database access 72 72 - **notify** - File system watching 73 + 74 + @.fp/FP_CLAUDE.md
+84 -5
apps/desktop/src-tauri/src/lib.rs
··· 2 2 mod storage; 3 3 mod watcher; 4 4 5 + /// Check if content appears to be binary (contains null bytes in first 8KB) 6 + fn is_binary_content(content: &[u8]) -> bool { 7 + let check_len = content.len().min(8192); 8 + content[..check_len].contains(&0) 9 + } 10 + 5 11 use repo::diff; 6 12 use repo::jj::{JjRepo, MutationResult, Operation}; 7 13 use repo::log::{LineageResult, Revision, RevsetResult}; ··· 231 237 fn compute_revision_diff_inner(jj_repo: &JjRepo, change_id: &str) -> Result<String, String> { 232 238 use jj_lib::backend::TreeValue; 233 239 use jj_lib::matchers::EverythingMatcher; 240 + use rayon::prelude::*; 241 + use std::time::Instant; 234 242 243 + let total_start = Instant::now(); 244 + 245 + let t0 = Instant::now(); 235 246 let commit = jj_repo 236 247 .get_commit(change_id) 237 248 .map_err(|e| format!("Failed to get commit: {}", e))?; 249 + let get_commit_ms = t0.elapsed().as_millis(); 238 250 251 + let t0 = Instant::now(); 239 252 let parent_tree = { 240 253 let parents = commit.parents(); 241 254 let parent = parents ··· 247 260 .tree() 248 261 .map_err(|e| format!("Failed to get parent tree: {}", e))? 249 262 }; 263 + let parent_tree_ms = t0.elapsed().as_millis(); 250 264 265 + let t0 = Instant::now(); 251 266 let commit_tree = commit 252 267 .tree() 253 268 .map_err(|e| format!("Failed to get commit tree: {}", e))?; 269 + let commit_tree_ms = t0.elapsed().as_millis(); 254 270 255 271 let matcher = EverythingMatcher; 256 272 let mut diff_iter = parent_tree.diff_stream(&commit_tree, &matcher); 257 273 274 + let t0 = Instant::now(); 258 275 let repo = jj_repo 259 276 .repo_loader() 260 277 .load_at_head() 261 278 .map_err(|e| format!("Failed to load repo: {}", e))?; 279 + let load_repo_ms = t0.elapsed().as_millis(); 262 280 263 - let mut unified_diffs = Vec::new(); 281 + // Phase 1: Collect file contents sequentially (requires JjRepo access) 282 + let mut file_contents: Vec<(String, Vec<u8>, Vec<u8>)> = Vec::new(); 283 + let mut total_old_content_ms: u128 = 0; 284 + let mut total_new_content_ms: u128 = 0; 285 + let mut total_content_bytes: usize = 0; 264 286 265 287 pollster::block_on(async { 266 288 use futures::StreamExt; ··· 278 300 (Some(TreeValue::File { .. }), Some(TreeValue::File { .. })) 279 301 | (None, Some(TreeValue::File { .. })) 280 302 | (Some(TreeValue::File { .. }), None) => { 303 + let t0 = Instant::now(); 281 304 let old_content = jj_repo 282 305 .get_parent_file_content_with_repo(repo.as_ref(), &commit, path_str) 283 306 .unwrap_or_default(); 307 + total_old_content_ms += t0.elapsed().as_millis(); 284 308 309 + let t0 = Instant::now(); 285 310 let new_content = jj_repo 286 311 .get_file_content_with_repo(repo.as_ref(), &commit, path_str) 287 312 .unwrap_or_default(); 313 + total_new_content_ms += t0.elapsed().as_millis(); 288 314 289 - let file_diff = diff::compute_file_diff(&old_content, &new_content, path_str) 290 - .map_err(|e| format!("Failed to compute diff: {}", e))?; 315 + total_content_bytes += old_content.len() + new_content.len(); 291 316 292 - if !file_diff.is_empty() { 293 - unified_diffs.push(file_diff); 317 + // Skip binary files 318 + if is_binary_content(&old_content) || is_binary_content(&new_content) { 319 + continue; 294 320 } 321 + 322 + file_contents.push((path_str.to_string(), old_content, new_content)); 295 323 } 296 324 _ => continue, 297 325 }; ··· 299 327 Ok::<(), String>(()) 300 328 })?; 301 329 330 + let file_count = file_contents.len(); 331 + 332 + // Phase 2: Compute diffs in parallel (pure computation, no JjRepo needed) 333 + let t0 = Instant::now(); 334 + let unified_diffs: Vec<String> = file_contents 335 + .par_iter() 336 + .filter_map(|(path, old, new)| diff::compute_file_diff(old, new, path).ok()) 337 + .filter(|d| !d.is_empty()) 338 + .collect(); 339 + let total_diff_compute_ms = t0.elapsed().as_millis(); 340 + 341 + let total_ms = total_start.elapsed().as_millis(); 342 + 343 + // Only log if total time is significant (>10ms) 344 + if total_ms > 10 { 345 + eprintln!( 346 + "[DIFF-TRACE] change={} total={}ms | get_commit={}ms parent_tree={}ms commit_tree={}ms load_repo={}ms | files={} old_content={}ms new_content={}ms diff_compute={}ms content_kb={}", 347 + &change_id[..8.min(change_id.len())], 348 + total_ms, 349 + get_commit_ms, 350 + parent_tree_ms, 351 + commit_tree_ms, 352 + load_repo_ms, 353 + file_count, 354 + total_old_content_ms, 355 + total_new_content_ms, 356 + total_diff_compute_ms, 357 + total_content_bytes / 1024 358 + ); 359 + } 360 + 302 361 Ok(unified_diffs.join("\n")) 303 362 } 304 363 ··· 367 426 repo_path: String, 368 427 change_ids: Vec<String>, 369 428 ) -> Result<Vec<RevisionDiff>, String> { 429 + use std::time::Instant; 430 + 431 + let batch_start = Instant::now(); 432 + let batch_size = change_ids.len(); 433 + 434 + let t0 = Instant::now(); 370 435 let path = Path::new(&repo_path); 371 436 let jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 437 + let open_repo_ms = t0.elapsed().as_millis(); 372 438 373 439 // Process sequentially since JjRepo is not Sync 374 440 let results: Vec<RevisionDiff> = change_ids ··· 383 449 } 384 450 }) 385 451 .collect(); 452 + 453 + let total_ms = batch_start.elapsed().as_millis(); 454 + 455 + // Log batch summary 456 + if total_ms > 10 { 457 + eprintln!( 458 + "[DIFF-BATCH] count={} total={}ms open_repo={}ms avg_per_diff={}ms", 459 + batch_size, 460 + total_ms, 461 + open_repo_ms, 462 + if batch_size > 0 { total_ms / batch_size as u128 } else { 0 } 463 + ); 464 + } 386 465 387 466 Ok(results) 388 467 }
+5
apps/desktop/src/atoms.ts
··· 12 12 export const expandedStacksAtom = Atom.make(new Set<string>()); 13 13 // Tracks which stack is currently hovered (for coordinated edge highlighting) 14 14 export const hoveredStackIdAtom = Atom.make<string | null>(null); 15 + // Persists revision graph scroll position across view mode changes 16 + export const revisionGraphScrollTopAtom = Atom.make<number>(0); 17 + // Debounced changeId for DiffPanel - updates 200ms after navigation settles 18 + // This prevents expensive DiffPanel re-renders during rapid j/k navigation 19 + export const debouncedChangeIdAtom = Atom.make<string | null>(null); 15 20 16 21 // Bookmark drag state - tracks which bookmark is being dragged and from which revision 17 22 export type DraggingBookmark = {
+28 -11
apps/desktop/src/components/AppShell.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; 4 - import { Profiler, useMemo, useRef, useState, useSyncExternalStore } from "react"; 4 + import { Profiler, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; 5 5 import { Route as ProjectRoute } from "@/routes/project.$projectId"; 6 - import { expandedStacksAtom, viewModeAtom } from "@/atoms"; 6 + import { debouncedChangeIdAtom, expandedStacksAtom, viewModeAtom } from "@/atoms"; 7 7 8 8 const NARROW_BREAKPOINT = 768; 9 9 ··· 46 46 import { useAddRepository } from "@/hooks/useAddRepository"; 47 47 import { useAppTitle } from "@/hooks/useAppTitle"; 48 48 import { useKeyboardNavigation, useKeyboardShortcut, useKeySequence } from "@/hooks/useKeyboard"; 49 + import { useSelectedRevision } from "@/hooks/useSelectedRevision"; 49 50 import type { Repository, Revision } from "@/tauri-commands"; 50 51 import { onRenderCallback } from "@/lib/trace"; 51 52 ··· 168 169 return orderedRevisions.filter((r) => !hiddenChangeIds.has(r.change_id)); 169 170 }, [revisions, orderedRevisions, expandedStacks]); 170 171 171 - const selectedRevision = (() => { 172 - if (revisions.length === 0) return null; 173 - if (rev) { 174 - // Match using revision key to handle divergent revisions (e.g., "tpuq/0") 175 - const found = revisions.find((r) => getRevisionKey(r) === rev); 176 - if (found) return found; 172 + const selectedRevision = useSelectedRevision(revisions, rev); 173 + 174 + // Debounce the changeId passed to DiffPanel to avoid expensive re-renders during rapid navigation 175 + // DiffPanel only updates when navigation settles (200ms without movement) 176 + const selectedChangeId = selectedRevision?.change_id ?? null; 177 + const [debouncedChangeId, setDebouncedChangeId] = useAtom(debouncedChangeIdAtom); 178 + const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 179 + 180 + useEffect(() => { 181 + // Clear any pending debounce 182 + if (debounceTimerRef.current) { 183 + clearTimeout(debounceTimerRef.current); 177 184 } 178 - return revisions.find((r) => r.is_working_copy) || revisions[0]; 179 - })(); 185 + 186 + // Update after 200ms of no changes 187 + debounceTimerRef.current = setTimeout(() => { 188 + setDebouncedChangeId(selectedChangeId); 189 + }, 200); 190 + 191 + return () => { 192 + if (debounceTimerRef.current) { 193 + clearTimeout(debounceTimerRef.current); 194 + } 195 + }; 196 + }, [selectedChangeId, setDebouncedChangeId]); 180 197 181 198 function handleSelectRepository(repository: Repository) { 182 199 navigate({ to: "/project/$projectId", params: { projectId: repository.id } }); ··· 455 472 ref={diffPanelRef} 456 473 repoPath={activeProject?.path ?? null} 457 474 revisions={orderedRevisions} 458 - selectedChangeId={selectedRevision?.change_id ?? null} 475 + selectedChangeId={debouncedChangeId} 459 476 revisionsPanelRef={revisionsPanelRef} 460 477 /> 461 478 </Profiler>
+378 -295
apps/desktop/src/components/DiffPanel.tsx
··· 2 2 import { PatchDiff } from "@pierre/diffs/react"; 3 3 import { Columns2Icon, Loader2, RowsIcon } from "lucide-react"; 4 4 import type { FocusEvent, RefObject } from "react"; 5 - import { 6 - forwardRef, 7 - useCallback, 8 - useDeferredValue, 9 - useEffect, 10 - useMemo, 11 - useRef, 12 - useState, 13 - } from "react"; 5 + import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; 14 6 import { type DiffStyle, type DiffViewState, diffStyleAtom, diffViewStateAtom } from "@/atoms"; 15 7 import { FileList, RevisionHeader } from "@/components/diff"; 16 8 import { ImageDiff } from "@/components/diff/ImageDiff"; 17 9 import { Button } from "@/components/ui/button"; 18 10 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 19 11 import { ScrollArea } from "@/components/ui/scroll-area"; 12 + import { Skeleton } from "@/components/ui/skeleton"; 20 13 import { useDiffPanelKeyboard } from "@/hooks/useDiffPanelKeyboard"; 21 14 import { useChanges, useDiff, usePrefetch } from "@/hooks/useRevisionData"; 22 15 import { extractFilePath, parsePatchStats, splitMultiFileDiff } from "@/lib/diff-utils"; ··· 59 52 ); 60 53 61 54 /** 55 + * Loading skeleton for DiffPanel - shown during Suspense fallback. 56 + * Mimics the structure of the real DiffPanel for smooth transitions. 57 + */ 58 + export function DiffPanelSkeleton() { 59 + return ( 60 + <div className="h-full w-full flex flex-col bg-background"> 61 + {/* Revision header skeleton */} 62 + <div className="px-4 pt-2 pb-2 shrink-0"> 63 + <div className="flex items-center gap-2"> 64 + <Skeleton className="h-5 w-16" /> 65 + <Skeleton className="h-4 w-48" /> 66 + </div> 67 + </div> 68 + 69 + {/* Toolbar skeleton */} 70 + <div className="flex items-center justify-end px-3 py-2 border-b border-border bg-background shrink-0"> 71 + <div className="flex items-center gap-0.5"> 72 + <Skeleton className="h-6 w-6 rounded" /> 73 + <Skeleton className="h-6 w-6 rounded" /> 74 + </div> 75 + </div> 76 + 77 + {/* Content skeleton - two column layout */} 78 + <div className="flex-1 min-h-0 flex"> 79 + {/* File list skeleton */} 80 + <div className="w-[30%] border-r border-border p-2 space-y-2"> 81 + <Skeleton className="h-4 w-full" /> 82 + <Skeleton className="h-4 w-3/4" /> 83 + <Skeleton className="h-4 w-5/6" /> 84 + <Skeleton className="h-4 w-2/3" /> 85 + <Skeleton className="h-4 w-4/5" /> 86 + </div> 87 + 88 + {/* Diff content skeleton */} 89 + <div className="flex-1 p-4 space-y-3"> 90 + <div className="flex items-center justify-center h-full"> 91 + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> 92 + </div> 93 + </div> 94 + </div> 95 + </div> 96 + ); 97 + } 98 + 99 + /** 62 100 * Multi-file diff viewer - shows multiple diffs in a scrollable container 63 101 */ 64 102 function MultiFileDiff({ ··· 74 112 repoPath: string; 75 113 changeId: string; 76 114 }) { 115 + // Track render timing 116 + const renderStart = performance.now(); 117 + const totalPatchBytes = patches.reduce((sum, p) => sum + p.patch.length, 0); 118 + 77 119 if (patches.length === 0) { 78 120 return ( 79 121 <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> ··· 82 124 ); 83 125 } 84 126 85 - return ( 127 + const result = ( 86 128 <ScrollArea className="h-full w-full"> 87 129 <div className="divide-y divide-border"> 88 130 {patches.map(({ path, patch, status }) => { ··· 120 162 </div> 121 163 </ScrollArea> 122 164 ); 165 + 166 + const renderMs = performance.now() - renderStart; 167 + if (renderMs > 5) { 168 + traceLog("MultiFileDiff-render", { 169 + changeId: changeId.slice(0, 8), 170 + files: patches.length, 171 + patchKB: Math.round(totalPatchBytes / 1024), 172 + renderMs: Math.round(renderMs), 173 + }); 174 + } 175 + 176 + return result; 123 177 } 124 178 125 179 /** ··· 139 193 }; 140 194 } 141 195 142 - export const DiffPanel = forwardRef<HTMLDivElement, DiffPanelProps>(function DiffPanel( 143 - { repoPath, changeId, revision, revisionsPanelRef }, 144 - ref, 145 - ) { 146 - // Defer changeId updates so revision selection highlight updates immediately 147 - // while diff panel data fetching/rendering happens on the next frame 148 - const deferredChangeId = useDeferredValue(changeId); 196 + /** 197 + * Custom comparison function for DiffPanel memoization. 198 + * Only re-renders when essential props change, ignoring ref changes. 199 + */ 200 + function diffPanelPropsAreEqual(prevProps: DiffPanelProps, nextProps: DiffPanelProps): boolean { 201 + return ( 202 + prevProps.repoPath === nextProps.repoPath && 203 + prevProps.changeId === nextProps.changeId && 204 + prevProps.revision?.change_id === nextProps.revision?.change_id 205 + // Note: revisionsPanelRef is intentionally excluded - refs are stable 206 + ); 207 + } 149 208 150 - const containerRef = useRef<HTMLDivElement>(null); 151 - const scrollContainerRef = useRef<HTMLDivElement>(null); 152 - const [globalDiffStyle] = useAtom(diffStyleAtom); 153 - const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 154 - const [hasFocus, setHasFocus] = useState(false); 209 + export const DiffPanel = React.memo( 210 + forwardRef<HTMLDivElement, DiffPanelProps>(function DiffPanel( 211 + { repoPath, changeId, revision, revisionsPanelRef }, 212 + ref, 213 + ) { 214 + // Use changeId directly - data is prefetched and cached, so no need to defer 215 + // (useDeferredValue was adding unnecessary latency when data is already available) 216 + const deferredChangeId = changeId; 155 217 156 - // Track selected files with the changeId they belong to 157 - // When changeId changes, we reset selection during render (no useEffect needed) 158 - const [selectedFilesState, setSelectedFilesState] = useState<{ 159 - forChangeId: string | null; 160 - files: Set<string>; 161 - }>({ forChangeId: null, files: new Set() }); 218 + const containerRef = useRef<HTMLDivElement>(null); 219 + const scrollContainerRef = useRef<HTMLDivElement>(null); 220 + const [globalDiffStyle] = useAtom(diffStyleAtom); 221 + const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 222 + const [hasFocus, setHasFocus] = useState(false); 162 223 163 - // Derive effective selected files - reset if changeId changed 164 - const selectedFiles = 165 - selectedFilesState.forChangeId === deferredChangeId 166 - ? selectedFilesState.files 167 - : new Set<string>(); 224 + // Track selected files with the changeId they belong to 225 + // When changeId changes, we reset selection during render (no useEffect needed) 226 + const [selectedFilesState, setSelectedFilesState] = useState<{ 227 + forChangeId: string | null; 228 + files: Set<string>; 229 + }>({ forChangeId: null, files: new Set() }); 168 230 169 - // Wrapper to update selected files with changeId tracking 170 - const setSelectedFiles = useCallback( 171 - (files: Set<string> | ((prev: Set<string>) => Set<string>)) => { 172 - setSelectedFilesState((prev) => { 173 - const newFiles = typeof files === "function" ? files(prev.files) : files; 174 - return { forChangeId: deferredChangeId, files: newFiles }; 175 - }); 176 - }, 177 - [deferredChangeId], 178 - ); 231 + // Derive effective selected files - reset if changeId changed 232 + const selectedFiles = 233 + selectedFilesState.forChangeId === deferredChangeId 234 + ? selectedFilesState.files 235 + : new Set<string>(); 236 + 237 + // Wrapper to update selected files with changeId tracking 238 + const setSelectedFiles = useCallback( 239 + (files: Set<string> | ((prev: Set<string>) => Set<string>)) => { 240 + setSelectedFilesState((prev) => { 241 + const newFiles = typeof files === "function" ? files(prev.files) : files; 242 + return { forChangeId: deferredChangeId, files: newFiles }; 243 + }); 244 + }, 245 + [deferredChangeId], 246 + ); 247 + 248 + // Handler for blur events - only unfocus if focus moves outside container 249 + const handleBlur = (e: FocusEvent<HTMLDivElement>) => { 250 + if (!e.currentTarget.contains(e.relatedTarget as Node)) { 251 + setHasFocus(false); 252 + } 253 + }; 254 + 255 + // Merge refs if external ref is provided 256 + const setRefs = (el: HTMLDivElement | null) => { 257 + (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el; 258 + if (typeof ref === "function") { 259 + ref(el); 260 + } else if (ref) { 261 + ref.current = el; 262 + } 263 + }; 179 264 180 - // Handler for blur events - only unfocus if focus moves outside container 181 - const handleBlur = (e: FocusEvent<HTMLDivElement>) => { 182 - if (!e.currentTarget.contains(e.relatedTarget as Node)) { 183 - setHasFocus(false); 184 - } 185 - }; 265 + // Keyboard navigation 266 + useDiffPanelKeyboard({ scrollContainerRef, revisionsPanelRef, hasFocus }); 186 267 187 - // Merge refs if external ref is provided 188 - const setRefs = (el: HTMLDivElement | null) => { 189 - (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el; 190 - if (typeof ref === "function") { 191 - ref(el); 192 - } else if (ref) { 193 - ref.current = el; 194 - } 195 - }; 268 + // Prefetch hook for triggering data load 269 + const { prefetchDiffs, prefetchChanges } = usePrefetch(repoPath ?? ""); 196 270 197 - // Keyboard navigation 198 - useDiffPanelKeyboard({ scrollContainerRef, revisionsPanelRef, hasFocus }); 271 + // Read file changes from unified collection 272 + // isLoaded distinguishes "still loading" from "genuinely empty" 273 + const { data: changesRecords, isLoaded: _changesLoaded } = useChanges( 274 + repoPath ?? "", 275 + deferredChangeId, 276 + ); 277 + const changedFiles = useMemo( 278 + () => changesRecords.map((c) => ({ path: c.path, status: c.status })), 279 + [changesRecords], 280 + ); 199 281 200 - // Prefetch hook for triggering data load 201 - const { prefetchDiffs, prefetchChanges } = usePrefetch(repoPath ?? ""); 282 + // Read diff from unified collection 283 + const diffRecord = useDiff(repoPath ?? "", deferredChangeId); 284 + const revisionDiff = diffRecord?.content ?? ""; 202 285 203 - // Trigger prefetch when selection changes 204 - useEffect(() => { 205 - if (repoPath && deferredChangeId) { 206 - traceLog("selection-change", { changeId: deferredChangeId }); 207 - prefetchDiffs([deferredChangeId]); 208 - prefetchChanges([deferredChangeId]); 209 - } 210 - }, [repoPath, deferredChangeId, prefetchDiffs, prefetchChanges]); 286 + // Trigger prefetch when selection changes 287 + useEffect(() => { 288 + if (repoPath && deferredChangeId) { 289 + // Check if diff is already cached before requesting 290 + const diffAlreadyCached = !!diffRecord && diffRecord.changeId === deferredChangeId; 291 + traceLog("selection-change", { 292 + changeId: deferredChangeId, 293 + diffCached: diffAlreadyCached, 294 + source: "DiffPanel", 295 + }); 296 + prefetchDiffs([deferredChangeId]); 297 + prefetchChanges([deferredChangeId]); 298 + } 299 + }, [repoPath, deferredChangeId, prefetchDiffs, prefetchChanges, diffRecord]); 211 300 212 - // Read file changes from unified collection 213 - const changesRecords = useChanges(repoPath ?? "", deferredChangeId); 214 - const changedFiles = useMemo( 215 - () => changesRecords.map((c) => ({ path: c.path, status: c.status })), 216 - [changesRecords], 217 - ); 301 + // Log when data appears (only on actual changes) 302 + useEffect(() => { 303 + if (changedFiles.length > 0 && deferredChangeId) { 304 + traceLog("changes-loaded", { changeId: deferredChangeId, fileCount: changedFiles.length }); 305 + } 306 + }, [changedFiles.length, deferredChangeId]); 218 307 219 - // Read diff from unified collection 220 - const diffRecord = useDiff(repoPath ?? "", deferredChangeId); 221 - const revisionDiff = diffRecord?.content ?? ""; 308 + useEffect(() => { 309 + if (diffRecord && deferredChangeId) { 310 + traceLog("diff-loaded", { changeId: deferredChangeId, size: revisionDiff.length }); 311 + } 312 + }, [diffRecord, deferredChangeId, revisionDiff.length]); 222 313 223 - // Log when data appears (only on actual changes) 224 - useEffect(() => { 225 - if (changedFiles.length > 0 && deferredChangeId) { 226 - traceLog("changes-loaded", { changeId: deferredChangeId, fileCount: changedFiles.length }); 227 - } 228 - }, [changedFiles.length, deferredChangeId]); 314 + // Derive effective diffViewState - reset when changeId changes (no useEffect needed) 315 + const effectiveDiffViewState = getDiffViewState(diffViewState, deferredChangeId); 229 316 230 - useEffect(() => { 231 - if (diffRecord && deferredChangeId) { 232 - traceLog("diff-loaded", { changeId: deferredChangeId, size: revisionDiff.length }); 317 + // Sync atom if it needs reset (one-time sync, not a loop) 318 + if (effectiveDiffViewState !== diffViewState) { 319 + // Schedule update for next microtask to avoid setState during render 320 + queueMicrotask(() => setDiffViewState(effectiveDiffViewState)); 233 321 } 234 - }, [diffRecord, deferredChangeId, revisionDiff.length]); 235 322 236 - // Derive effective diffViewState - reset when changeId changes (no useEffect needed) 237 - const effectiveDiffViewState = getDiffViewState(diffViewState, deferredChangeId); 323 + // Derive effective selected files with auto-select first file 324 + const effectiveSelectedFiles = useMemo(() => { 325 + // If we have selected files for this changeId, use them 326 + if (selectedFiles.size > 0) return selectedFiles; 327 + // Auto-select first file when files load and none selected 328 + if (changedFiles.length > 0) { 329 + return new Set([changedFiles[0].path]); 330 + } 331 + return selectedFiles; 332 + }, [selectedFiles, changedFiles]); 238 333 239 - // Sync atom if it needs reset (one-time sync, not a loop) 240 - if (effectiveDiffViewState !== diffViewState) { 241 - // Schedule update for next microtask to avoid setState during render 242 - queueMicrotask(() => setDiffViewState(effectiveDiffViewState)); 243 - } 334 + // Get first selected file for style override display 335 + const firstSelectedFile = 336 + effectiveSelectedFiles.size > 0 ? [...effectiveSelectedFiles][0] : null; 244 337 245 - // Derive effective selected files with auto-select first file 246 - const effectiveSelectedFiles = useMemo(() => { 247 - // If we have selected files for this changeId, use them 248 - if (selectedFiles.size > 0) return selectedFiles; 249 - // Auto-select first file when files load and none selected 250 - if (changedFiles.length > 0) { 251 - return new Set([changedFiles[0].path]); 252 - } 253 - return selectedFiles; 254 - }, [selectedFiles, changedFiles]); 338 + // Get effective diff style for first selected file 339 + const effectiveDiffStyle = firstSelectedFile 340 + ? (effectiveDiffViewState.styleOverrides.get(firstSelectedFile) ?? globalDiffStyle) 341 + : globalDiffStyle; 255 342 256 - // Get first selected file for style override display 257 - const firstSelectedFile = effectiveSelectedFiles.size > 0 ? [...effectiveSelectedFiles][0] : null; 343 + const handleSetLocalStyle = useCallback( 344 + (style: DiffStyle) => { 345 + if (effectiveSelectedFiles.size === 0) return; 346 + setDiffViewState((prev) => { 347 + const next = new Map(prev.styleOverrides); 348 + // Apply style to all selected files 349 + for (const file of effectiveSelectedFiles) { 350 + next.set(file, style); 351 + } 352 + return { ...prev, styleOverrides: next }; 353 + }); 354 + }, 355 + [effectiveSelectedFiles, setDiffViewState], 356 + ); 258 357 259 - // Get effective diff style for first selected file 260 - const effectiveDiffStyle = firstSelectedFile 261 - ? (effectiveDiffViewState.styleOverrides.get(firstSelectedFile) ?? globalDiffStyle) 262 - : globalDiffStyle; 358 + // Track last valid displayed state using a ref (avoids useEffect for caching) 359 + const lastValidStateRef = useRef<{ 360 + changeId: string; 361 + patches: Array<{ path: string; patch: string; status: ChangedFileStatus }>; 362 + } | null>(null); 263 363 264 - const handleSetLocalStyle = useCallback( 265 - (style: DiffStyle) => { 266 - if (effectiveSelectedFiles.size === 0) return; 267 - setDiffViewState((prev) => { 268 - const next = new Map(prev.styleOverrides); 269 - // Apply style to all selected files 270 - for (const file of effectiveSelectedFiles) { 271 - next.set(file, style); 272 - } 273 - return { ...prev, styleOverrides: next }; 274 - }); 275 - }, 276 - [effectiveSelectedFiles, setDiffViewState], 277 - ); 364 + // Compute current patches when data is available 365 + // Note: diffRecord existing means data was fetched (even if content is empty for empty commits) 366 + const currentPatches = useMemo(() => { 367 + if (!diffRecord || !deferredChangeId) return null; 368 + // Empty diff is valid (empty commit) - return empty array, not null 369 + return splitMultiFileDiff(revisionDiff).map((patch) => ({ 370 + path: extractFilePath(patch) ?? "unknown", 371 + patch, 372 + status: (changedFiles.find((f) => f.path === extractFilePath(patch))?.status ?? 373 + "modified") as ChangedFileStatus, 374 + })); 375 + }, [diffRecord, revisionDiff, deferredChangeId, changedFiles]); 278 376 279 - // Track last valid displayed state using a ref (avoids useEffect for caching) 280 - const lastValidStateRef = useRef<{ 281 - changeId: string; 282 - patches: Array<{ path: string; patch: string; status: ChangedFileStatus }>; 283 - } | null>(null); 377 + // Update ref when we have valid data (side effect during render is fine for refs) 378 + if (currentPatches && deferredChangeId) { 379 + lastValidStateRef.current = { changeId: deferredChangeId, patches: currentPatches }; 380 + } 284 381 285 - // Compute current patches when data is available 286 - const currentPatches = useMemo(() => { 287 - if (!revisionDiff || !deferredChangeId) return null; 288 - return splitMultiFileDiff(revisionDiff).map((patch) => ({ 289 - path: extractFilePath(patch) ?? "unknown", 290 - patch, 291 - status: (changedFiles.find((f) => f.path === extractFilePath(patch))?.status ?? 292 - "modified") as ChangedFileStatus, 293 - })); 294 - }, [revisionDiff, deferredChangeId, changedFiles]); 382 + // Use current data if available, otherwise fall back to last valid state 383 + const displayedState = 384 + currentPatches && deferredChangeId 385 + ? { changeId: deferredChangeId, patches: currentPatches } 386 + : lastValidStateRef.current; 295 387 296 - // Update ref when we have valid data (side effect during render is fine for refs) 297 - if (currentPatches && deferredChangeId) { 298 - lastValidStateRef.current = { changeId: deferredChangeId, patches: currentPatches }; 299 - } 388 + // Determine if we're showing stale data 389 + const isStale = displayedState !== null && displayedState.changeId !== changeId; 300 390 301 - // Use current data if available, otherwise fall back to last valid state 302 - const displayedState = 303 - currentPatches && deferredChangeId 304 - ? { changeId: deferredChangeId, patches: currentPatches } 305 - : lastValidStateRef.current; 391 + // Parse diff into individual file patches 392 + const fileDiffs = useMemo(() => splitMultiFileDiff(revisionDiff), [revisionDiff]); 306 393 307 - // Determine if we're showing stale data 308 - const isStale = displayedState !== null && displayedState.changeId !== changeId; 394 + // Create a map from file path to patch content 395 + const patchMap = useMemo(() => { 396 + const map = new Map<string, string>(); 397 + for (const patch of fileDiffs) { 398 + const path = extractFilePath(patch); 399 + if (path) { 400 + map.set(path, patch); 401 + } 402 + } 403 + return map; 404 + }, [fileDiffs]); 309 405 310 - // Parse diff into individual file patches 311 - const fileDiffs = useMemo(() => splitMultiFileDiff(revisionDiff), [revisionDiff]); 406 + // Calculate total stats 407 + const { totalAdditions, totalDeletions } = useMemo(() => { 408 + let additions = 0; 409 + let deletions = 0; 410 + for (const patch of fileDiffs) { 411 + const stats = parsePatchStats(patch); 412 + additions += stats.additions; 413 + deletions += stats.deletions; 414 + } 415 + return { totalAdditions: additions, totalDeletions: deletions }; 416 + }, [fileDiffs]); 312 417 313 - // Create a map from file path to patch content 314 - const patchMap = useMemo(() => { 315 - const map = new Map<string, string>(); 316 - for (const patch of fileDiffs) { 317 - const path = extractFilePath(patch); 318 - if (path) { 319 - map.set(path, patch); 418 + // Get patches for selected files (in order) 419 + const selectedPatches = useMemo(() => { 420 + const patches: Array<{ path: string; patch: string; status: ChangedFileStatus }> = []; 421 + // Maintain file order from changedFiles 422 + for (const file of changedFiles) { 423 + if (effectiveSelectedFiles.has(file.path)) { 424 + const patch = patchMap.get(file.path) ?? ""; 425 + patches.push({ path: file.path, patch, status: file.status as ChangedFileStatus }); 426 + } 320 427 } 321 - } 322 - return map; 323 - }, [fileDiffs]); 428 + return patches; 429 + }, [changedFiles, effectiveSelectedFiles, patchMap]); 324 430 325 - // Calculate total stats 326 - const { totalAdditions, totalDeletions } = useMemo(() => { 327 - let additions = 0; 328 - let deletions = 0; 329 - for (const patch of fileDiffs) { 330 - const stats = parsePatchStats(patch); 331 - additions += stats.additions; 332 - deletions += stats.deletions; 431 + if (!repoPath || !changeId) { 432 + return ( 433 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 434 + <div 435 + ref={setRefs} 436 + tabIndex={-1} 437 + onFocus={() => setHasFocus(true)} 438 + onBlur={handleBlur} 439 + className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 440 + > 441 + Select a revision to view diffs 442 + </div> 443 + ); 333 444 } 334 - return { totalAdditions: additions, totalDeletions: deletions }; 335 - }, [fileDiffs]); 336 445 337 - // Get patches for selected files (in order) 338 - const selectedPatches = useMemo(() => { 339 - const patches: Array<{ path: string; patch: string; status: ChangedFileStatus }> = []; 340 - // Maintain file order from changedFiles 341 - for (const file of changedFiles) { 342 - if (effectiveSelectedFiles.has(file.path)) { 343 - const patch = patchMap.get(file.path) ?? ""; 344 - patches.push({ path: file.path, patch, status: file.status as ChangedFileStatus }); 345 - } 446 + // Only show "No changes" if we have no displayed state to show 447 + if (changedFiles.length === 0 && !displayedState) { 448 + return ( 449 + // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 450 + <div 451 + ref={setRefs} 452 + tabIndex={-1} 453 + onFocus={() => setHasFocus(true)} 454 + onBlur={handleBlur} 455 + className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 456 + > 457 + No changes in this revision 458 + </div> 459 + ); 346 460 } 347 - return patches; 348 - }, [changedFiles, effectiveSelectedFiles, patchMap]); 349 461 350 - if (!repoPath || !changeId) { 351 - return ( 352 - // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 353 - <div 354 - ref={setRefs} 355 - tabIndex={-1} 356 - onFocus={() => setHasFocus(true)} 357 - onBlur={handleBlur} 358 - className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 359 - > 360 - Select a revision to view diffs 361 - </div> 362 - ); 363 - } 462 + // Determine which patches to show - use previous while loading 463 + const patchesToRender = isStale && displayedState ? displayedState.patches : selectedPatches; 364 464 365 - // Only show "No changes" if we have no displayed state to show 366 - if (changedFiles.length === 0 && !displayedState) { 367 465 return ( 368 466 // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 369 467 <div ··· 371 469 tabIndex={-1} 372 470 onFocus={() => setHasFocus(true)} 373 471 onBlur={handleBlur} 374 - className="flex items-center justify-center h-full text-muted-foreground text-sm outline-none" 472 + className="h-full w-full flex flex-col bg-background outline-none overflow-hidden" 375 473 > 376 - No changes in this revision 377 - </div> 378 - ); 379 - } 474 + {/* Revision header */} 475 + {revision && ( 476 + <div className="px-4 pt-2 pb-2 shrink-0"> 477 + <RevisionHeader revision={revision} /> 478 + </div> 479 + )} 380 480 381 - // Determine which patches to show - use previous while loading 382 - const patchesToRender = isStale && displayedState ? displayedState.patches : selectedPatches; 383 - 384 - return ( 385 - // biome-ignore lint/a11y/noStaticElementInteractions: Focus tracking for keyboard navigation 386 - <div 387 - ref={setRefs} 388 - tabIndex={-1} 389 - onFocus={() => setHasFocus(true)} 390 - onBlur={handleBlur} 391 - className="h-full w-full flex flex-col bg-background outline-none overflow-hidden" 392 - > 393 - {/* Revision header */} 394 - {revision && ( 395 - <div className="px-4 pt-2 pb-2 shrink-0"> 396 - <RevisionHeader revision={revision} /> 481 + {/* Toolbar */} 482 + <div className="flex items-center justify-end px-3 py-2 border-b border-border bg-background shrink-0 min-w-0"> 483 + <div className="flex items-center gap-0.5 shrink-0"> 484 + <Button 485 + variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 486 + size="icon-xs" 487 + onClick={() => handleSetLocalStyle("unified")} 488 + title="Unified diff view" 489 + className="h-6 w-6" 490 + disabled={effectiveSelectedFiles.size === 0} 491 + > 492 + <RowsIcon className="size-3" /> 493 + </Button> 494 + <Button 495 + variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 496 + size="icon-xs" 497 + onClick={() => handleSetLocalStyle("split")} 498 + title="Split diff view" 499 + className="h-6 w-6" 500 + disabled={effectiveSelectedFiles.size === 0} 501 + > 502 + <Columns2Icon className="size-3" /> 503 + </Button> 504 + </div> 397 505 </div> 398 - )} 399 506 400 - {/* Toolbar */} 401 - <div className="flex items-center justify-end px-3 py-2 border-b border-border bg-background shrink-0 min-w-0"> 402 - <div className="flex items-center gap-0.5 shrink-0"> 403 - <Button 404 - variant={effectiveDiffStyle === "unified" ? "secondary" : "ghost"} 405 - size="icon-xs" 406 - onClick={() => handleSetLocalStyle("unified")} 407 - title="Unified diff view" 408 - className="h-6 w-6" 409 - disabled={effectiveSelectedFiles.size === 0} 410 - > 411 - <RowsIcon className="size-3" /> 412 - </Button> 413 - <Button 414 - variant={effectiveDiffStyle === "split" ? "secondary" : "ghost"} 415 - size="icon-xs" 416 - onClick={() => handleSetLocalStyle("split")} 417 - title="Split diff view" 418 - className="h-6 w-6" 419 - disabled={effectiveSelectedFiles.size === 0} 507 + {/* Two-column layout wrapper */} 508 + <div ref={scrollContainerRef} className="relative flex-1 min-h-0 min-w-0 overflow-auto"> 509 + <ResizablePanelGroup 510 + id="diff-panel-layout" 511 + orientation="horizontal" 512 + className="absolute inset-0" 420 513 > 421 - <Columns2Icon className="size-3" /> 422 - </Button> 423 - </div> 424 - </div> 425 - 426 - {/* Two-column layout wrapper */} 427 - <div ref={scrollContainerRef} className="relative flex-1 min-h-0 min-w-0 overflow-auto"> 428 - <ResizablePanelGroup 429 - id="diff-panel-layout" 430 - orientation="horizontal" 431 - className="absolute inset-0" 432 - > 433 - {/* File list panel */} 434 - <ResizablePanel id="diff-file-list" defaultSize="30%" minSize="15%" maxSize="50%"> 435 - <div className="h-full w-full min-w-0"> 436 - <FileList 437 - files={changedFiles} 438 - selectedFiles={effectiveSelectedFiles} 439 - onSelectFiles={setSelectedFiles} 440 - totalAdditions={totalAdditions} 441 - totalDeletions={totalDeletions} 442 - hasFocus={hasFocus} 443 - /> 444 - </div> 445 - </ResizablePanel> 514 + {/* File list panel */} 515 + <ResizablePanel id="diff-file-list" defaultSize="30%" minSize="15%" maxSize="50%"> 516 + <div className="h-full w-full min-w-0"> 517 + <FileList 518 + files={changedFiles} 519 + selectedFiles={effectiveSelectedFiles} 520 + onSelectFiles={setSelectedFiles} 521 + totalAdditions={totalAdditions} 522 + totalDeletions={totalDeletions} 523 + hasFocus={hasFocus} 524 + /> 525 + </div> 526 + </ResizablePanel> 446 527 447 - <ResizableHandle withHandle /> 528 + <ResizableHandle withHandle /> 448 529 449 - {/* Diff content panel */} 450 - <ResizablePanel id="diff-content" defaultSize="70%"> 451 - <div 452 - className={cn( 453 - "h-full w-full min-w-0 relative", 454 - isStale && "opacity-60 pointer-events-none", 455 - )} 456 - > 457 - {isStale && ( 458 - <div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10"> 459 - <Loader2 className="h-6 w-6 animate-spin" /> 460 - </div> 461 - )} 462 - <MultiFileDiff 463 - patches={patchesToRender} 464 - diffViewState={diffViewState} 465 - globalDiffStyle={globalDiffStyle} 466 - repoPath={repoPath} 467 - changeId={displayedState?.changeId ?? changeId} 468 - /> 469 - </div> 470 - </ResizablePanel> 471 - </ResizablePanelGroup> 530 + {/* Diff content panel */} 531 + <ResizablePanel id="diff-content" defaultSize="70%"> 532 + <div 533 + className={cn( 534 + "h-full w-full min-w-0 relative", 535 + isStale && "opacity-60 pointer-events-none", 536 + )} 537 + > 538 + {isStale && ( 539 + <div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10"> 540 + <Loader2 className="h-6 w-6 animate-spin" /> 541 + </div> 542 + )} 543 + <MultiFileDiff 544 + patches={patchesToRender} 545 + diffViewState={diffViewState} 546 + globalDiffStyle={globalDiffStyle} 547 + repoPath={repoPath} 548 + changeId={displayedState?.changeId ?? changeId} 549 + /> 550 + </div> 551 + </ResizablePanel> 552 + </ResizablePanelGroup> 553 + </div> 472 554 </div> 473 - </div> 474 - ); 475 - }); 555 + ); 556 + }), 557 + diffPanelPropsAreEqual, 558 + );
+96 -83
apps/desktop/src/components/revision-graph/index.tsx
··· 4 4 import type { RefObject } from "react"; 5 5 import { 6 6 forwardRef, 7 - useDeferredValue, 7 + useCallback, 8 + // DISABLED: useDeferredValue removed (lineage/dimming disabled) 8 9 useEffect, 9 10 useImperativeHandle, 10 11 useMemo, ··· 17 18 expandedStacksAtom, 18 19 hoveredStackIdAtom, 19 20 inlineJumpQueryAtom, 21 + revisionGraphScrollTopAtom, 20 22 } from "@/atoms"; 21 23 import { 22 24 reorderForGraph, ··· 26 28 } from "@/components/revision-graph-utils"; 27 29 import { getRevisionKey } from "@/db"; 28 30 import { useFocusWithin } from "@/hooks/useFocusWithin"; 29 - import { useLineage, usePrefetch } from "@/hooks/useRevisionData"; 31 + // DISABLED: useLineage removed (lineage/dimming disabled) 32 + import { usePrefetch } from "@/hooks/useRevisionData"; 30 33 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; 31 34 import { useRevisionGraphNavigation } from "@/hooks/useRevisionGraphNavigation"; 32 35 import type { Revision } from "@/tauri-commands"; ··· 332 335 }, 333 336 ref, 334 337 ) { 338 + const renderCount = useRef(0); 339 + console.log(`RevisionGraph Render #${++renderCount.current}`); 340 + 335 341 const parentRef = useRef<HTMLDivElement>(null); 336 342 const containerRef = useRef<HTMLDivElement>(null); 337 343 const prefetchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); ··· 363 369 const stacks = useMemo(() => detectStacks(stableRevisions), [stableRevisions]); 364 370 365 371 // Setup prefetch hooks for visible revision data 366 - const { prefetchDiffs, prefetchChanges, prefetchLineage } = usePrefetch(repoPath ?? ""); 372 + // DISABLED: prefetchLineage removed (lineage/dimming disabled) 373 + const { prefetchDiffs, prefetchChanges, flushDiffs, flushChanges } = usePrefetch( 374 + repoPath ?? "", 375 + ); 367 376 368 377 // Track which stacks are expanded (empty = all collapsed by default) 369 378 const [expandedStacks, setExpandedStacks] = useAtom(expandedStacksAtom); ··· 491 500 } 492 501 493 502 // Toggle stack expansion and focus the top of newly revealed revisions when expanding 494 - function handleToggleStack(stackId: string) { 503 + async function handleToggleStack(stackId: string) { 495 504 const stack = stackById.get(stackId); 496 505 const isCurrentlyExpanded = expandedStacks.has(stackId); 506 + const traceId = traceStart(isCurrentlyExpanded ? "collapseStack" : "expandStack", { 507 + stackId, 508 + revisionCount: stack?.intermediateChangeIds.length ?? 0, 509 + }); 510 + 511 + // If expanding (not currently expanded), prefetch the target diff FIRST 512 + // This ensures the diff is cached before we navigate, avoiding UI freeze 513 + if (!isCurrentlyExpanded && stack && stack.intermediateChangeIds.length > 0) { 514 + const targetChangeId = stack.intermediateChangeIds[0]; 515 + prefetchDiffs([targetChangeId]); 516 + prefetchChanges([targetChangeId]); 517 + await Promise.all([flushDiffs(), flushChanges()]); 518 + } 519 + 497 520 toggleStackExpansion(stackId); 498 521 499 522 // If expanding (not currently expanded), focus the first intermediate revision 500 523 // (the top of the newly revealed revisions, not the already-visible top of the stack) 501 524 if (!isCurrentlyExpanded && stack && stack.intermediateChangeIds.length > 0) { 525 + const targetChangeId = stack.intermediateChangeIds[0]; 502 526 navigate({ 503 527 search: { 504 528 ...search, 505 529 stack: undefined, 506 - rev: stack.intermediateChangeIds[0], 530 + rev: targetChangeId, 507 531 selected: undefined, 508 532 selectionAnchor: undefined, 509 533 }, 510 534 replace: true, 511 535 }); 512 536 } 537 + traceEnd(traceId); 513 538 } 514 539 515 540 // Maps for lookups - by revision key for UI, by commit_id for graph edges 516 541 const revisionMapByKey = new Map(stableRevisions.map((r) => [getRevisionKey(r), r])); 517 542 const revisionMapByCommitId = new Map(stableRevisions.map((r) => [r.commit_id, r])); 518 543 519 - // Defer the selected ID so dimming computation doesn't block selection highlight 520 - const deferredSelectedChangeId = useDeferredValue(selectedRevision?.change_id ?? null); 521 - 522 - // Resolve stack for lineage queries 523 - const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 524 - 525 - // Get lineage from backend - call hooks unconditionally with null when not needed 526 - const topLineageChangeId = focusedStack ? focusedStack.topChangeId : null; 527 - const bottomLineageChangeId = focusedStack ? focusedStack.bottomChangeId : null; 528 - const primaryLineageChangeId = focusedStack ? null : deferredSelectedChangeId; 529 - 530 - const { lineage: topLineage, isLoaded: topLoaded } = useLineage( 531 - repoPath ?? "", 532 - topLineageChangeId, 533 - ); 534 - const { lineage: bottomLineage, isLoaded: bottomLoaded } = useLineage( 535 - repoPath ?? "", 536 - bottomLineageChangeId, 537 - ); 538 - const { lineage: primaryLineage, isLoaded: primaryLoaded } = useLineage( 539 - repoPath ?? "", 540 - primaryLineageChangeId, 541 - ); 542 - 543 - // Don't dim while lineage is loading - prevents flicker 544 - const lineageIsLoading = focusedStack 545 - ? !topLoaded || !bottomLoaded 546 - : deferredSelectedChangeId && !primaryLoaded; 547 - 548 - // Compute related revisions for dimming logic 549 - // Intersect with visible revisions for display 550 - // When loading, return null to disable dimming entirely 551 - const relatedRevisions = useMemo(() => { 552 - // Don't dim while loading 553 - if (lineageIsLoading) return null; 554 - 555 - const visibleIds = new Set(stableRevisions.map((r) => r.change_id)); 556 - 557 - if (focusedStack) { 558 - // When stack is focused, highlight the stack endpoints and their ancestors/descendants 559 - const combined = new Set([...topLineage, ...bottomLineage]); 560 - const result = new Set([...combined].filter((id) => visibleIds.has(id))); 561 - // Always include the focused stack endpoints 562 - if (focusedStack.topChangeId) result.add(focusedStack.topChangeId); 563 - if (focusedStack.bottomChangeId) result.add(focusedStack.bottomChangeId); 564 - return result; 565 - } 566 - 567 - if (!deferredSelectedChangeId) return null; 568 - const result = new Set([...primaryLineage].filter((id) => visibleIds.has(id))); 569 - // Always include the selected revision itself 570 - result.add(deferredSelectedChangeId); 571 - return result; 572 - }, [ 573 - stableRevisions, 574 - focusedStack, 575 - topLineage, 576 - bottomLineage, 577 - primaryLineage, 578 - deferredSelectedChangeId, 579 - lineageIsLoading, 580 - ]); 544 + // DISABLED: Lineage/dimming calculations commented out 545 + // const deferredSelectedChangeId = useDeferredValue(selectedRevision?.change_id ?? null); 546 + // const focusedStack = focusedStackId ? stackById.get(focusedStackId) : null; 547 + // const topLineageChangeId = focusedStack ? focusedStack.topChangeId : null; 548 + // const bottomLineageChangeId = focusedStack ? focusedStack.bottomChangeId : null; 549 + // const primaryLineageChangeId = focusedStack ? null : deferredSelectedChangeId; 550 + // const { lineage: topLineage, isLoaded: topLoaded } = useLineage(repoPath ?? "", topLineageChangeId); 551 + // const { lineage: bottomLineage, isLoaded: bottomLoaded } = useLineage(repoPath ?? "", bottomLineageChangeId); 552 + // const { lineage: primaryLineage, isLoaded: primaryLoaded } = useLineage(repoPath ?? "", primaryLineageChangeId); 553 + // const lineageIsLoading = focusedStack ? !topLoaded || !bottomLoaded : deferredSelectedChangeId && !primaryLoaded; 554 + // DISABLED: Lineage/dimming completely removed 555 + // const relatedRevisions = useMemo(() => { ... }, [...]); 581 556 582 557 // Build revision key -> displayRow index map for scrolling and edge positioning 583 558 // IMPORTANT: Use displayRows indices (not rows) to match virtualizer positioning ··· 738 713 739 714 const COLLAPSED_STACK_HEIGHT = 32; 740 715 716 + const getItemKey = useCallback( 717 + (index: number) => { 718 + const displayRow = displayRows[index]; 719 + if (displayRow.type === "collapsed-stack") { 720 + return `collapsed-${displayRow.stack.id}`; 721 + } 722 + return `revision-${displayRow.row.revision.change_id}`; 723 + }, 724 + [displayRows], 725 + ); 726 + 741 727 const rowVirtualizer = useVirtualizer({ 742 728 count: displayRows.length, 743 729 getScrollElement: () => parentRef.current, ··· 748 734 } 749 735 return ROW_HEIGHT; 750 736 }, 737 + getItemKey, 751 738 overscan: 5, 752 739 debug: debugEnabled, 753 740 }); 754 741 742 + // Persist scroll position across view mode changes 743 + const [savedScrollTop, setSavedScrollTop] = useAtom(revisionGraphScrollTopAtom); 744 + 745 + // Restore scroll position on mount 746 + useEffect(() => { 747 + const scrollEl = parentRef.current; 748 + if (!scrollEl || savedScrollTop === 0) return; 749 + 750 + // Use requestAnimationFrame to ensure the DOM is ready 751 + requestAnimationFrame(() => { 752 + scrollEl.scrollTop = savedScrollTop; 753 + }); 754 + // Only run on mount - savedScrollTop is intentionally not in deps 755 + // eslint-disable-next-line react-hooks/exhaustive-deps 756 + }, []); 757 + 758 + // Save scroll position on scroll (DOM event subscription) 759 + // ast-grep-ignore: no-useeffect-state-sync 760 + useEffect(() => { 761 + const scrollEl = parentRef.current; 762 + if (!scrollEl) return; 763 + 764 + const handleScroll = () => { 765 + setSavedScrollTop(scrollEl.scrollTop); 766 + }; 767 + 768 + scrollEl.addEventListener("scroll", handleScroll, { passive: true }); 769 + return () => scrollEl.removeEventListener("scroll", handleScroll); 770 + }, [setSavedScrollTop]); 771 + 755 772 // scrollToIndexIfNeededRef is updated below after virtualItems is computed 756 773 757 774 // Expose scrollToChangeId method via ref ··· 917 934 prefetchDiffs(uniqueIds); 918 935 prefetchChanges(uniqueIds); 919 936 920 - // Prefetch lineage for selected revision (for dimming related revisions) 921 - if (selectedRevision) { 922 - prefetchLineage([selectedRevision.change_id]); 923 - } 937 + // DISABLED: Lineage prefetch removed (lineage/dimming disabled) 938 + // if (selectedRevision) { 939 + // prefetchLineage([selectedRevision.change_id]); 940 + // } 924 941 }, 200); 925 942 926 943 // Cleanup on unmount or when deps change ··· 937 954 selectedRevision, 938 955 prefetchDiffs, 939 956 prefetchChanges, 940 - prefetchLineage, 941 957 ]); 942 958 943 959 // Compute jump hints for visible rows based on change ID prefix matching ··· 1142 1158 const nodeAreaWidth = LANE_PADDING + (lane + 1) * LANE_WIDTH; 1143 1159 const count = stack.intermediateChangeIds.length; 1144 1160 1145 - // Check if this stack is related to the selected revision (for dimming) 1146 - // Don't dim if relatedRevisions is null (still loading) 1147 - const isStackRelated = 1148 - relatedRevisions === null || 1149 - stack.changeIds.some((id) => relatedRevisions.has(id)); 1150 - const isStackDimmed = selectedRevision !== null && !isStackRelated; 1161 + // DISABLED: Dimming disabled (lineage calculations removed) 1162 + // const isStackRelated = relatedRevisions === null || stack.changeIds.some((id) => relatedRevisions.has(id)); 1163 + // const isStackDimmed = selectedRevision !== null && !isStackRelated; 1164 + const isStackDimmed = false; 1151 1165 const isStackFocused = focusedStackId === stack.id; 1152 1166 1153 1167 return ( ··· 1222 1236 const { row } = displayRow; 1223 1237 const lane = changeIdToLane.get(row.revision.change_id) ?? 0; 1224 1238 const isFlashing = flash?.changeId === row.revision.change_id; 1225 - // Don't dim if relatedRevisions is null (still loading) 1226 - const isDimmed = 1227 - relatedRevisions !== null && 1228 - (selectedRevision !== null || focusedStackId !== null) && 1229 - !relatedRevisions.has(row.revision.change_id); 1239 + // DISABLED: Dimming disabled (lineage calculations removed) 1240 + // const isDimmed = relatedRevisions !== null && (selectedRevision !== null || focusedStackId !== null) && !relatedRevisions.has(row.revision.change_id); 1241 + const isDimmed = false; 1230 1242 // Only show focus if no stack is focused 1231 1243 const isFocused = 1232 1244 !focusedStackId && ··· 1241 1253 className="absolute left-0 w-full" 1242 1254 style={{ 1243 1255 transform: `translateY(${virtualRow.start}px)`, 1256 + height: ROW_HEIGHT, 1244 1257 }} 1245 1258 > 1246 1259 <RevisionRow
+23 -18
apps/desktop/src/db.ts
··· 138 138 await queryClient.invalidateQueries({ queryKey: ["revision-changes", repoPath] }); 139 139 await queryClient.invalidateQueries({ queryKey: ["revision-diff", repoPath] }); 140 140 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 141 - await queryClient.invalidateQueries({ queryKey: ["lineage"] }); 141 + // DISABLED: Lineage calculations commented out 142 + // await queryClient.invalidateQueries({ queryKey: ["lineage"] }); 142 143 } 143 144 }); 144 145 ··· 605 606 repoPath: string; 606 607 changeId: string; 607 608 content: string; 609 + prerenderedUnified?: string; 610 + prerenderedSplit?: string; 608 611 } 609 612 610 613 function getDiffRecordKey(d: DiffRecord): string { ··· 660 663 661 664 // ============================================================================ 662 665 // Unified Lineage Collection (single collection for all revision lineage data) 666 + // DISABLED: Lineage calculations commented out 663 667 // ============================================================================ 664 668 665 669 /** ··· 672 676 relatedIds: string[]; 673 677 } 674 678 675 - function getLineageRecordKey(l: LineageRecord): string { 676 - return `${l.repoPath}:${l.changeId}`; 677 - } 678 - 679 - const lineageQueryKey = ["lineage"] as const; 680 - 681 - export const lineageCollection = createCollection({ 682 - ...queryCollectionOptions({ 683 - queryClient, 684 - queryKey: lineageQueryKey, 685 - queryFn: async () => [] as LineageRecord[], 686 - getKey: getLineageRecordKey, 687 - }), 688 - startSync: true, 689 - }); 690 - 691 - export type LineageCollection = typeof lineageCollection; 679 + // DISABLED: Lineage calculations commented out 680 + // function getLineageRecordKey(l: LineageRecord): string { 681 + // return `${l.repoPath}:${l.changeId}`; 682 + // } 683 + // 684 + // const lineageQueryKey = ["lineage"] as const; 685 + // 686 + // export const lineageCollection = createCollection({ 687 + // ...queryCollectionOptions({ 688 + // queryClient, 689 + // queryKey: lineageQueryKey, 690 + // queryFn: async () => [] as LineageRecord[], 691 + // getKey: getLineageRecordKey, 692 + // }), 693 + // startSync: true, 694 + // }); 695 + // 696 + // export type LineageCollection = typeof lineageCollection;
+190 -100
apps/desktop/src/hooks/useRevisionData.ts
··· 5 5 * enabling efficient batched fetching with instant reads from local state. 6 6 */ 7 7 8 - import { useLiveQuery } from "@tanstack/react-db"; 8 + import { and, eq, useLiveQuery, useLiveSuspenseQuery } from "@tanstack/react-db"; 9 9 import { useMemo, useRef } from "react"; 10 10 import { 11 11 type ChangeRecord, 12 12 changesCollection, 13 13 type DiffRecord, 14 14 diffsCollection, 15 - type LineageRecord, 16 - lineageCollection, 15 + // DISABLED: Lineage calculations commented out 16 + // type LineageRecord, 17 + // lineageCollection, 17 18 } from "@/db"; 18 19 import { type BatchLoader, createBatchLoader } from "@/lib/batch-loader"; 19 20 import { traceLog } from "@/lib/trace"; 20 21 import { 21 22 getChangesBatchEffect, 22 23 getDiffsBatchEffect, 23 - getLineageBatchEffect, 24 - type LineageResult, 24 + // DISABLED: Lineage calculations commented out 25 + // getLineageBatchEffect, 26 + // type LineageResult, 25 27 type RevisionChanges, 26 28 type RevisionDiff, 27 29 } from "@/tauri-commands"; ··· 120 122 }); 121 123 } 122 124 123 - /** 124 - * Creates a BatchLoader for revision lineage. 125 - * The loader batches IPC calls and syncs results to the unified lineageCollection. 126 - */ 127 - function createLineageLoader(repoPath: string): BatchLoader { 128 - return createBatchLoader<LineageResult>({ 129 - debounceMs: 50, 130 - maxBatchSize: 20, 131 - fetchBatch: (ids) => getLineageBatchEffect(repoPath, ids), 132 - syncToCollection: (results) => { 133 - // Wait for collection to be ready before writing 134 - if (lineageCollection.status !== "ready") return; 135 - const records: LineageRecord[] = results.map((r) => ({ 136 - repoPath, 137 - changeId: r.change_id, 138 - relatedIds: r.related_ids, 139 - })); 140 - lineageCollection.utils.writeUpsert(records); 141 - 142 - // LRU eviction: keep only last 50 lineage records per repo 143 - const MAX_LINEAGE = 50; 144 - const allRecords = Array.from(lineageCollection.state.values()).filter( 145 - (r) => r.repoPath === repoPath, 146 - ); 147 - if (allRecords.length > MAX_LINEAGE) { 148 - // Remove oldest entries (first ones in the Map iteration order) 149 - const toRemove = allRecords.slice(0, allRecords.length - MAX_LINEAGE); 150 - for (const record of toRemove) { 151 - const key = `${record.repoPath}:${record.changeId}`; 152 - lineageCollection.state.delete(key); 153 - } 154 - } 155 - }, 156 - isLoaded: (id) => { 157 - if (lineageCollection.status !== "ready") return false; 158 - const key = `${repoPath}:${id}`; 159 - return lineageCollection.state.has(key); 160 - }, 161 - }); 162 - } 125 + // DISABLED: Lineage calculations commented out 126 + // /** 127 + // * Creates a BatchLoader for revision lineage. 128 + // * The loader batches IPC calls and syncs results to the unified lineageCollection. 129 + // */ 130 + // function createLineageLoader(repoPath: string): BatchLoader { 131 + // return createBatchLoader<LineageResult>({ 132 + // debounceMs: 50, 133 + // maxBatchSize: 20, 134 + // fetchBatch: (ids) => getLineageBatchEffect(repoPath, ids), 135 + // syncToCollection: (results) => { 136 + // // Wait for collection to be ready before writing 137 + // if (lineageCollection.status !== "ready") return; 138 + // const records: LineageRecord[] = results.map((r) => ({ 139 + // repoPath, 140 + // changeId: r.change_id, 141 + // relatedIds: r.related_ids, 142 + // })); 143 + // lineageCollection.utils.writeUpsert(records); 144 + // 145 + // // LRU eviction: keep only last 50 lineage records per repo 146 + // const MAX_LINEAGE = 50; 147 + // const allRecords = Array.from(lineageCollection.state.values()).filter( 148 + // (r) => r.repoPath === repoPath, 149 + // ); 150 + // if (allRecords.length > MAX_LINEAGE) { 151 + // // Remove oldest entries (first ones in the Map iteration order) 152 + // const toRemove = allRecords.slice(0, allRecords.length - MAX_LINEAGE); 153 + // for (const record of toRemove) { 154 + // const key = `${record.repoPath}:${record.changeId}`; 155 + // lineageCollection.state.delete(key); 156 + // } 157 + // } 158 + // }, 159 + // isLoaded: (id) => { 160 + // if (lineageCollection.status !== "ready") return false; 161 + // const key = `${repoPath}:${id}`; 162 + // return lineageCollection.state.has(key); 163 + // }, 164 + // }); 165 + // } 163 166 164 167 // ============================================================================ 165 168 // Loader Instance Cache ··· 168 171 // Cache loaders per repoPath to avoid creating multiple instances 169 172 const diffLoaders = new Map<string, BatchLoader>(); 170 173 const changesLoaders = new Map<string, BatchLoader>(); 171 - const lineageLoaders = new Map<string, BatchLoader>(); 174 + // DISABLED: Lineage calculations commented out 175 + // const lineageLoaders = new Map<string, BatchLoader>(); 172 176 173 177 /** 174 178 * Clean up loaders for repos we're no longer viewing. ··· 185 189 changesLoaders.delete(path); 186 190 } 187 191 } 188 - for (const [path] of lineageLoaders) { 189 - if (path !== currentRepoPath) { 190 - lineageLoaders.delete(path); 191 - } 192 - } 192 + // DISABLED: Lineage calculations commented out 193 + // for (const [path] of lineageLoaders) { 194 + // if (path !== currentRepoPath) { 195 + // lineageLoaders.delete(path); 196 + // } 197 + // } 193 198 } 194 199 195 200 function getDiffLoader(repoPath: string): BatchLoader { ··· 210 215 return loader; 211 216 } 212 217 213 - function getLineageLoader(repoPath: string): BatchLoader { 214 - let loader = lineageLoaders.get(repoPath); 215 - if (!loader) { 216 - loader = createLineageLoader(repoPath); 217 - lineageLoaders.set(repoPath, loader); 218 - } 219 - return loader; 220 - } 218 + // DISABLED: Lineage calculations commented out 219 + // function getLineageLoader(repoPath: string): BatchLoader { 220 + // let loader = lineageLoaders.get(repoPath); 221 + // if (!loader) { 222 + // loader = createLineageLoader(repoPath); 223 + // lineageLoaders.set(repoPath, loader); 224 + // } 225 + // return loader; 226 + // } 221 227 222 228 // ============================================================================ 223 229 // React Hooks ··· 228 234 * Call prefetchDiffs to trigger loading. 229 235 */ 230 236 export function useDiff(repoPath: string, changeId: string | null): DiffRecord | undefined { 231 - const { data: allDiffs = [] } = useLiveQuery(diffsCollection); 237 + // Use selective query - only re-renders when THIS specific diff changes 238 + const { data } = useLiveQuery( 239 + (q) => 240 + changeId 241 + ? q 242 + .from({ diffs: diffsCollection }) 243 + .where(({ diffs }) => and(eq(diffs.repoPath, repoPath), eq(diffs.changeId, changeId))) 244 + .findOne() 245 + : null, 246 + [repoPath, changeId], 247 + ); 232 248 233 - return useMemo(() => { 234 - if (!changeId) return undefined; 235 - const key = `${repoPath}:${changeId}`; 236 - return allDiffs.find((d) => `${d.repoPath}:${d.changeId}` === key); 237 - }, [allDiffs, repoPath, changeId]); 249 + return data; 238 250 } 239 251 240 252 /** 241 253 * Read changes (file list) for a revision from local DB. 242 - * Returns empty array if not yet loaded. Call prefetchChanges to trigger loading. 254 + * Returns { data, isLoaded } to distinguish between "loading" and "genuinely empty". 255 + * Call prefetchChanges to trigger loading. 243 256 */ 244 - export function useChanges(repoPath: string, changeId: string | null): ChangeRecord[] { 245 - const { data: allChanges = [] } = useLiveQuery(changesCollection); 257 + export function useChanges( 258 + repoPath: string, 259 + changeId: string | null, 260 + ): { data: ChangeRecord[]; isLoaded: boolean } { 261 + // Use selective query - only re-renders when changes for THIS revision update 262 + const { data = [], isReady } = useLiveQuery( 263 + (q) => 264 + changeId 265 + ? q 266 + .from({ changes: changesCollection }) 267 + .where(({ changes }) => 268 + and(eq(changes.repoPath, repoPath), eq(changes.changeId, changeId)), 269 + ) 270 + : null, 271 + [repoPath, changeId], 272 + ); 273 + 274 + // isLoaded is true when: 275 + // 1. No changeId requested (nothing to load) 276 + // 2. Query is ready AND we have data (data was fetched) 277 + // 3. Query is ready AND data is empty but was explicitly fetched 278 + // We track "explicitly fetched" by checking if the changeId exists in the collection's loaded set 279 + // For now, we use isReady as a proxy - if the query ran and returned, the data is loaded 280 + const isLoaded = !changeId || isReady; 281 + 282 + return { data, isLoaded }; 283 + } 284 + 285 + // ============================================================================ 286 + // Suspense-enabled Hooks 287 + // ============================================================================ 288 + 289 + /** 290 + * Read a single diff from local DB with Suspense support. 291 + * Throws a promise when data isn't ready, caught by Suspense boundary. 292 + * Data is guaranteed to be defined when returned. 293 + * 294 + * @throws Promise when data is loading (caught by Suspense boundary) 295 + */ 296 + export function useDiffSuspense(repoPath: string, changeId: string): DiffRecord | undefined { 297 + const { data } = useLiveSuspenseQuery( 298 + (q) => 299 + q 300 + .from({ diffs: diffsCollection }) 301 + .where(({ diffs }) => and(eq(diffs.repoPath, repoPath), eq(diffs.changeId, changeId))) 302 + .findOne(), 303 + [repoPath, changeId], 304 + ); 305 + 306 + traceLog("useDiffSuspense", { changeId, hasData: !!data }); 307 + 308 + return data; 309 + } 310 + 311 + /** 312 + * Read changes (file list) for a revision from local DB with Suspense support. 313 + * Throws a promise when data isn't ready, caught by Suspense boundary. 314 + * Data is guaranteed to be defined when returned. 315 + * 316 + * @throws Promise when data is loading (caught by Suspense boundary) 317 + */ 318 + export function useChangesSuspense(repoPath: string, changeId: string): ChangeRecord[] { 319 + const { data } = useLiveSuspenseQuery( 320 + (q) => 321 + q 322 + .from({ changes: changesCollection }) 323 + .where(({ changes }) => 324 + and(eq(changes.repoPath, repoPath), eq(changes.changeId, changeId)), 325 + ), 326 + [repoPath, changeId], 327 + ); 328 + 329 + traceLog("useChangesSuspense", { changeId, fileCount: data.length }); 246 330 247 - return useMemo(() => { 248 - if (!changeId) return []; 249 - return allChanges.filter((c) => c.repoPath === repoPath && c.changeId === changeId); 250 - }, [allChanges, repoPath, changeId]); 331 + return data; 251 332 } 252 333 253 334 /** 254 335 * Read lineage (related revision IDs) for a revision from local DB. 255 336 * Returns { lineage, isLoaded } to allow callers to handle loading state. 337 + * 338 + * DISABLED: Lineage calculations commented out - always returns empty/loaded. 256 339 */ 257 340 export function useLineage( 258 - repoPath: string, 259 - changeId: string | null, 341 + _repoPath: string, 342 + _changeId: string | null, 260 343 ): { lineage: Set<string>; isLoaded: boolean } { 261 - const { data: allLineage = [] } = useLiveQuery(lineageCollection); 262 - 263 - return useMemo(() => { 264 - if (!changeId) { 265 - return { lineage: new Set<string>(), isLoaded: true }; 266 - } 267 - 268 - const record = allLineage.find((l) => l.repoPath === repoPath && l.changeId === changeId); 269 - 270 - if (record) { 271 - return { lineage: new Set(record.relatedIds), isLoaded: true }; 272 - } 344 + // DISABLED: Lineage calculations commented out 345 + // const { data: allLineage = [] } = useLiveQuery(lineageCollection); 346 + // 347 + // return useMemo(() => { 348 + // if (!changeId) { 349 + // return { lineage: new Set<string>(), isLoaded: true }; 350 + // } 351 + // 352 + // const record = allLineage.find((l) => l.repoPath === repoPath && l.changeId === changeId); 353 + // 354 + // if (record) { 355 + // return { lineage: new Set(record.relatedIds), isLoaded: true }; 356 + // } 357 + // 358 + // return { lineage: new Set<string>(), isLoaded: false }; 359 + // }, [allLineage, repoPath, changeId]); 273 360 274 - return { lineage: new Set<string>(), isLoaded: false }; 275 - }, [allLineage, repoPath, changeId]); 361 + return { lineage: new Set<string>(), isLoaded: true }; 276 362 } 277 363 278 364 /** ··· 290 376 // Use refs to avoid recreating loaders on each render 291 377 const diffLoaderRef = useRef<BatchLoader | null>(null); 292 378 const changesLoaderRef = useRef<BatchLoader | null>(null); 293 - const lineageLoaderRef = useRef<BatchLoader | null>(null); 379 + // DISABLED: Lineage calculations commented out 380 + // const lineageLoaderRef = useRef<BatchLoader | null>(null); 294 381 const currentRepoPathRef = useRef<string>(repoPath); 295 382 296 383 // Reset loaders if repoPath changes ··· 298 385 currentRepoPathRef.current = repoPath; 299 386 diffLoaderRef.current = null; 300 387 changesLoaderRef.current = null; 301 - lineageLoaderRef.current = null; 388 + // DISABLED: Lineage calculations commented out 389 + // lineageLoaderRef.current = null; 302 390 // Clean up loaders for other repos to prevent memory leaks 303 391 cleanupLoadersExcept(repoPath); 304 392 } ··· 318 406 return changesLoaderRef.current; 319 407 } 320 408 321 - function getLineageLoaderInstance(): BatchLoader { 322 - if (!lineageLoaderRef.current) { 323 - lineageLoaderRef.current = getLineageLoader(repoPath); 324 - } 325 - return lineageLoaderRef.current; 326 - } 409 + // DISABLED: Lineage calculations commented out 410 + // function getLineageLoaderInstance(): BatchLoader { 411 + // if (!lineageLoaderRef.current) { 412 + // lineageLoaderRef.current = getLineageLoader(repoPath); 413 + // } 414 + // return lineageLoaderRef.current; 415 + // } 327 416 328 417 return { 329 418 prefetchDiffs: (ids: string[]) => { ··· 334 423 traceLog("prefetch-changes", { count: ids.length, ids }); 335 424 getChangesLoaderInstance().queueMany(ids); 336 425 }, 337 - prefetchLineage: (ids: string[]) => { 338 - traceLog("prefetch-lineage", { count: ids.length, ids }); 339 - getLineageLoaderInstance().queueMany(ids); 426 + // DISABLED: Lineage calculations commented out 427 + prefetchLineage: (_ids: string[]) => { 428 + // traceLog("prefetch-lineage", { count: ids.length, ids }); 429 + // getLineageLoaderInstance().queueMany(ids); 340 430 }, 341 431 flushDiffs: () => getDiffLoaderInstance().flushPromise(), 342 432 flushChanges: () => getChangesLoaderInstance().flushPromise(), 343 - flushLineage: () => getLineageLoaderInstance().flushPromise(), 433 + flushLineage: () => Promise.resolve(), 344 434 }; 345 435 }, [repoPath]); 346 436 }
+8
apps/desktop/src/hooks/useRevisionGraphNavigation.ts
··· 4 4 import { useRef } from "react"; 5 5 import { Route } from "@/routes/project.$projectId"; 6 6 import { viewModeAtom } from "@/atoms"; 7 + import { traceLog } from "@/lib/trace"; 7 8 import type { RevisionStack } from "@/components/revision-graph-utils"; 8 9 import { getRevisionKey } from "@/db"; 9 10 import { useKeyboardShortcut } from "@/hooks/useKeyboard"; ··· 127 128 const navigateToDisplayRow = (index: number) => { 128 129 const displayRow = displayRows[index]; 129 130 if (!displayRow) return; 131 + 132 + traceLog("navigateToDisplayRow", { 133 + index, 134 + type: displayRow.type, 135 + changeId: displayRow.type === "revision" ? displayRow.row.revision.change_id : undefined, 136 + stackId: displayRow.type === "collapsed-stack" ? displayRow.stack.id : undefined, 137 + }); 130 138 131 139 if (displayRow.type === "revision") { 132 140 navigate({
+35
apps/desktop/src/hooks/useSelectedRevision.ts
··· 1 + import { useMemo } from "react"; 2 + import { getRevisionKey } from "@/db"; 3 + import { traceLog } from "@/lib/trace"; 4 + import type { Revision } from "@/tauri-commands"; 5 + 6 + /** 7 + * Hook to compute the selected revision from URL param and revisions array. 8 + * Uses memoization for stability and includes trace logging for debugging. 9 + * 10 + * @param revisions - Array of revisions to search 11 + * @param rev - URL param value (can be change_id or revision key like "tpuq/0") 12 + * @returns The selected revision or null if not found 13 + */ 14 + export function useSelectedRevision( 15 + revisions: Revision[], 16 + rev: string | undefined, 17 + ): Revision | null { 18 + return useMemo(() => { 19 + if (revisions.length === 0) return null; 20 + 21 + if (rev) { 22 + // Match using revision key to handle divergent revisions (e.g., "tpuq/0") 23 + const found = revisions.find((r) => getRevisionKey(r) === rev); 24 + if (found) { 25 + traceLog("selectedRevision-from-rev", { rev, changeId: found.change_id }); 26 + return found; 27 + } 28 + } 29 + 30 + // Fallback to working copy or first revision 31 + const fallback = revisions.find((r) => r.is_working_copy) || revisions[0]; 32 + traceLog("selectedRevision-fallback", { changeId: fallback?.change_id }); 33 + return fallback; 34 + }, [revisions, rev]); 35 + }
+13
apps/desktop/src/lib/batch-loader.ts
··· 159 159 160 160 function queueMany(ids: string[]): void { 161 161 let addedAny = false; 162 + let cacheHits = 0; 163 + let cacheMisses = 0; 162 164 for (const id of ids) { 163 165 if (!isLoaded(id)) { 164 166 pending.add(id); 165 167 addedAny = true; 168 + cacheMisses++; 169 + } else { 170 + cacheHits++; 166 171 } 172 + } 173 + if (ids.length > 0) { 174 + traceLog("batch-queue", { 175 + total: ids.length, 176 + cacheHits, 177 + cacheMisses, 178 + alreadyPending: pending.size - cacheMisses, 179 + }); 167 180 } 168 181 if (addedAny) { 169 182 scheduleFlush();
+4 -2
apps/desktop/src/main.tsx
··· 15 15 16 16 const workerPoolOptions = { 17 17 workerFactory: () => 18 - new Worker(new URL("@pierre/diffs/worker/worker.js", import.meta.url), { type: "module" }), 19 - poolSize: 4, 18 + new Worker(new URL("@pierre/diffs/worker/worker.js", import.meta.url), { 19 + type: "module", 20 + }), 21 + poolSize: 8, 20 22 }; 21 23 22 24 const highlighterOptions = {