bookwiz.io / components / DifferencesTab.tsx
DifferencesTab.tsx
Raw
'use client'

import { useState, useEffect } from 'react'
import { IoGitCommitOutline, IoAddOutline, IoRemoveOutline, IoCreateOutline, IoDocumentTextOutline, IoLogoGithub, IoSettingsOutline, IoEyeOutline, IoRefreshOutline, IoEllipsisHorizontalOutline, IoReturnUpBackOutline, IoGitBranchOutline, IoTimeOutline, IoCloudUploadOutline } from 'react-icons/io5'
import { supabase } from '@/lib/supabase'
import RevertConfirmDialog from './RevertConfirmDialog'
import DeleteConfirmationDialog from './DeleteConfirmationDialog'
import GitHubRepoSelector from './GitHubRepoSelector'

// Define FileChange interface locally since we removed the import
interface FileChange {
  id: string
  name: string
  path: string
  changeType: 'added' | 'modified' | 'deleted'
  currentContent: string
  lastCommittedContent: string
  linesAdded: number
  linesRemoved: number
}

interface DifferencesTabProps {
  bookId: string
  onFileSelect?: (file: any) => void
  onRequestDiff?: () => void
}

export default function DifferencesTab({ 
  bookId, 
  onFileSelect, 
  onRequestDiff 
}: DifferencesTabProps) {
  
  const [changes, setChanges] = useState<FileChange[]>([])
  const [selectedChange, setSelectedChange] = useState<FileChange | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [commitMessage, setCommitMessage] = useState('')
  const [isCommitting, setIsCommitting] = useState(false)
  const [showCommitDialog, setShowCommitDialog] = useState(false)
  const [hasGitHubIntegration, setHasGitHubIntegration] = useState(false)
  const [repoOwner, setRepoOwner] = useState('')
  const [repoName, setRepoName] = useState('')
  const [revertingFiles, setRevertingFiles] = useState<Set<string>>(new Set())
  const [revertDialogOpen, setRevertDialogOpen] = useState(false)
  const [fileToRevert, setFileToRevert] = useState<FileChange | null>(null)
  const [commitHistory, setCommitHistory] = useState<any[]>([])
  const [commitHistoryLoading, setCommitHistoryLoading] = useState(false)
  const [commitHistoryError, setCommitHistoryError] = useState<string | null>(null)
  const [revertCommitDialogOpen, setRevertCommitDialogOpen] = useState(false)
  const [commitToRevert, setCommitToRevert] = useState<any | null>(null)
  const [isRevertingCommit, setIsRevertingCommit] = useState(false)
  const [showRepoSelector, setShowRepoSelector] = useState(false)

  useEffect(() => {
    const checkGitHubAccess = async () => {
      // Check if we have OAuth data in URL parameters (after GitHub OAuth redirect)
      const urlParams = new URLSearchParams(window.location.search)
      const hasRepoData = urlParams.get('repositories') && urlParams.get('accessToken') && urlParams.get('githubUsername')
      
      if (hasRepoData && !hasGitHubIntegration) {
        setShowRepoSelector(true)
        return
      }
      
      // If no GitHub integration for this book, check if user has GitHub access for other books
      if (!hasGitHubIntegration) {
        try {
          const { data: { session } } = await supabase.auth.getSession()
          const headers: Record<string, string> = {}
          if (session?.access_token) {
            headers['Authorization'] = `Bearer ${session.access_token}`
          }
          
          const response = await fetch('/api/github/repos', { headers })
          if (response.ok) {
            // User has GitHub access, show repo selector
            setShowRepoSelector(true)
          }
        } catch (err) {
          // No GitHub access, show connect screen
          setShowRepoSelector(false)
        }
      }
    }
    
    checkGitHubAccess()
  }, [hasGitHubIntegration])

  useEffect(() => {
    checkGitHubIntegration()
    if (hasGitHubIntegration) {
      fetchCommitHistory()
    }
  }, [bookId, hasGitHubIntegration])

  const checkGitHubIntegration = async () => {
    try {
      // Get user session token
      const { data: { session } } = await supabase.auth.getSession()
      
      const headers: Record<string, string> = {}
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }
      
      const response = await fetch(`/api/books/${bookId}/github-integration`, { headers })
      
      if (response.ok) {
        const data = await response.json()
        if (data.hasIntegration) {
          setHasGitHubIntegration(true)
          setRepoOwner(data.github_username)
          setRepoName(data.repository_name)
          // Pass the integration status directly to avoid race condition
          await fetchUncommittedChanges(true, headers)
        } else {
          setHasGitHubIntegration(false)
          // Pass the integration status directly to avoid race condition
          await fetchUncommittedChanges(false, headers)
        }
      } else {
        setHasGitHubIntegration(false)
        await fetchUncommittedChanges(false)
      }
    } catch (err) {
      console.error('Error checking GitHub integration:', err)
      setHasGitHubIntegration(false)
      await fetchUncommittedChanges(false)
    }
  }

  const fetchUncommittedChanges = async (integrationExists?: boolean, authHeaders?: Record<string, string>) => {
    try {
      setLoading(true)
      setError(null)

      // Use provided headers or get fresh session
      let headers = authHeaders
      if (!headers) {
        const { data: { session } } = await supabase.auth.getSession()
        headers = {}
        if (session?.access_token) {
          headers['Authorization'] = `Bearer ${session.access_token}`
        }
      }
      
      // Get current files from the book - with authentication
      const response = await fetch(`/api/books/${bookId}/files`, { headers })
      if (!response.ok) throw new Error('Failed to fetch files')
      
      const { files } = await response.json()
      
      // Flatten the tree structure to get all files
      const flattenFiles = (items: any[]): any[] => {
        const result: any[] = []
        for (const item of items) {
          if (item.type === 'file') {
            result.push(item)
          }
          if (item.children && item.children.length > 0) {
            result.push(...flattenFiles(item.children))
          }
        }
        return result
      }
      
      const currentFiles = flattenFiles(files)

      // Use the passed parameter instead of state to avoid race condition
      const hasIntegration = integrationExists !== undefined ? integrationExists : hasGitHubIntegration
      
      if (!hasIntegration) {
        // No GitHub integration - show all files as uncommitted
        const newChanges: FileChange[] = currentFiles.map((file: any) => ({
          id: file.id,
          name: file.name,
          path: getFilePath(file, files),
          changeType: 'added' as const,
          currentContent: file.content || '',
          lastCommittedContent: '',
          linesAdded: (file.content || '').split('\n').length,
          linesRemoved: 0
        }))
        setChanges(newChanges)
        setLoading(false)
        return
      }

      // Get GitHub committed files for comparison (reuse existing session and headers)
      const githubResponse = await fetch(`/api/books/${bookId}/github-integration/compare`, { headers })
      
      if (githubResponse.ok) {
        const githubData = await githubResponse.json()
        const { committedFiles, repositoryEmpty } = githubData

        if (repositoryEmpty) {
          const newChanges: FileChange[] = currentFiles.map((file: any) => ({
            id: file.id,
            name: file.name,
            path: getFilePath(file, files),
            changeType: 'added' as const,
            currentContent: file.content || '',
            lastCommittedContent: '',
            linesAdded: (file.content || '').split('\n').length,
            linesRemoved: 0
          }))
          setChanges(newChanges)
          setLoading(false)
          return
        }

        // Check if repository was just created (has commits but all files match exactly)
        // This handles the case where repository was just created with multiple commits
        // but the current state should match the latest commit
        
        const allFilesMatch = currentFiles.every((file: any) => {
          const filePath = getFilePath(file, files)
          const currentContent = file.content || ''
          
          // Use the same path matching logic as the main comparison
          let committedContent = ''
          let matchedPath = ''
          
          // First try the exact path
          if (committedFiles[filePath] !== undefined) {
            committedContent = committedFiles[filePath]
            matchedPath = filePath
          } else {
            // Fallback: try alternative path formats
            const possiblePaths = [
              file.name,
              `${file.name}.${file.file_extension || 'md'}`,
              filePath.replace(/^\/+/, ''),
              filePath.split('/').pop() || filePath
            ].filter(Boolean)
            
            for (const path of possiblePaths) {
              if (committedFiles[path] !== undefined) {
                committedContent = committedFiles[path]
                matchedPath = path
                break
              }
            }
          }
          
          if (committedContent) {
            const matches = currentContent === committedContent
            return matches
          } else {
            return false
          }
        })

        // Also check that we don't have extra committed files
        const noExtraCommittedFiles = Object.keys(committedFiles || {}).every(committedPath => {
          return currentFiles.some((file: any) => {
            const filePath = getFilePath(file, files)
            
            // Check if this committed path matches any current file
            if (filePath === committedPath) return true
            
            // Also check alternative formats for legacy compatibility
            const possiblePaths = [
              file.name,
              `${file.name}.${file.file_extension || 'md'}`,
              filePath.replace(/^\/+/, ''),
              filePath.split('/').pop() || filePath
            ]
            return possiblePaths.includes(committedPath)
          })
        })

        // If all files match perfectly, show no changes (repository is in sync)
        if (allFilesMatch && noExtraCommittedFiles && Object.keys(committedFiles || {}).length > 0) {
          setChanges([])
          setLoading(false)
          return
        }

        // Compare current files with GitHub committed files
        const changes: FileChange[] = []
        
        // Check for modified and added files
        currentFiles.forEach((file: any) => {
          const filePath = getFilePath(file, files)
          const currentContent = file.content || ''
          
          // Primary path should be the correctly formatted path with extension
          let committedContent = ''
          let matchedPath = ''
          
          // First try the exact path (should work for properly formatted paths)
          if (committedFiles[filePath] !== undefined) {
            committedContent = committedFiles[filePath]
            matchedPath = filePath
          } else {
            // Fallback: try alternative path formats for legacy compatibility
            const possiblePaths = [
              file.name, // Just the filename
              `${file.name}.${file.file_extension || 'md'}`, // Filename with extension
              filePath.replace(/^\/+/, ''), // Remove leading slashes
              filePath.split('/').pop() || filePath // Just the filename from full path
            ].filter(Boolean)
            
            for (const path of possiblePaths) {
              if (committedFiles[path] !== undefined) {
                committedContent = committedFiles[path]
                matchedPath = path
                break
              }
            }
          }
          

          
          if (currentContent !== committedContent) {
            const currentLines = currentContent.split('\n').length
            const committedLines = committedContent.split('\n').length
            
            changes.push({
              id: file.id,
              name: file.name,
              path: filePath,
              changeType: committedContent ? 'modified' as const : 'added' as const,
              currentContent,
              lastCommittedContent: committedContent,
              linesAdded: Math.max(0, currentLines - committedLines),
              linesRemoved: Math.max(0, committedLines - currentLines)
            })
          }
        })

        // Check for deleted files
        Object.keys(committedFiles || {}).forEach(committedPath => {
          const fileExists = currentFiles.some((file: any) => {
            const filePath = getFilePath(file, files)
            const possiblePaths = [
              filePath,
              file.name,
              `${file.name}.${file.file_extension || 'md'}`,
              filePath.replace(/^\/+/, ''),
              filePath.split('/').pop() || filePath
            ]
            return possiblePaths.includes(committedPath)
          })
          if (!fileExists) {
            changes.push({
              id: `deleted-${committedPath}`,
              name: committedPath.split('/').pop() || committedPath,
              path: committedPath,
              changeType: 'deleted' as const,
              currentContent: '',
              lastCommittedContent: committedFiles[committedPath],
              linesAdded: 0,
              linesRemoved: committedFiles[committedPath].split('\n').length
            })
          }
        })


        setChanges(changes)
      } else {
        // Handle API errors more gracefully
        console.error('❌ DifferencesTab: GitHub compare failed:', githubResponse.status, githubResponse.statusText)
        
        try {
          const errorData = await githubResponse.json()
          console.error('❌ DifferencesTab: GitHub compare error details:', errorData)
          setError(`Failed to compare with GitHub: ${errorData.error || githubResponse.statusText}`)
        } catch (e) {
          setError(`Failed to compare with GitHub: ${githubResponse.statusText}`)
        }
        
        // Don't show fallback changes if we have a GitHub integration
        // This prevents showing all files as "new" when there's an actual error
        setChanges([])
      }

    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load changes')
    } finally {
      setLoading(false)
    }
  }

  const getFilePath = (file: any, allFiles: any[]): string => {
    // Create a flat map of all items (including folders) for path building
    const flattenAllItems = (items: any[]): any[] => {
      const result: any[] = []
      for (const item of items) {
        result.push(item)
        if (item.children && item.children.length > 0) {
          result.push(...flattenAllItems(item.children))
        }
      }
      return result
    }
    
    const allItems = flattenAllItems(allFiles)
    
    const buildPath = (fileId: string): string[] => {
      const currentFile = allItems.find(f => f.id === fileId)
      if (!currentFile) return []
      
      // For files, always include extension to match GitHub paths
      let fileName = currentFile.name
      if (currentFile.type === 'file') {
        // If file already has an extension in the name, use as-is
        if (fileName.includes('.')) {
          // File name already includes extension
        } else if (currentFile.file_extension) {
          // Add the extension if it's not in the name
          fileName = `${fileName}.${currentFile.file_extension}`
        } else {
          // Default to .md if no extension specified
          fileName = `${fileName}.md`
        }
      }
      
      if (!currentFile.parent_id) return [fileName]
      
      const parentPath = buildPath(currentFile.parent_id)
      return [...parentPath, fileName]
    }
    
    return buildPath(file.id).join('/')
  }

  const handleConnectGitHub = () => {
    // Redirect to GitHub OAuth flow
    const redirectUrl = window.location.href
    window.location.href = `/auth/github?bookId=${bookId}&redirectUrl=${encodeURIComponent(redirectUrl)}`
  }

  const handleFileClickForDiff = (change: FileChange) => {
    // Store the diff information globally so the editor can access it
    const diffInfo = {
      originalContent: change.lastCommittedContent,
      modifiedContent: change.currentContent,
      fileName: change.name,
      filePath: change.path,
      changeType: change.changeType
    }
    ;(window as any).currentDiffInfo = diffInfo

    if (change.changeType === 'deleted') {
      // For deleted files, we don't need to select them in the editor
      // Just show the diff with the last committed content
      if (onRequestDiff) {
        onRequestDiff()
      }
      return
    }

    // For non-deleted files, find the actual file from the current files to pass to onFileSelect
    const fileForSelection = {
      id: change.id,
      name: change.name,
      type: 'file' as const,
      content: change.currentContent,
      book_id: bookId,
      parent_id: null, // We'll set this properly if needed
      file_extension: change.name.split('.').pop() || 'md',
      mime_type: 'text/markdown',
      file_size: null,
      file_url: null,
      expanded: null,
      sort_order: 0,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
      metadata: {},
      last_ai_operation_id: null
    }
    
    // Select the file in the main editor
    if (onFileSelect) {
      onFileSelect(fileForSelection)
      
      // After file selection, immediately request diff mode
      if (onRequestDiff) {
        // Use a shorter delay to ensure file is selected first
        setTimeout(() => {
          onRequestDiff()
        }, 10)
      }
    }
  }

  const handleOpenFileNormally = (change: FileChange, event: React.MouseEvent) => {
    event.stopPropagation() // Prevent triggering the diff view
    
    if (change.changeType === 'deleted') {
      return
    }

    const fileForSelection = {
      id: change.id,
      name: change.name,
      type: 'file' as const,
      content: change.currentContent,
      book_id: bookId,
      parent_id: null,
      file_extension: change.name.split('.').pop() || 'md',
      mime_type: 'text/markdown',
      file_size: null,
      file_url: null,
      expanded: null,
      sort_order: 0,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
      metadata: {},
      last_ai_operation_id: null
    }

    // Clear any existing diff info to exit diff mode (like "Back to Editor")
    ;(window as any).currentDiffInfo = null
    
    // Dispatch event to close diff mode (similar to handleCloseDiff in page)
    if (typeof window !== 'undefined') {
      window.dispatchEvent(new CustomEvent('close-diff-mode'))
    }
    
    // Select the file without requesting diff mode
    if (onFileSelect) {
      onFileSelect(fileForSelection)
    }
  }

    const handleRevertFile = (change: FileChange, event: React.MouseEvent) => {
    event.stopPropagation() // Prevent triggering the diff view
    
    // Show the custom confirmation dialog
    setFileToRevert(change)
    setRevertDialogOpen(true)
  }

  const confirmRevertFile = async () => {
    if (!fileToRevert) return

    try {
      setError(null)
      
      // Add file to reverting set
      setRevertingFiles(prev => new Set(prev).add(fileToRevert.id))
      
      // Get current session for authentication
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = { 'Content-Type': 'application/json' }
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }

      if (fileToRevert.changeType === 'added') {
        // Delete the file
        const response = await fetch(`/api/books/${bookId}/files/${fileToRevert.id}`, {
          method: 'DELETE',
          headers
        })

        if (!response.ok) {
          const errorData = await response.json()
          throw new Error(errorData.error || 'Failed to delete file')
        }
      } else if (fileToRevert.changeType === 'deleted') {
        // Restore the deleted file by creating it with the committed content
        const response = await fetch(`/api/books/${bookId}/files`, {
          method: 'POST',
          headers,
          body: JSON.stringify({
            name: fileToRevert.name,
            content: fileToRevert.lastCommittedContent,
            type: 'file',
            file_extension: fileToRevert.name.split('.').pop() || 'md'
          })
        })

        if (!response.ok) {
          const errorData = await response.json()
          throw new Error(errorData.error || 'Failed to restore file')
        }
      } else {
        // Update file content to committed version (modified files)
        const response = await fetch(`/api/books/${bookId}/files/${fileToRevert.id}`, {
          method: 'PUT',
          headers,
          body: JSON.stringify({
            content: fileToRevert.lastCommittedContent
          })
        })

        if (!response.ok) {
          const errorData = await response.json()
          throw new Error(errorData.error || 'Failed to revert file')
        }
      }

      // Close the dialog
      setRevertDialogOpen(false)
      setFileToRevert(null)

      // Clear diff mode if showing the reverted file
      ;(window as any).currentDiffInfo = null
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('close-diff-mode'))
      }

      // Refresh the changes list and trigger file system refresh
      await fetchUncommittedChanges()
      
      // Trigger file system refresh
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('file-operation-completed', { 
          detail: { type: 'revert', fileId: fileToRevert.id } 
        }))
      }
      

      
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to revert file')
    } finally {
      // Remove file from reverting set
      setRevertingFiles(prev => {
        const newSet = new Set(prev)
        if (fileToRevert) {
          newSet.delete(fileToRevert.id)
        }
        return newSet
      })
    }
  }

  const cancelRevertFile = () => {
    setRevertDialogOpen(false)
    setFileToRevert(null)
  }

  const handleCommitChanges = async () => {
    if (!commitMessage.trim() || changes.length === 0 || !hasGitHubIntegration) return

    try {
      setIsCommitting(true)
      setError(null)
      
      // Get current session for authentication
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = { 'Content-Type': 'application/json' }
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }

      // Commit changes via API
      const response = await fetch(`/api/books/${bookId}/github-integration/commit`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          message: commitMessage,
          changes: changes
        })
      })

      if (!response.ok) {
        const errorData = await response.json()
        throw new Error(errorData.error || 'Failed to commit changes')
      }

      // Success - refresh changes
      setCommitMessage('')
      setShowCommitDialog(false)
      setSelectedChange(null)
      await fetchUncommittedChanges()
      
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to commit changes')
    } finally {
      setIsCommitting(false)
    }
  }

  const getChangeIcon = (changeType: string) => {
    switch (changeType) {
      case 'added': return <IoAddOutline className="w-4 h-4 text-green-400" />
      case 'modified': return <IoCreateOutline className="w-4 h-4 text-blue-400" />
      case 'deleted': return <IoRemoveOutline className="w-4 h-4 text-red-400" />
      default: return <IoDocumentTextOutline className="w-4 h-4 text-slate-400" />
    }
  }

  const getChangeColor = (changeType: string) => {
    switch (changeType) {
      case 'added': return 'text-green-400'
      case 'modified': return 'text-blue-400'
      case 'deleted': return 'text-red-400'
      default: return 'text-slate-400'
    }
  }

  const fetchCommitHistory = async () => {
    setCommitHistoryLoading(true)
    setCommitHistoryError(null)
    try {
      const { data: { session } } = await supabase.auth.getSession()
      const headers: Record<string, string> = {}
      if (session?.access_token) {
        headers['Authorization'] = `Bearer ${session.access_token}`
      }
      const response = await fetch(`/api/books/${bookId}/github-integration/commits`, { headers })
      if (!response.ok) throw new Error('Failed to fetch commit history')
      const data = await response.json()
      setCommitHistory(data.commits || [])
    } catch (err) {
      setCommitHistoryError(err instanceof Error ? err.message : 'Failed to load commit history')
      setCommitHistory([])
    } finally {
      setCommitHistoryLoading(false)
    }
  }

  const formatTimeAgo = (dateString: string) => {
    const date = new Date(dateString)
    const now = new Date()
    const diffMs = now.getTime() - date.getTime()
    const diffMins = Math.floor(diffMs / 60000)
    const diffHours = Math.floor(diffMs / 3600000)
    const diffDays = Math.floor(diffMs / 86400000)
    if (diffMins < 1) return 'just now'
    if (diffMins < 60) return `${diffMins}m ago`
    if (diffHours < 24) return `${diffHours}h ago`
    if (diffDays < 30) return `${diffDays}d ago`
    return date.toLocaleDateString()
  }

  const handleRevertToCommit = (commit: any) => {
    setCommitToRevert(commit)
    setRevertCommitDialogOpen(true)
  }

  const confirmRevertToCommit = async () => {
    if (!commitToRevert) return
    setIsRevertingCommit(true)
    try {
      // TODO: Implement actual revert logic via API
      // await fetch(`/api/books/${bookId}/github-integration/checkout`, { ... })
      setRevertCommitDialogOpen(false)
      setCommitToRevert(null)
      // Optionally refresh state here
    } catch (err) {
      // Handle error
    } finally {
      setIsRevertingCommit(false)
    }
  }

  const cancelRevertToCommit = () => {
    setRevertCommitDialogOpen(false)
    setCommitToRevert(null)
  }

  const handleRepoConnected = () => {
    setShowRepoSelector(false)
    checkGitHubIntegration() // Refresh the integration status
  }

  if (loading) {
    return (
      <div className="h-full flex items-center justify-center">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-400"></div>
      </div>
    )
  }

  if (error) {
    return (
      <div className="p-4">
        <div className="bg-red-900/20 border border-red-700 rounded-lg p-4 text-red-300">
          {error}
        </div>
      </div>
    )
  }

  // Show repo selector if we have OAuth data but no integration yet
  if (showRepoSelector && !hasGitHubIntegration) {
    return (
      <div className="h-full flex flex-col min-h-0">
        <GitHubRepoSelector bookId={bookId} onConnect={handleRepoConnected} />
      </div>
    )
  }

  // Show GitHub setup if not connected
  if (!hasGitHubIntegration) {
    return (
      <div className="h-full flex flex-col min-h-0">
        <div className="p-4 border-b border-slate-700">
          <h3 className="text-lg font-semibold text-slate-200 flex items-center gap-2">
            <IoLogoGithub className="w-5 h-5" />
            Version Control
          </h3>
        </div>
        
        <div className="flex-1 flex items-center justify-center p-6">
          <div className="text-center max-w-md">
            <IoLogoGithub className="w-12 h-12 text-slate-400 mx-auto mb-4" />
            <h4 className="text-lg font-semibold text-slate-200 mb-2">Connect to GitHub</h4>
            <p className="text-slate-400 mb-4">
              Track changes, collaborate, and keep your work safe with professional version control.
            </p>
            
            <div className="space-y-3 mb-6 text-left text-sm">
              <div className="flex items-center gap-2 text-slate-300">
                <IoTimeOutline className="w-4 h-4 text-teal-400 flex-shrink-0" />
                <span>Track every change with timestamps</span>
              </div>
              <div className="flex items-center gap-2 text-slate-300">
                <IoGitBranchOutline className="w-4 h-4 text-teal-400 flex-shrink-0" />
                <span>Try different versions safely</span>
              </div>
              <div className="flex items-center gap-2 text-slate-300">
                <IoCloudUploadOutline className="w-4 h-4 text-teal-400 flex-shrink-0" />
                <span>Secure cloud backup on GitHub</span>
              </div>
            </div>
            
            <div className="space-y-3">
              <button
                onClick={handleConnectGitHub}
                className="w-full bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
              >
                <IoLogoGithub className="w-4 h-4" />
                Connect with GitHub
              </button>
              
              <a
                href="/blog/git-version-control-with-bookwiz"
                target="_blank"
                rel="noopener noreferrer"
                className="block w-full bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-200 px-4 py-2 rounded-lg font-medium transition-colors text-center text-sm"
              >
                Learn More
              </a>
            </div>
          </div>
        </div>
      </div>
    )
  }

  return (
    <div className="h-full flex flex-col min-h-0">
      {/* Header */}
      <div className="p-3 border-b border-slate-700">
        <div className="flex items-center justify-between mb-2">
          <div className="flex items-center gap-2 min-w-0 flex-1">
            <IoLogoGithub className="w-4 h-4 text-green-400 flex-shrink-0" />
            <span className="text-sm font-semibold text-slate-200">Changes</span>
          </div>
          {changes.length > 0 && (
            <button
              onClick={() => setShowCommitDialog(true)}
              className="bg-teal-600 hover:bg-teal-700 text-white px-2.5 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1.5 flex-shrink-0"
            >
              <IoGitCommitOutline className="w-3.5 h-3.5" />
              Commit ({changes.length})
            </button>
          )}
        </div>
        <div className="text-xs bg-green-900/20 text-green-400 px-2 py-1 rounded-md inline-block">
          {repoOwner}/{repoName}
        </div>
        
        {changes.length > 0 && (
          <div className="text-xs text-slate-400 mt-1 flex flex-wrap gap-2">
            {changes.filter(c => c.changeType === 'added').length > 0 && (
              <span className="bg-green-500/10 text-green-400 px-1.5 py-0.5 rounded">
                +{changes.filter(c => c.changeType === 'added').reduce((sum, c) => sum + c.linesAdded, 0)}
              </span>
            )}
            {changes.filter(c => c.changeType === 'modified').length > 0 && (
              <span className="bg-blue-500/10 text-blue-400 px-1.5 py-0.5 rounded">
                ~{changes.filter(c => c.changeType === 'modified').length}
              </span>
            )}
            {changes.filter(c => c.changeType === 'deleted').length > 0 && (
              <span className="bg-red-500/10 text-red-400 px-1.5 py-0.5 rounded">
                -{changes.filter(c => c.changeType === 'deleted').reduce((sum, c) => sum + c.linesRemoved, 0)}
              </span>
            )}
          </div>
        )}
      </div>

      {/* Content */}
      <div className="flex-1">
        {/* Changes List */}
        {changes.length === 0 ? (
          <div className="flex-1 flex items-center justify-center p-8">
            <div className="text-center">
              <IoGitCommitOutline className="w-12 h-12 mx-auto mb-3 text-slate-400 opacity-50" />
              <p className="text-slate-400 mb-2">No changes</p>
              <p className="text-sm text-slate-500">
                All changes have been committed
              </p>
            </div>
          </div>
        ) : (
          <div className="flex-1 overflow-y-auto min-h-0">
            {changes.map((change) => (
              <div
                key={change.id}
                className={`group relative p-2.5 border-b border-slate-700/50 hover:bg-slate-800/30 cursor-pointer transition-colors ${
                  selectedChange?.id === change.id ? 'bg-slate-800/50 border-l-2 border-l-teal-400' : ''
                }`}
                onClick={() => {
                  setSelectedChange(change)
                  handleFileClickForDiff(change)
                }}
              >
                <div className="flex items-center gap-2 mb-1">
                  {getChangeIcon(change.changeType)}
                  <span className="text-slate-200 text-sm font-medium truncate flex-1">
                    {change.name}
                  </span>
                  
                  {/* Action buttons - show on hover */}
                  <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
                    {/* Stats badges */}
                    <div className="flex gap-1 mr-1">
                      {change.changeType !== 'deleted' && change.linesAdded > 0 && (
                        <span className="bg-green-500/10 text-green-400 px-1 py-0.5 rounded text-[10px]">+{change.linesAdded}</span>
                      )}
                      {change.changeType !== 'added' && change.linesRemoved > 0 && (
                        <span className="bg-red-500/10 text-red-400 px-1 py-0.5 rounded text-[10px]">-{change.linesRemoved}</span>
                      )}
                    </div>
                    
                                         {/* Action buttons */}
                     {change.changeType !== 'deleted' && (
                       <button
                         onClick={(e) => handleOpenFileNormally(change, e)}
                         className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-blue-400 transition-colors"
                         title="Open file (without diff)"
                       >
                         <IoDocumentTextOutline className="w-3.5 h-3.5" />
                       </button>
                     )}
                    
                                         <button
                       onClick={(e) => handleRevertFile(change, e)}
                       disabled={revertingFiles.has(change.id)}
                       className="p-1 rounded hover:bg-slate-700/50 text-slate-400 hover:text-orange-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                       title={
                         revertingFiles.has(change.id)
                           ? 'Reverting...'
                           : change.changeType === 'added' 
                             ? 'Delete' 
                             : change.changeType === 'deleted' 
                               ? 'Restore' 
                               : 'Revert'
                       }
                     >
                       <IoRefreshOutline className={`w-3.5 h-3.5 ${revertingFiles.has(change.id) ? 'animate-spin' : ''}`} style={{ transform: 'scaleX(-1)' }} />
                     </button>
                  </div>
                  
                  {/* Always visible stats when not hovering */}
                  <div className="flex gap-1 group-hover:hidden">
                    {change.changeType !== 'deleted' && change.linesAdded > 0 && (
                      <span className="bg-green-500/10 text-green-400 px-1 py-0.5 rounded text-[10px]">+{change.linesAdded}</span>
                    )}
                    {change.changeType !== 'added' && change.linesRemoved > 0 && (
                      <span className="bg-red-500/10 text-red-400 px-1 py-0.5 rounded text-[10px]">-{change.linesRemoved}</span>
                    )}
                  </div>
                </div>
                <div className="flex items-center justify-between">
                  <div className="text-xs text-slate-400 truncate">
                    {change.path}
                  </div>
                  <div className="text-xs text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity">
                    Click to compare
                  </div>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Commit Dialog */}
      {showCommitDialog && (
        <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
          <div className="bg-slate-800 rounded-lg p-6 w-full max-w-md">
            <h4 className="text-lg font-semibold text-slate-200 mb-4">
              Commit Changes
            </h4>
            
            <div className="space-y-4">
              <div>
                <label className="block text-sm font-medium text-slate-300 mb-2">
                  Commit Message
                </label>
                <textarea
                  value={commitMessage}
                  onChange={(e) => setCommitMessage(e.target.value)}
                  placeholder="Describe your changes..."
                  rows={3}
                  className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 placeholder-slate-500 focus:border-teal-400 focus:outline-none resize-none"
                />
              </div>

              <div className="text-sm text-slate-400">
                {changes.length} file(s) will be committed
              </div>
              
              <div className="flex gap-3">
                <button
                  onClick={handleCommitChanges}
                  disabled={!commitMessage.trim() || isCommitting}
                  className="flex-1 bg-teal-600 hover:bg-teal-700 disabled:bg-slate-700 disabled:text-slate-500 text-white px-4 py-2 rounded-lg font-medium transition-colors"
                >
                  {isCommitting ? 'Committing...' : 'Commit Changes'}
                </button>
                <button
                  onClick={() => {
                    setShowCommitDialog(false)
                    setCommitMessage('')
                  }}
                  className="px-4 py-2 text-slate-400 hover:text-slate-200 transition-colors"
                >
                  Cancel
                </button>
              </div>
            </div>
          </div>
        </div>
      )}

      {/* Revert Confirmation Dialog */}
      <RevertConfirmDialog
        isOpen={revertDialogOpen}
        fileName={fileToRevert?.name || ''}
        changeType={fileToRevert?.changeType || 'modified'}
        onConfirm={confirmRevertFile}
        onCancel={cancelRevertFile}
        isLoading={fileToRevert ? revertingFiles.has(fileToRevert.id) : false}
      />

      {/* Commit History Section at the bottom */}
      {hasGitHubIntegration && (
        <div className="border-t border-slate-700 p-3 bg-slate-900">
          <h4 className="text-sm font-semibold text-slate-200 mb-2 flex items-center gap-2">
            <IoTimeOutline className="w-4 h-4 text-teal-400" />
            Commit History
          </h4>
          {commitHistoryLoading ? (
            <div className="flex items-center gap-2 text-slate-400 text-xs"><span className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-400"></span> Loading commit history...</div>
          ) : commitHistoryError ? (
            <div className="text-xs text-red-400">{commitHistoryError}</div>
          ) : commitHistory.length === 0 ? (
            <div className="text-xs text-slate-500">No commits yet.</div>
          ) : (
            <div className="space-y-1 max-h-40 overflow-y-auto min-h-0">
              {commitHistory.map((commit: any) => (
                <div key={commit.sha} className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-800/50 transition-colors">
                  <div className="flex-1 min-w-0">
                    <div className="text-xs text-slate-200 truncate font-medium">{commit.message}</div>
                    <div className="text-xs text-slate-400 truncate">{commit.author}  {formatTimeAgo(commit.date)}</div>
                  </div>
                  <a href={commit.html_url} target="_blank" rel="noopener noreferrer" className="ml-2 font-mono text-teal-400 text-xs hover:underline">{commit.sha.slice(0, 8)}</a>
                  <button
                    onClick={() => handleRevertToCommit(commit)}
                    className="ml-2 p-1 rounded hover:bg-red-800/60 text-red-300 hover:text-white transition-colors border border-transparent hover:border-red-700"
                    title="Revert to this commit"
                  >
                    <IoReturnUpBackOutline className="w-4 h-4" />
                  </button>
                </div>
              ))}
            </div>
          )}
          <DeleteConfirmationDialog
            isOpen={revertCommitDialogOpen}
            onClose={cancelRevertToCommit}
            onConfirm={confirmRevertToCommit}
            loading={isRevertingCommit}
            title="Revert to this commit?"
            message={commitToRevert ? `This will restore your project to the state it was in at commit: \"${commitToRevert.message}\". All current changes will be lost. Are you sure you want to continue?` : ''}
            confirmText={isRevertingCommit ? 'Reverting...' : 'Revert'}
            cancelText="Cancel"
          />
        </div>
      )}
    </div>
  )
}