a very good jj gui
0
fork

Configure Feed

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

+799 -178
+50
apps/desktop/AGENTS.md
··· 1 + # Agent Instructions 2 + 3 + ## Project Context 4 + 5 + Tatami is a desktop GUI client for Jujutsu (jj) version control. Tauri v2 + React frontend with Rust backend. 6 + 7 + ## Key Documentation 8 + 9 + - **[DEVELOPMENT.md](./DEVELOPMENT.md)** - Data architecture and patterns (MUST READ before modifying data layer) 10 + - **[../../CLAUDE.md](../../CLAUDE.md)** - Build commands and project structure 11 + 12 + ## Linting 13 + 14 + ### Biome 15 + Standard linting via `biome.jsonc`. Run with `bun run lint`. 16 + 17 + ### ast-grep 18 + Architecture rules in `rules/`. Run with: 19 + 20 + ```bash 21 + # Scan all rules 22 + sg scan 23 + 24 + # Scan single rule 25 + sg scan --rule rules/no-direct-ipc-data-fetch.yml 26 + ``` 27 + 28 + ## Data Flow (Critical) 29 + 30 + ``` 31 + Components → useLiveQuery (instant) → TanStack DB Collections 32 + 33 + Batch Loader (debounced) 34 + 35 + Tauri IPC (batched, expensive!) 36 + ``` 37 + 38 + **Principle: All reads are local. All IPC calls are batched.** 39 + 40 + See [DEVELOPMENT.md](./DEVELOPMENT.md) for details. 41 + 42 + ## Working with Issues 43 + 44 + This project uses `fp` for issue tracking: 45 + 46 + ```bash 47 + fp tree # View issue hierarchy 48 + fp issue show <id> # View issue details 49 + fp issue list --status todo # List open issues 50 + ```
+152
apps/desktop/DEVELOPMENT.md
··· 1 + # Data Architecture: Local-First with Batch IPC 2 + 3 + ## Principle 4 + 5 + **All data reads are local. All IPC calls are batched.** 6 + 7 + ``` 8 + ┌────────────────────────────────────────────────────────────┐ 9 + │ Components │ 10 + │ │ 11 + │ useDiff(changeId) useChanges(changeId) │ 12 + │ usePrefetch() (always instant from local DB) │ 13 + └────────────────────────────────────────────────────────────┘ 14 + 15 + 16 + ┌────────────────────────────────────────────────────────────┐ 17 + │ TanStack DB │ 18 + │ │ 19 + │ diffsCollection changesCollection │ 20 + │ (all diffs, keyed (all file lists, keyed │ 21 + │ by repoPath:changeId) by repoPath:changeId) │ 22 + └────────────────────────────────────────────────────────────┘ 23 + 24 + │ batch sync 25 + 26 + ┌────────────────────────────────────────────────────────────┐ 27 + │ Batch Loader │ 28 + │ │ 29 + │ - Queues IDs that need loading │ 30 + │ - Debounces (50ms) to collect multiple requests │ 31 + │ - Batches into single IPC call │ 32 + │ - Syncs results → collections │ 33 + └────────────────────────────────────────────────────────────┘ 34 + 35 + │ single batched invoke() 36 + 37 + ┌────────────────────────────────────────────────────────────┐ 38 + │ Tauri IPC (expensive!) │ 39 + │ │ 40 + │ getDiffsBatch({ changeIds: string[] }) │ 41 + │ getChangesBatch({ changeIds: string[] }) │ 42 + │ │ 43 + │ ~50-200ms per call - minimize these! │ 44 + └────────────────────────────────────────────────────────────┘ 45 + ``` 46 + 47 + ## Rules 48 + 49 + ### 1. Components Never Call IPC Directly 50 + 51 + ```typescript 52 + // ❌ WRONG 53 + import { getRevisionDiff } from '@/tauri-commands'; 54 + const diff = await getRevisionDiff(repoPath, changeId); 55 + 56 + // ✅ RIGHT 57 + import { useDiff } from '@/db'; 58 + const { data: diff } = useDiff(repoPath, changeId); 59 + ``` 60 + 61 + ### 2. Use Unified Collections, Not Per-Entity 62 + 63 + ```typescript 64 + // ❌ WRONG - creates N collections, causes GC issues 65 + function getRevisionDiffCollection(repoPath, changeId) { 66 + return createCollection({ 67 + queryKey: ["diff", repoPath, changeId], // Per changeId! 68 + ... 69 + }); 70 + } 71 + 72 + // ✅ RIGHT - single collection, query locally 73 + const diffsCollection = createCollection({ 74 + queryKey: ["diffs"], 75 + getKey: (d) => `${d.repoPath}:${d.changeId}`, 76 + }); 77 + 78 + // Query with filter - instant local read 79 + useLiveQuery(diffsCollection, q => 80 + q.where('changeId', '==', selectedId) 81 + ); 82 + ``` 83 + 84 + ### 3. Batch IPC Calls 85 + 86 + ```typescript 87 + // ❌ WRONG - N IPC calls = N × 200ms 88 + for (const id of changeIds) { 89 + await getRevisionDiff(repoPath, id); 90 + } 91 + 92 + // ✅ RIGHT - 1 IPC call = 200ms total 93 + const diffs = await getDiffsBatch(repoPath, changeIds); 94 + 95 + // ✅ EVEN BETTER - use batch loader with debounce 96 + const { prefetchDiffs } = usePrefetch(repoPath); 97 + prefetchDiffs(changeIds); // Queued, debounced, batched 98 + ``` 99 + 100 + ### 4. Prefetch Strategically 101 + 102 + Prefetch data before user needs it: 103 + 104 + ```typescript 105 + // Prefetch visible range 106 + useEffect(() => { 107 + const visibleIds = visibleRevisions.map(r => r.change_id); 108 + prefetchDiffs(visibleIds); 109 + }, [visibleRevisions]); 110 + 111 + // Prefetch around selection for smooth navigation 112 + useEffect(() => { 113 + const nearbyIds = getNearbyRevisionIds(selectedIndex, ±5); 114 + prefetchDiffs(nearbyIds); 115 + }, [selectedIndex]); 116 + 117 + // Prefetch search results 118 + useEffect(() => { 119 + if (searchResults.length > 0) { 120 + prefetchDiffs(searchResults.slice(0, 20).map(r => r.change_id)); 121 + } 122 + }, [searchResults]); 123 + ``` 124 + 125 + ### 5. File Watcher Handles Invalidation 126 + 127 + ```typescript 128 + // When repo changes, clear and re-fetch 129 + listen('repo-changed', (repoPath) => { 130 + // Clear affected data from collections 131 + clearCollectionsForRepo(repoPath); 132 + 133 + // Component effects will re-trigger prefetch 134 + // No manual refetch needed 135 + }); 136 + ``` 137 + 138 + ## Why This Architecture? 139 + 140 + | Problem | Old Approach | New Approach | 141 + |---------|--------------|--------------| 142 + | IPC latency | 1 call per selection (200ms wait) | Batched prefetch (instant reads) | 143 + | GC issues | Per-entity collections get cleaned up | Unified collections persist | 144 + | Prefetch broken | Collections GC'd before use | Data stays in collection | 145 + | Search results | Fetch on select (slow) | Prefetch on search (instant) | 146 + 147 + ## Files 148 + 149 + - `src/db.ts` - Collections, batch loaders, hooks 150 + - `src/lib/batch-loader.ts` - BatchLoader class 151 + - `src/tauri-commands.ts` - IPC wrappers (batch APIs) 152 + - `src-tauri/src/lib.rs` - Rust batch commands
apps/desktop/rules/no-direct-tauri-mutations.yml rules/no-direct-tauri-mutations.yml
apps/desktop/rules/no-react-memoization.yml rules/no-react-memoization.yml
-7
apps/desktop/rules/no-useeffect-data-fetching.yml
··· 1 - # Rule: Discourage useEffect for data fetching 2 - id: no-useeffect-data-fetching 3 - language: typescript 4 - severity: warning 5 - message: "useEffect for data fetching is discouraged. Use TanStack DB (useLiveQuery) instead." 6 - rule: 7 - pattern: useEffect($$$ARGS)
apps/desktop/rules/no-usestate.yml rules/no-usestate.yml
apps/desktop/sgconfig.yml sgconfig.yml
+31 -54
apps/desktop/src/components/AceJump.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 + import { useQuery } from "@tanstack/react-query"; 2 3 import type React from "react"; 3 - import { useRef, useState, useEffect, useMemo, useCallback, useDeferredValue } from "react"; 4 + import { useRef, useState, useMemo, useCallback, useDeferredValue } from "react"; 4 5 import { aceJumpOpenAtom } from "@/atoms"; 5 6 import { 6 7 CommandDialog, ··· 63 64 return false; 64 65 } 65 66 66 - // Initial revset result state 67 - const initialRevsetResult = { 68 - changeIds: [] as string[], 69 - error: null as string | null, 70 - loading: false, 71 - label: null as string | null, 72 - }; 73 - 74 67 export function AceJump({ revisions, repoPath, onJump }: AceJumpProps) { 75 68 const [open, setOpenRaw] = useAtom(aceJumpOpenAtom); 76 69 const [search, setSearch] = useState(""); 77 - // Use React's useDeferredValue for filtering debounce - no useEffect needed 70 + // Use React's useDeferredValue for debouncing - defers updates during typing 78 71 const debouncedSearch = useDeferredValue(search); 79 72 80 - const [revsetResult, setRevsetResult] = useState(initialRevsetResult); 81 - 82 73 // Wrap setOpen to reset state when opening 83 74 const setOpen = useCallback( 84 75 (nextOpen: boolean) => { 85 76 if (nextOpen) { 86 - // Reset state when opening - no useEffect needed 77 + // Reset state when opening 87 78 setSearch(""); 88 - setRevsetResult(initialRevsetResult); 89 79 } 90 80 setOpenRaw(nextOpen); 91 81 }, ··· 109 99 }); 110 100 } 111 101 112 - // Debounced revset resolution 113 - const resolveRevsetDebounced = useCallback( 114 - async (query: string) => { 115 - if (!repoPath || !query.trim()) { 116 - setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 117 - return; 118 - } 102 + // Determine if current search is a revset expression 103 + const isRevset = isRevsetExpression(debouncedSearch); 119 104 120 - if (!isRevsetExpression(query)) { 121 - setRevsetResult({ changeIds: [], error: null, loading: false, label: null }); 122 - return; 123 - } 105 + // Use TanStack Query for revset resolution (async data fetching) 106 + const { 107 + data: revsetData, 108 + isLoading: revsetLoading, 109 + error: revsetError, 110 + } = useQuery({ 111 + queryKey: ["revset", repoPath, debouncedSearch], 112 + queryFn: async () => { 113 + if (!repoPath) throw new Error("No repo path"); 114 + const result = await resolveRevset(repoPath, debouncedSearch.trim()); 115 + return result; 116 + }, 117 + enabled: !!repoPath && isRevset && debouncedSearch.trim().length > 0, 118 + staleTime: 30 * 1000, // 30 seconds 119 + retry: false, 120 + }); 124 121 125 - setRevsetResult((prev) => ({ ...prev, loading: true, label: query })); 126 - 127 - try { 128 - const result = await resolveRevset(repoPath, query.trim()); 129 - setRevsetResult({ 130 - changeIds: result.change_ids, 131 - error: result.error, 132 - loading: false, 133 - label: query, 134 - }); 135 - } catch (err) { 136 - setRevsetResult({ 137 - changeIds: [], 138 - error: String(err), 139 - loading: false, 140 - label: query, 141 - }); 142 - } 143 - }, 144 - [repoPath], 122 + // Derive revset result from query state 123 + const revsetResult = useMemo( 124 + () => ({ 125 + changeIds: revsetData?.change_ids ?? [], 126 + error: revsetData?.error ?? (revsetError ? String(revsetError) : null), 127 + loading: revsetLoading, 128 + label: isRevset ? debouncedSearch : null, 129 + }), 130 + [revsetData, revsetError, revsetLoading, isRevset, debouncedSearch], 145 131 ); 146 - 147 - // Debounce the revset resolution (async API call - acceptable use of useEffect) 148 - useEffect(() => { 149 - const timeout = setTimeout(() => { 150 - resolveRevsetDebounced(search); 151 - }, 150); // 150ms debounce 152 - 153 - return () => clearTimeout(timeout); 154 - }, [search, resolveRevsetDebounced]); 155 132 156 133 // Build lookup maps 157 134 const revisionByChangeId = useMemo(
+106 -69
apps/desktop/src/components/DiffPanel.tsx
··· 7 7 forwardRef, 8 8 useCallback, 9 9 useDeferredValue, 10 - useEffect, 11 10 useMemo, 12 11 useRef, 13 12 useState, ··· 156 155 const scrollContainerRef = useRef<HTMLDivElement>(null); 157 156 const [globalDiffStyle] = useAtom(diffStyleAtom); 158 157 const [diffViewState, setDiffViewState] = useAtom(diffViewStateAtom); 159 - const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); 160 158 const [hasFocus, setHasFocus] = useState(false); 161 159 160 + // Track selected files with the changeId they belong to 161 + // When changeId changes, we reset selection during render (no useEffect needed) 162 + const [selectedFilesState, setSelectedFilesState] = useState<{ 163 + forChangeId: string | null; 164 + files: Set<string>; 165 + }>({ forChangeId: null, files: new Set() }); 166 + 167 + // Derive effective selected files - reset if changeId changed 168 + const selectedFiles = 169 + selectedFilesState.forChangeId === deferredChangeId 170 + ? selectedFilesState.files 171 + : new Set<string>(); 172 + 173 + // Wrapper to update selected files with changeId tracking 174 + const setSelectedFiles = useCallback( 175 + (files: Set<string> | ((prev: Set<string>) => Set<string>)) => { 176 + setSelectedFilesState((prev) => { 177 + const newFiles = typeof files === "function" ? files(prev.files) : files; 178 + return { forChangeId: deferredChangeId, files: newFiles }; 179 + }); 180 + }, 181 + [deferredChangeId], 182 + ); 183 + 162 184 // Handler for blur events - only unfocus if focus moves outside container 163 185 const handleBlur = (e: FocusEvent<HTMLDivElement>) => { 164 186 if (!e.currentTarget.contains(e.relatedTarget as Node)) { ··· 176 198 } 177 199 }; 178 200 179 - // Get first selected file for style override display 180 - const firstSelectedFile = selectedFiles.size > 0 ? [...selectedFiles][0] : null; 181 - 182 - // Get effective diff style for first selected file 183 - const effectiveDiffStyle = firstSelectedFile 184 - ? (diffViewState.styleOverrides.get(firstSelectedFile) ?? globalDiffStyle) 185 - : globalDiffStyle; 186 - 187 - const handleSetLocalStyle = useCallback( 188 - (style: DiffStyle) => { 189 - if (selectedFiles.size === 0) return; 190 - setDiffViewState((prev) => { 191 - const next = new Map(prev.styleOverrides); 192 - // Apply style to all selected files 193 - for (const file of selectedFiles) { 194 - next.set(file, style); 195 - } 196 - return { ...prev, styleOverrides: next }; 197 - }); 198 - }, 199 - [selectedFiles, setDiffViewState], 200 - ); 201 - 202 - // Reset selected files when changeId changes 203 - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on changeId change 204 - useEffect(() => { 205 - setSelectedFiles(new Set()); 206 - }, [deferredChangeId]); 207 - 208 201 // Keyboard navigation 209 202 useDiffPanelKeyboard({ scrollContainerRef, revisionsPanelRef, hasFocus }); 210 203 ··· 224 217 const revisionDiff = diffEntries[0]?.content ?? ""; 225 218 226 219 // Timing instrumentation for cache analysis 227 - console.log('[DiffPanel] selection:', changeId?.slice(0,8), 228 - 'deferred:', deferredChangeId?.slice(0,8), 229 - 'changedFiles:', changedFiles.length, 230 - 'hasDiff:', !!revisionDiff, 231 - 'at:', performance.now().toFixed(0)); 220 + console.log( 221 + "[DiffPanel] selection:", 222 + changeId?.slice(0, 8), 223 + "deferred:", 224 + deferredChangeId?.slice(0, 8), 225 + "changedFiles:", 226 + changedFiles.length, 227 + "hasDiff:", 228 + !!revisionDiff, 229 + "at:", 230 + performance.now().toFixed(0), 231 + ); 232 + 233 + // Derive effective diffViewState - reset when changeId changes (no useEffect needed) 234 + const effectiveDiffViewState = getDiffViewState(diffViewState, deferredChangeId); 235 + 236 + // Sync atom if it needs reset (one-time sync, not a loop) 237 + if (effectiveDiffViewState !== diffViewState) { 238 + // Schedule update for next microtask to avoid setState during render 239 + queueMicrotask(() => setDiffViewState(effectiveDiffViewState)); 240 + } 241 + 242 + // Derive effective selected files with auto-select first file 243 + const effectiveSelectedFiles = useMemo(() => { 244 + // If we have selected files for this changeId, use them 245 + if (selectedFiles.size > 0) return selectedFiles; 246 + // Auto-select first file when files load and none selected 247 + if (changedFiles.length > 0) { 248 + return new Set([changedFiles[0].path]); 249 + } 250 + return selectedFiles; 251 + }, [selectedFiles, changedFiles]); 252 + 253 + // Get first selected file for style override display 254 + const firstSelectedFile = 255 + effectiveSelectedFiles.size > 0 ? [...effectiveSelectedFiles][0] : null; 232 256 233 - // Track what's currently displayed - shows previous while loading 234 - const [displayedState, setDisplayedState] = useState<{ 257 + // Get effective diff style for first selected file 258 + const effectiveDiffStyle = firstSelectedFile 259 + ? (effectiveDiffViewState.styleOverrides.get(firstSelectedFile) ?? globalDiffStyle) 260 + : globalDiffStyle; 261 + 262 + const handleSetLocalStyle = useCallback( 263 + (style: DiffStyle) => { 264 + if (effectiveSelectedFiles.size === 0) return; 265 + setDiffViewState((prev) => { 266 + const next = new Map(prev.styleOverrides); 267 + // Apply style to all selected files 268 + for (const file of effectiveSelectedFiles) { 269 + next.set(file, style); 270 + } 271 + return { ...prev, styleOverrides: next }; 272 + }); 273 + }, 274 + [effectiveSelectedFiles, setDiffViewState], 275 + ); 276 + 277 + // Track last valid displayed state using a ref (avoids useEffect for caching) 278 + const lastValidStateRef = useRef<{ 235 279 changeId: string; 236 280 patches: Array<{ path: string; patch: string; status: ChangedFileStatus }>; 237 281 } | null>(null); 238 282 239 - // Update displayed state only when new data arrives 240 - useEffect(() => { 241 - if (revisionDiff && deferredChangeId) { 242 - const patches = splitMultiFileDiff(revisionDiff).map((patch) => ({ 243 - path: extractFilePath(patch) ?? "unknown", 244 - patch, 245 - status: (changedFiles.find((f) => f.path === extractFilePath(patch))?.status ?? 246 - "modified") as ChangedFileStatus, 247 - })); 248 - setDisplayedState({ changeId: deferredChangeId, patches }); 249 - } 283 + // Compute current patches when data is available 284 + const currentPatches = useMemo(() => { 285 + if (!revisionDiff || !deferredChangeId) return null; 286 + return splitMultiFileDiff(revisionDiff).map((patch) => ({ 287 + path: extractFilePath(patch) ?? "unknown", 288 + patch, 289 + status: (changedFiles.find((f) => f.path === extractFilePath(patch))?.status ?? 290 + "modified") as ChangedFileStatus, 291 + })); 250 292 }, [revisionDiff, deferredChangeId, changedFiles]); 293 + 294 + // Update ref when we have valid data (side effect during render is fine for refs) 295 + if (currentPatches && deferredChangeId) { 296 + lastValidStateRef.current = { changeId: deferredChangeId, patches: currentPatches }; 297 + } 298 + 299 + // Use current data if available, otherwise fall back to last valid state 300 + const displayedState = currentPatches 301 + ? { changeId: deferredChangeId!, patches: currentPatches } 302 + : lastValidStateRef.current; 251 303 252 304 // Determine if we're showing stale data 253 305 const isStale = displayedState !== null && displayedState.changeId !== changeId; ··· 279 331 return { totalAdditions: additions, totalDeletions: deletions }; 280 332 }, [fileDiffs]); 281 333 282 - // Sync diffViewState atom when changeId changes (reset to initial state) 283 - useEffect(() => { 284 - const effectiveState = getDiffViewState(diffViewState, deferredChangeId); 285 - if (effectiveState !== diffViewState) { 286 - setDiffViewState(effectiveState); 287 - } 288 - }, [deferredChangeId, diffViewState, setDiffViewState]); 289 - 290 - // Auto-select first file when files load and none selected 291 - useEffect(() => { 292 - if (changedFiles.length > 0 && selectedFiles.size === 0) { 293 - setSelectedFiles(new Set([changedFiles[0].path])); 294 - } 295 - }, [changedFiles, selectedFiles.size]); 296 - 297 334 // Get patches for selected files (in order) 298 335 const selectedPatches = useMemo(() => { 299 336 const patches: Array<{ path: string; patch: string; status: ChangedFileStatus }> = []; 300 337 // Maintain file order from changedFiles 301 338 for (const file of changedFiles) { 302 - if (selectedFiles.has(file.path)) { 339 + if (effectiveSelectedFiles.has(file.path)) { 303 340 const patch = patchMap.get(file.path) ?? ""; 304 341 patches.push({ path: file.path, patch, status: file.status as ChangedFileStatus }); 305 342 } 306 343 } 307 344 return patches; 308 - }, [changedFiles, selectedFiles, patchMap]); 345 + }, [changedFiles, effectiveSelectedFiles, patchMap]); 309 346 310 347 if (!repoPath || !changeId) { 311 348 return ( ··· 366 403 onClick={() => handleSetLocalStyle("unified")} 367 404 title="Unified diff view" 368 405 className="h-6 w-6" 369 - disabled={selectedFiles.size === 0} 406 + disabled={effectiveSelectedFiles.size === 0} 370 407 > 371 408 <RowsIcon className="size-3" /> 372 409 </Button> ··· 376 413 onClick={() => handleSetLocalStyle("split")} 377 414 title="Split diff view" 378 415 className="h-6 w-6" 379 - disabled={selectedFiles.size === 0} 416 + disabled={effectiveSelectedFiles.size === 0} 380 417 > 381 418 <Columns2Icon className="size-3" /> 382 419 </Button> ··· 395 432 <div className="h-full w-full min-w-0"> 396 433 <FileList 397 434 files={changedFiles} 398 - selectedFiles={selectedFiles} 435 + selectedFiles={effectiveSelectedFiles} 399 436 onSelectFiles={setSelectedFiles} 400 437 totalAdditions={totalAdditions} 401 438 totalDeletions={totalDeletions}
+35 -16
apps/desktop/src/components/diff/FileList.tsx
··· 307 307 return getFilesInTreeOrder(tree); 308 308 }, [tree]); 309 309 310 - // Auto-expand all directories when switching to tree view or when filter changes 311 - useEffect(() => { 312 - if (viewMode === "tree") { 313 - const allDirs = new Set<string>(); 314 - const collectDirs = (node: TreeNode) => { 315 - if (node.isDirectory && node.path) { 316 - allDirs.add(node.path); 317 - } 318 - for (const child of node.children.values()) { 319 - collectDirs(child); 320 - } 321 - }; 322 - collectDirs(tree); 323 - setExpandedDirs(allDirs); 310 + // Compute all directory paths in tree (for auto-expand) 311 + const allDirsInTree = useMemo(() => { 312 + const allDirs = new Set<string>(); 313 + const collectDirs = (node: TreeNode) => { 314 + if (node.isDirectory && node.path) { 315 + allDirs.add(node.path); 316 + } 317 + for (const child of node.children.values()) { 318 + collectDirs(child); 319 + } 320 + }; 321 + collectDirs(tree); 322 + return allDirs; 323 + }, [tree]); 324 + 325 + // Track tree identity for auto-expand reset 326 + const [lastTreeForAutoExpand, setLastTreeForAutoExpand] = useState<TreeNode | null>(null); 327 + 328 + // Derive effective expanded dirs - auto-expand all when tree changes (in tree view) 329 + const effectiveExpandedDirs = useMemo(() => { 330 + if (viewMode !== "tree") return expandedDirs; 331 + // If tree changed, expand all directories 332 + if (tree !== lastTreeForAutoExpand) { 333 + return allDirsInTree; 324 334 } 325 - }, [viewMode, tree]); 335 + return expandedDirs; 336 + }, [viewMode, tree, lastTreeForAutoExpand, expandedDirs, allDirsInTree]); 337 + 338 + // Sync expanded dirs state when tree changes (schedule to avoid setState during render) 339 + if (viewMode === "tree" && tree !== lastTreeForAutoExpand) { 340 + queueMicrotask(() => { 341 + setExpandedDirs(allDirsInTree); 342 + setLastTreeForAutoExpand(tree); 343 + }); 344 + } 326 345 327 346 const toggleDir = useCallback((path: string) => { 328 347 setExpandedDirs((prev) => { ··· 656 675 selectedFiles={selectedFiles} 657 676 onSelectFile={handleSelectFile} 658 677 onSelectFolder={handleSelectFolder} 659 - expandedDirs={expandedDirs} 678 + expandedDirs={effectiveExpandedDirs} 660 679 toggleDir={toggleDir} 661 680 itemRefs={itemRefs} 662 681 hasFocus={hasFocus}
+42 -27
apps/desktop/src/components/diff/ImageDiff.tsx
··· 1 - import { useEffect, useState } from "react"; 1 + import { useQuery } from "@tanstack/react-query"; 2 2 import type { ChangedFileStatus } from "@/schemas"; 3 3 import { getFileContentBase64 } from "@/tauri-commands"; 4 4 import { getMimeType } from "@/utils/file-types"; ··· 10 10 status: ChangedFileStatus; 11 11 } 12 12 13 + async function loadImageSrc( 14 + repoPath: string, 15 + changeId: string, 16 + filePath: string, 17 + version: "current" | "parent", 18 + ): Promise<string> { 19 + const mimeType = getMimeType(filePath); 20 + const result = await getFileContentBase64(repoPath, changeId, filePath, version); 21 + return `data:${mimeType};base64,${result.base64}`; 22 + } 23 + 13 24 export function ImageDiff({ repoPath, changeId, filePath, status }: ImageDiffProps) { 14 - const [currentSrc, setCurrentSrc] = useState<string | null>(null); 15 - const [parentSrc, setParentSrc] = useState<string | null>(null); 16 - const [loading, setLoading] = useState(true); 17 - const [error, setError] = useState<string | null>(null); 25 + // Fetch current image (skip if deleted) 26 + const { 27 + data: currentSrc, 28 + isLoading: currentLoading, 29 + error: currentError, 30 + } = useQuery({ 31 + queryKey: ["image", repoPath, changeId, filePath, "current"], 32 + queryFn: () => loadImageSrc(repoPath, changeId, filePath, "current"), 33 + enabled: status !== "deleted", 34 + staleTime: 5 * 60 * 1000, // 5 minutes 35 + }); 18 36 19 - useEffect(() => { 20 - async function loadImages() { 21 - setLoading(true); 22 - setError(null); 23 - const mimeType = getMimeType(filePath); 37 + // Fetch parent image (skip if added) 38 + const { 39 + data: parentSrc, 40 + isLoading: parentLoading, 41 + error: parentError, 42 + } = useQuery({ 43 + queryKey: ["image", repoPath, changeId, filePath, "parent"], 44 + queryFn: () => loadImageSrc(repoPath, changeId, filePath, "parent"), 45 + enabled: status !== "added", 46 + staleTime: 5 * 60 * 1000, // 5 minutes 47 + }); 24 48 25 - try { 26 - if (status !== "deleted") { 27 - const result = await getFileContentBase64(repoPath, changeId, filePath, "current"); 28 - setCurrentSrc(`data:${mimeType};base64,${result.base64}`); 29 - } 30 - if (status !== "added") { 31 - const result = await getFileContentBase64(repoPath, changeId, filePath, "parent"); 32 - setParentSrc(`data:${mimeType};base64,${result.base64}`); 33 - } 34 - } catch (e) { 35 - setError(e instanceof Error ? e.message : "Failed to load image"); 36 - } 37 - setLoading(false); 38 - } 39 - loadImages(); 40 - }, [repoPath, changeId, filePath, status]); 49 + const loading = 50 + (status !== "deleted" && currentLoading) || (status !== "added" && parentLoading); 51 + const error = currentError || parentError; 41 52 42 53 if (loading) { 43 54 return <div className="p-4 text-muted-foreground">Loading image...</div>; 44 55 } 45 56 46 57 if (error) { 47 - return <div className="p-4 text-destructive">Error: {error}</div>; 58 + return ( 59 + <div className="p-4 text-destructive"> 60 + Error: {error instanceof Error ? error.message : "Failed to load image"} 61 + </div> 62 + ); 48 63 } 49 64 50 65 if (status === "added" && currentSrc) {
+2 -1
apps/desktop/src/components/revision-graph/index.tsx
··· 932 932 const matchingRevisionsRef = useRef(matchingRevisions); 933 933 matchingRevisionsRef.current = matchingRevisions; 934 934 935 - // Handle jump hint letter key presses 935 + // Handle jump hint letter key presses (DOM event subscription - legitimate useEffect) 936 + // biome-ignore lint/correctness/useExhaustiveDependencies: keyboard event handler pattern 936 937 useEffect(() => { 937 938 if (!inlineJumpMode) return; 938 939
+24 -4
apps/desktop/src/db.ts
··· 435 435 queryClient, 436 436 queryKey: ["revision-changes", repoPath, changeId], 437 437 queryFn: async () => { 438 - console.log('[DB] FETCHING changes for', changeId.slice(0,8), 'at:', performance.now().toFixed(0)); 438 + console.log( 439 + "[DB] FETCHING changes for", 440 + changeId.slice(0, 8), 441 + "at:", 442 + performance.now().toFixed(0), 443 + ); 439 444 const changes = await getRevisionChanges(repoPath, changeId); 440 - console.log('[DB] FETCHED changes for', changeId.slice(0,8), 'at:', performance.now().toFixed(0)); 445 + console.log( 446 + "[DB] FETCHED changes for", 447 + changeId.slice(0, 8), 448 + "at:", 449 + performance.now().toFixed(0), 450 + ); 441 451 return changes; 442 452 }, 443 453 getKey: (file: ChangedFile) => file.path, ··· 487 497 queryClient, 488 498 queryKey: ["revision-diff", repoPath, changeId], 489 499 queryFn: async () => { 490 - console.log('[DB] FETCHING diff for', changeId.slice(0,8), 'at:', performance.now().toFixed(0)); 500 + console.log( 501 + "[DB] FETCHING diff for", 502 + changeId.slice(0, 8), 503 + "at:", 504 + performance.now().toFixed(0), 505 + ); 491 506 const diff = await getRevisionDiff(repoPath, changeId); 492 - console.log('[DB] FETCHED diff for', changeId.slice(0,8), 'at:', performance.now().toFixed(0)); 507 + console.log( 508 + "[DB] FETCHED diff for", 509 + changeId.slice(0, 8), 510 + "at:", 511 + performance.now().toFixed(0), 512 + ); 493 513 return [{ id: "diff" as const, content: diff }]; 494 514 }, 495 515 getKey: (entry: DiffEntry) => entry.id,
+37
rules/batch-ipc-calls.yml
··· 1 + # Rule: IPC calls for data should be batched, not individual 2 + # Each IPC call costs 50-200ms. Batch multiple IDs into single calls. 3 + 4 + id: batch-ipc-calls 5 + language: tsx 6 + severity: error 7 + message: "IPC data fetching in a loop is inefficient. Use batch APIs (getDiffsBatch, getChangesBatch) instead." 8 + note: | 9 + WRONG (N IPC calls): 10 + for (const id of changeIds) { 11 + await getRevisionDiff(repoPath, id); // 200ms × N = slow! 12 + } 13 + 14 + RIGHT (1 IPC call): 15 + const diffs = await getDiffsBatch(repoPath, changeIds); // 200ms once 16 + 17 + Even better - use the batch loader which handles debouncing: 18 + const { prefetchDiffs } = usePrefetch(repoPath); 19 + prefetchDiffs(changeIds); // Queued, debounced, batched automatically 20 + 21 + files: 22 + - "src/**/*.ts" 23 + - "src/**/*.tsx" 24 + 25 + rule: 26 + all: 27 + - any: 28 + - pattern: getRevisionDiff($$$) 29 + - pattern: getRevisionChanges($$$) 30 + - inside: 31 + stopBy: end 32 + any: 33 + - kind: for_statement 34 + - kind: for_in_statement 35 + - pattern: for ($ID of $ITER) { $$$BODY } 36 + - pattern: $ARR.forEach($$$) 37 + - pattern: $ARR.map($$$)
+42
rules/no-direct-ipc-data-fetch.yml
··· 1 + # Rule: Data fetching IPC commands should only be called from db.ts 2 + # All data fetching should go through the batch loader + unified collections pattern. 3 + # Components should use useLiveQuery hooks, never invoke IPC directly. 4 + 5 + id: no-direct-ipc-data-fetch 6 + language: tsx 7 + severity: error 8 + message: "Direct import of data fetching function from tauri-commands is restricted. Use hooks from '@/db' (useDiff, useChanges, usePrefetch) instead." 9 + note: | 10 + Data Flow Architecture: 11 + 12 + Components → useLiveQuery (instant) → TanStack DB Collections 13 + 14 + Batch Loader (debounced) 15 + 16 + Tauri IPC (batched) 17 + 18 + Restricted functions and their replacements: 19 + - getRevisionDiff → useDiff() hook 20 + - getRevisionChanges → useChanges() hook 21 + - getDiffsBatch → internal to batch loader (db.ts only) 22 + - getChangesBatch → internal to batch loader (db.ts only) 23 + 24 + For prefetching, use usePrefetch() hook which queues IDs for batch loading. 25 + 26 + files: 27 + - "src/**/*.ts" 28 + - "src/**/*.tsx" 29 + - "!src/db.ts" 30 + - "!src/tauri-commands.ts" 31 + - "!src/lib/batch-loader.ts" 32 + 33 + rule: 34 + all: 35 + - kind: import_specifier 36 + - regex: "^(getRevisionDiff|getRevisionChanges|getDiffsBatch|getChangesBatch)$" 37 + - inside: 38 + stopBy: end 39 + kind: import_statement 40 + has: 41 + kind: string 42 + regex: "tauri-commands"
+38
rules/no-dynamic-import.yml
··· 1 + # yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/typescript_rule.json 2 + 3 + id: no-dynamic-import 4 + language: tsx 5 + severity: warning 6 + message: "Avoid dynamic imports - use static imports instead" 7 + 8 + rule: 9 + pattern: import($$$) 10 + 11 + note: | 12 + Dynamic imports make code harder to analyze and bundle. 13 + Use static imports unless absolutely necessary (e.g., React lazy loading). 14 + 15 + Suppress with: // ast-grep-ignore: no-dynamic-import 16 + 17 + Example: 18 + ```typescript 19 + // ❌ Bad 20 + const module = await import("./module"); 21 + 22 + // ✅ Good 23 + import { something } from "./module"; 24 + 25 + // ✅ OK for React lazy loading (add ignore comment) 26 + // ast-grep-ignore: no-dynamic-import 27 + const LazyComponent = lazy(() => import("./HeavyComponent")); 28 + ``` 29 + 30 + files: 31 + - "src/**/*.ts" 32 + - "src/**/*.tsx" 33 + 34 + ignores: 35 + - "**/*.test.ts" 36 + - "**/*.test.tsx" 37 + - "**/*.d.ts" 38 + - "**/node_modules/**"
+43
rules/no-foreach.yml
··· 1 + # yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/typescript_rule.json 2 + 3 + id: no-foreach 4 + language: tsx 5 + severity: error 6 + message: "Prefer for...of over forEach for better performance and debuggability" 7 + 8 + rule: 9 + pattern: $ARRAY.forEach($$$ARGS) 10 + 11 + note: | 12 + Use `for...of` loops instead of `forEach` for: 13 + - Better performance (no function call overhead) 14 + - Easier debugging (can use break/continue) 15 + - Works with async/await naturally 16 + 17 + Example: 18 + ```typescript 19 + // ❌ Bad 20 + items.forEach(item => process(item)); 21 + 22 + // ✅ Good 23 + for (const item of items) { 24 + process(item); 25 + } 26 + 27 + // ❌ Bad - async in forEach doesn't wait 28 + items.forEach(async item => await process(item)); 29 + 30 + // ✅ Good - proper async iteration 31 + for (const item of items) { 32 + await process(item); 33 + } 34 + ``` 35 + 36 + files: 37 + - "src/**/*.ts" 38 + - "src/**/*.tsx" 39 + 40 + ignores: 41 + - "**/*.test.ts" 42 + - "**/*.test.tsx" 43 + - "**/node_modules/**"
+45
rules/no-hardcoded-colors.yml
··· 1 + # yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/typescript_rule.json 2 + 3 + id: no-hardcoded-colors 4 + language: tsx 5 + severity: error 6 + message: "Use semantic color tokens instead of hardcoded Tailwind colors" 7 + 8 + rule: 9 + kind: string_fragment 10 + regex: "(text|bg|border)-(white|black|gray|neutral|zinc|slate|stone|red|green|blue|yellow|orange|amber|indigo|violet|purple|pink|emerald|teal|cyan|sky|lime|rose|fuchsia)(-\\d+)?(/\\d+)?" 11 + 12 + note: | 13 + Use semantic color tokens from shadcn/ui theme instead of hardcoded colors. 14 + This ensures consistency and proper dark/light mode support. 15 + 16 + Semantic tokens (from Tailwind config / CSS variables): 17 + - foreground, muted-foreground - text colors 18 + - background, muted, card - background colors 19 + - primary, secondary, accent - brand colors 20 + - destructive - error/danger 21 + - border, input, ring - UI chrome 22 + 23 + Opacity modifiers on semantic tokens ARE allowed: 24 + - text-foreground/80, bg-primary/20, bg-accent/40, etc. 25 + 26 + Example: 27 + ```tsx 28 + // ❌ Bad 29 + <div className="text-white bg-gray-800 border-gray-600"> 30 + <div className="text-red-500">Error</div> 31 + 32 + // ✅ Good 33 + <div className="text-foreground bg-background border-border"> 34 + <div className="text-destructive">Error</div> 35 + 36 + // ✅ Good - opacity on semantic tokens 37 + <div className="bg-accent/40 text-muted-foreground/80"> 38 + ``` 39 + 40 + files: 41 + - "src/**/*.tsx" 42 + 43 + ignores: 44 + - "**/node_modules/**" 45 + - "**/styles/**"
+40
rules/no-inline-styles.yml
··· 1 + # yaml-language-server: $schema=https://raw.githubusercontent.com/ast-grep/ast-grep/main/schemas/typescript_rule.json 2 + 3 + id: no-inline-styles 4 + language: tsx 5 + severity: warning 6 + message: "Prefer Tailwind classes over inline styles" 7 + 8 + rule: 9 + pattern: style={{ $$$PROPS }} 10 + 11 + note: | 12 + Use Tailwind CSS classes instead of inline styles for consistency 13 + and better performance (styles are compiled at build time). 14 + 15 + Exceptions (use ignore comment): 16 + - Dynamic values that can't be expressed in Tailwind (e.g., calculated positions) 17 + - CSS custom properties / variables 18 + 19 + Example: 20 + ```tsx 21 + // ❌ Bad 22 + <div style={{ padding: '16px', backgroundColor: 'red' }}> 23 + 24 + // ✅ Good 25 + <div className="p-4 bg-destructive"> 26 + 27 + // ✅ OK - dynamic value (add ignore comment) 28 + // ast-grep-ignore: no-inline-styles 29 + <div style={{ transform: `translateX(${offset}px)` }}> 30 + 31 + // ✅ OK - CSS variables 32 + // ast-grep-ignore: no-inline-styles 33 + <div style={{ '--lane-color': color } as React.CSSProperties}> 34 + ``` 35 + 36 + files: 37 + - "src/**/*.tsx" 38 + 39 + ignores: 40 + - "**/node_modules/**"
+42
rules/no-per-entity-collections.yml
··· 1 + # Rule: Do not create per-entity TanStack DB collections 2 + # All data should live in unified collections (one per data type, not one per entity). 3 + # Per-entity collections cause GC issues and prevent effective prefetching. 4 + 5 + id: no-per-entity-collections 6 + language: tsx 7 + severity: error 8 + message: "Creating per-entity collections is forbidden. Use unified collections (diffsCollection, changesCollection) instead." 9 + note: | 10 + WRONG (per-entity collections - causes GC issues): 11 + const collection = createCollection({ 12 + queryKey: ["revision-diff", repoPath, changeId], // Per-changeId! 13 + ... 14 + }); 15 + 16 + RIGHT (unified collection with local filtering): 17 + // One collection for ALL diffs 18 + const diffsCollection = createCollection({ 19 + queryKey: ["diffs", repoPath], 20 + getKey: (d) => d.changeId, 21 + ... 22 + }); 23 + 24 + // Query locally 25 + useLiveQuery(diffsCollection, q => q.where('changeId', '==', selectedId)); 26 + 27 + Why: Per-entity collections get garbage collected before use. 28 + Unified collections persist and allow instant local reads. 29 + 30 + files: 31 + - "src/**/*.ts" 32 + - "src/**/*.tsx" 33 + - "!src/db.ts" 34 + 35 + rule: 36 + all: 37 + - pattern: createCollection($$$ARGS) 38 + - inside: 39 + stopBy: end 40 + kind: function_declaration 41 + any: 42 + - regex: "create.*Collection"
+26
rules/no-useeffect-data-fetching.yml
··· 1 + # Rule: Discourage useEffect for data fetching 2 + # Detects fetch() or invoke() calls inside useEffect callbacks 3 + 4 + id: no-useeffect-data-fetching 5 + language: tsx 6 + severity: warning 7 + message: "useEffect with fetch/invoke is discouraged for data fetching. Use TanStack DB (useLiveQuery) or TanStack Query instead." 8 + note: | 9 + Anti-patterns detected: 10 + - useEffect(() => { fetch(...) }, []) 11 + - useEffect(() => { invoke(...) }, []) 12 + - useEffect(() => { (async () => { await fetch(...) })() }, []) 13 + 14 + Preferred alternatives: 15 + - useLiveQuery() for reactive data from TanStack DB collections 16 + - useQuery() for server state that doesn't need local caching 17 + - Batch loader pattern in db.ts for IPC calls 18 + 19 + rule: 20 + pattern: useEffect($CALLBACK, $$$DEPS) 21 + has: 22 + stopBy: end 23 + kind: call_expression 24 + has: 25 + kind: identifier 26 + regex: "^(fetch|invoke)$"
+44
rules/no-useeffect-state-sync.yml
··· 1 + # Rule: Discourage useEffect for state synchronization 2 + # State derived from other state should use derived atoms, selectors, or reducers 3 + # instead of useEffect + setState patterns 4 + 5 + id: no-useeffect-state-sync 6 + language: tsx 7 + severity: warning 8 + message: "useEffect that calls setState is often an anti-pattern. Consider derived state, useMemo, TanStack Query, or event callbacks instead." 9 + note: | 10 + Common anti-patterns this catches: 11 + 12 + 1. Syncing derived state: 13 + useEffect(() => { setDerived(compute(source)) }, [source]) 14 + → Use useMemo or derived atoms 15 + 16 + 2. Data fetching: 17 + useEffect(() => { fetch().then(setData) }, []) 18 + → Use TanStack Query or TanStack DB 19 + 20 + 3. Resetting state on prop change: 21 + useEffect(() => { setState(initial) }, [id]) 22 + → Use key prop: <Component key={id} /> 23 + 24 + Legitimate useEffect + setState (suppress with biome-ignore): 25 + - DOM event subscriptions (resize, keydown, etc.) 26 + - External library callbacks 27 + - Imperative animations 28 + 29 + rule: 30 + kind: call_expression 31 + all: 32 + - has: 33 + kind: identifier 34 + regex: "^useEffect$" 35 + - has: 36 + kind: arguments 37 + has: 38 + kind: arrow_function 39 + has: 40 + stopBy: end 41 + kind: call_expression 42 + has: 43 + kind: identifier 44 + regex: "^set[A-Z]"