'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>
)
}