/ hooks / useDiffData.ts
useDiffData.ts
  1  import type { StructuredPatchHunk } from 'diff'
  2  import { useEffect, useMemo, useState } from 'react'
  3  import {
  4    fetchGitDiff,
  5    fetchGitDiffHunks,
  6    type GitDiffResult,
  7    type GitDiffStats,
  8  } from '../utils/gitDiff.js'
  9  
 10  const MAX_LINES_PER_FILE = 400
 11  
 12  export type DiffFile = {
 13    path: string
 14    linesAdded: number
 15    linesRemoved: number
 16    isBinary: boolean
 17    isLargeFile: boolean
 18    isTruncated: boolean
 19    isNewFile?: boolean
 20    isUntracked?: boolean
 21  }
 22  
 23  export type DiffData = {
 24    stats: GitDiffStats | null
 25    files: DiffFile[]
 26    hunks: Map<string, StructuredPatchHunk[]>
 27    loading: boolean
 28  }
 29  
 30  /**
 31   * Hook to fetch current git diff data on demand.
 32   * Fetches both stats and hunks when component mounts.
 33   */
 34  export function useDiffData(): DiffData {
 35    const [diffResult, setDiffResult] = useState<GitDiffResult | null>(null)
 36    const [hunks, setHunks] = useState<Map<string, StructuredPatchHunk[]>>(
 37      new Map(),
 38    )
 39    const [loading, setLoading] = useState(true)
 40  
 41    // Fetch diff data on mount
 42    useEffect(() => {
 43      let cancelled = false
 44  
 45      async function loadDiffData() {
 46        try {
 47          // Fetch both stats and hunks
 48          const [statsResult, hunksResult] = await Promise.all([
 49            fetchGitDiff(),
 50            fetchGitDiffHunks(),
 51          ])
 52  
 53          if (!cancelled) {
 54            setDiffResult(statsResult)
 55            setHunks(hunksResult)
 56            setLoading(false)
 57          }
 58        } catch (_error) {
 59          if (!cancelled) {
 60            setDiffResult(null)
 61            setHunks(new Map())
 62            setLoading(false)
 63          }
 64        }
 65      }
 66  
 67      void loadDiffData()
 68  
 69      return () => {
 70        cancelled = true
 71      }
 72    }, [])
 73  
 74    return useMemo(() => {
 75      if (!diffResult) {
 76        return { stats: null, files: [], hunks: new Map(), loading }
 77      }
 78  
 79      const { stats, perFileStats } = diffResult
 80      const files: DiffFile[] = []
 81  
 82      // Iterate over perFileStats to get all files including large/skipped ones
 83      for (const [path, fileStats] of perFileStats) {
 84        const fileHunks = hunks.get(path)
 85        const isUntracked = fileStats.isUntracked ?? false
 86  
 87        // Detect large file (in perFileStats but not in hunks, and not binary/untracked)
 88        const isLargeFile = !fileStats.isBinary && !isUntracked && !fileHunks
 89  
 90        // Detect truncated file (total > limit means we truncated)
 91        const totalLines = fileStats.added + fileStats.removed
 92        const isTruncated =
 93          !isLargeFile && !fileStats.isBinary && totalLines > MAX_LINES_PER_FILE
 94  
 95        files.push({
 96          path,
 97          linesAdded: fileStats.added,
 98          linesRemoved: fileStats.removed,
 99          isBinary: fileStats.isBinary,
100          isLargeFile,
101          isTruncated,
102          isUntracked,
103        })
104      }
105  
106      files.sort((a, b) => a.path.localeCompare(b.path))
107  
108      return { stats, files, hunks, loading: false }
109    }, [diffResult, hunks, loading])
110  }