import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import { styled } from '@mui/material/styles'; import Tab from '@mui/material/Tab'; import Tabs from '@mui/material/Tabs'; import Typography from '@mui/material/Typography'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; const Panel = styled(Box)` padding: 0; height: 100%; overflow: hidden; display: flex; flex-direction: column; `; const TabsWrapper = styled(Box)` border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; padding: 0 16px; `; const ContentWrapper = styled(Box)` flex: 1; overflow: auto; padding: 16px; `; const EmptyState = styled(Box)` display: flex; align-items: center; justify-content: center; height: 100%; color: ${({ theme }) => theme.palette.text.secondary}; `; const DiffContainer = styled(Box)` font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; white-space: pre-wrap; word-break: break-all; background-color: ${({ theme }) => theme.palette.mode === 'dark' ? '#0d1117' : '#f6f8fa'}; border-radius: 4px; overflow-x: auto; border: 1px solid ${({ theme }) => theme.palette.divider}; `; const FileContentContainer = styled(Box)` font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 0.875rem; white-space: pre; background-color: ${({ theme }) => theme.palette.mode === 'dark' ? '#0d1117' : '#f6f8fa'}; padding: 12px; border-radius: 4px; overflow: auto; border: 1px solid ${({ theme }) => theme.palette.divider}; color: ${({ theme }) => theme.palette.text.primary}; `; const DiffLine = styled('div')<{ $type: 'added' | 'removed' | 'context' | 'header' }>` ${({ $type, theme }) => { const isDark = theme.palette.mode === 'dark'; switch ($type) { case 'added': return isDark ? ` background-color: rgba(46, 160, 67, 0.2); color: #7ee787; border-left: 3px solid #3fb950; ` : ` background-color: rgba(46, 160, 67, 0.15); color: #116329; border-left: 3px solid #2da44e; `; case 'removed': return isDark ? ` background-color: rgba(248, 81, 73, 0.2); color: #ffa198; border-left: 3px solid #f85149; ` : ` background-color: rgba(248, 81, 73, 0.15); color: #82071e; border-left: 3px solid #cf222e; `; case 'header': return isDark ? ` background-color: rgba(56, 139, 253, 0.15); color: #79c0ff; font-weight: 600; border-left: 3px solid #388bfd; ` : ` background-color: rgba(56, 139, 253, 0.15); color: #0969da; font-weight: 600; border-left: 3px solid #0969da; `; default: return ` color: ${theme.palette.text.secondary}; border-left: 3px solid transparent; `; } }} padding: 2px 4px 2px 8px; min-height: 20px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; line-height: 1.5; `; const ImageComparisonWrapper = styled(Box)` display: flex; flex-direction: column; gap: 16px; `; const ImageBox = styled(Box)` display: flex; flex-direction: column; gap: 8px; `; const ImagePreview = styled('img')` max-width: 100%; height: auto; border: 1px solid ${({ theme }) => theme.palette.divider}; border-radius: 4px; `; interface IFileDiffPanelProps { commitHash: string; filePath: string | null; onDiscardSuccess?: () => void; } export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileDiffPanelProps): React.JSX.Element { const { t } = useTranslation(); const [diff, setDiff] = useState(''); const [fileContent, setFileContent] = useState(''); const [loading, setLoading] = useState(false); const [isImage, setIsImage] = useState(false); const [currentTab, setCurrentTab] = useState<'diff' | 'content' | 'actions'>('diff'); const [imageDataUrl, setImageDataUrl] = useState(''); const [previousImageDataUrl, setPreviousImageDataUrl] = useState(''); const [imageError, setImageError] = useState(''); const [isDiffTruncated, setIsDiffTruncated] = useState(false); const [isContentTruncated, setIsContentTruncated] = useState(false); const [isLoadingFullDiff, setIsLoadingFullDiff] = useState(false); const [isLoadingFullContent, setIsLoadingFullContent] = useState(false); const getWorkspace = async () => { const meta = window.meta(); const workspaceID = (meta as { workspaceID?: string }).workspaceID; if (!workspaceID) return null; const workspace = await window.service.workspace.get(workspaceID); if (!workspace || !('wikiFolderLocation' in workspace)) return null; return workspace; }; const loadFullDiff = async () => { if (!filePath || !commitHash) return; setIsLoadingFullDiff(true); try { const workspace = await getWorkspace(); if (!workspace) return; // Load full diff without limits (pass very large values) const fileDiffResult = await window.service.git.getFileDiff(workspace.wikiFolderLocation, commitHash, filePath, 50000, 1000000); setDiff(fileDiffResult.content); setIsDiffTruncated(fileDiffResult.isTruncated); } catch (error) { console.error('Failed to load full diff:', error); } finally { setIsLoadingFullDiff(false); } }; const canDiscardChanges = !commitHash; const extensionMatch = filePath ? filePath.match(/\.([^.\\/]+)$/) : null; const fileExtension = extensionMatch?.[1] ?? null; const handleDiscardChanges = async () => { if (!filePath || !canDiscardChanges) return; const workspace = await getWorkspace(); if (!workspace) return; try { await window.service.git.discardFileChanges(workspace.wikiFolderLocation, filePath); // Clear selection and trigger refresh onDiscardSuccess?.(); } catch (error) { console.error('Failed to discard changes:', error); // TODO: Show error message } }; const handleIgnoreFile = async () => { if (!filePath) return; const workspace = await getWorkspace(); if (!workspace) return; try { await window.service.git.addToGitignore(workspace.wikiFolderLocation, filePath); // TODO: Show success message } catch (error) { console.error('Failed to add to .gitignore:', error); // TODO: Show error message } }; const handleIgnoreExtension = async () => { if (!filePath || !fileExtension) return; const workspace = await getWorkspace(); if (!workspace) return; try { await window.service.git.addToGitignore(workspace.wikiFolderLocation, `*.${fileExtension}`); // TODO: Show success message } catch (error) { console.error('Failed to add extension to .gitignore:', error); // TODO: Show error message } }; const handleCopyPath = async () => { if (!filePath) return; const workspace = await getWorkspace(); if (!workspace) return; const fullPath = `${workspace.wikiFolderLocation}/${filePath}`; await navigator.clipboard.writeText(fullPath); // TODO: Show success message }; const handleCopyRelativePath = async () => { if (!filePath) return; await navigator.clipboard.writeText(filePath); // TODO: Show success message }; const handleShowInExplorer = async () => { if (!filePath) return; const workspace = await getWorkspace(); if (!workspace) return; const fullPath = `${workspace.wikiFolderLocation}/${filePath}`; await window.service.native.openPath(fullPath, true); }; const handleOpenInEditor = async () => { if (!filePath) return; const workspace = await getWorkspace(); if (!workspace) return; const fullPath = `${workspace.wikiFolderLocation}/${filePath}`; await window.service.native.openInEditor(fullPath); }; const handleOpenWithDefault = async () => { if (!filePath) return; const workspace = await getWorkspace(); if (!workspace) return; const fullPath = `${workspace.wikiFolderLocation}/${filePath}`; await window.service.native.openPath(fullPath, false); }; const loadFullContent = async () => { if (!filePath || !commitHash) return; setIsLoadingFullContent(true); try { const meta = window.meta(); const workspaceID = (meta as { workspaceID?: string }).workspaceID; if (!workspaceID) return; const workspace = await window.service.workspace.get(workspaceID); if (!workspace || !('wikiFolderLocation' in workspace)) return; // Load full content without limits (pass very large values) const fileContentResult = await window.service.git.getFileContent(workspace.wikiFolderLocation, commitHash, filePath, 50000, 1000000); setFileContent(fileContentResult.content); setIsContentTruncated(fileContentResult.isTruncated); } catch (error) { console.error('Failed to load full content:', error); } finally { setIsLoadingFullContent(false); } }; useEffect(() => { // Note: commitHash can be empty string for uncommitted changes, which is valid if (!filePath || commitHash === null || commitHash === undefined) { setDiff(''); setFileContent(''); setImageDataUrl(''); setPreviousImageDataUrl(''); setImageError(''); setIsDiffTruncated(false); setIsContentTruncated(false); return; } const loadFileData = async () => { setLoading(true); try { const workspace = await getWorkspace(); if (!workspace) return; // Check if file is an image or binary file const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']; const isImageExtension = imageExtensions.some((extension) => filePath.toLowerCase().endsWith(extension)); // First, try to get the diff to check if it's a binary file const fileDiffResult = await window.service.git.getFileDiff(workspace.wikiFolderLocation, commitHash, filePath); const isBinaryFile = fileDiffResult.content.includes('Binary files') || fileDiffResult.content.includes('differ'); // Handle binary files if (isBinaryFile) { if (isImageExtension) { // Display image comparison for image files setIsImage(true); setImageError(''); try { const imageComparison = await window.service.git.getImageComparison(workspace.wikiFolderLocation, commitHash, filePath); setImageDataUrl(imageComparison.current || ''); setPreviousImageDataUrl(imageComparison.previous || ''); } catch (error) { console.error('Failed to load image:', error); const errorMessage = error instanceof Error ? error.message : String(error); setImageError(errorMessage); } } else { // Display binary file message for non-image binary files setIsImage(false); setDiff(t('GitLog.BinaryFileCannotDisplay')); setFileContent(''); } } else { // For text files, get both diff and content setIsImage(false); const fileContentResult = await window.service.git.getFileContent(workspace.wikiFolderLocation, commitHash, filePath); setDiff(fileDiffResult.content); setFileContent(fileContentResult.content); setIsDiffTruncated(fileDiffResult.isTruncated); setIsContentTruncated(fileContentResult.isTruncated); } } catch (error) { console.error('Failed to load file data:', error); setDiff(t('GitLog.FailedToLoadDiff')); setFileContent(''); } finally { setLoading(false); } }; void loadFileData(); }, [commitHash, filePath, t]); if (!filePath) { return ( {t('GitLog.SelectFileToViewDiff')} ); } if (loading) { return ( ); } // Render action buttons panel const renderActionsPanel = () => ( {canDiscardChanges && ( )} {fileExtension && ( )} ); const renderTextPanelContent = () => { if (currentTab === 'actions') { return renderActionsPanel(); } if (currentTab === 'diff') { return ( <> {isDiffTruncated && ( )} {renderDiffContent()} ); } return ( <> {isContentTruncated && ( )} {fileContent} ); }; if (isImage) { return ( {filePath} { setCurrentTab(newValue); }} > {currentTab === 'diff' && ( {/* Show previous image if available */} {previousImageDataUrl && ( {t('GitLog.PreviousVersion')} { setPreviousImageDataUrl(''); }} /> )} {/* Show current image */} {previousImageDataUrl ? t('GitLog.CurrentVersion') : t('GitLog.ImageInCommit')} {imageDataUrl ? ( { setImageDataUrl(''); setImageError('Failed to render image'); }} /> ) : imageError ? ( {t('GitLog.ImageNotAvailable')} {imageError} ) : ( {previousImageDataUrl ? t('GitLog.NewImage') : t('GitLog.ImageNotAvailable')} )} )} {currentTab === 'content' && imageDataUrl && ( { setImageDataUrl(''); setImageError('Failed to render image'); }} /> )} {currentTab === 'actions' && renderActionsPanel()} ); } // Parse diff into lines with colors const renderDiffContent = () => { const lines = diff.split('\n'); const renderDiffLine = (line: string, index: number) => { let type: 'added' | 'removed' | 'context' | 'header' = 'context'; if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) { type = 'header'; } else if (line.startsWith('+')) { type = 'added'; } else if (line.startsWith('-')) { type = 'removed'; } return ( {line || ' '} ); }; return lines.map((line, index) => renderDiffLine(line, index)); }; return ( {filePath} { setCurrentTab(newValue); }} > {renderTextPanelContent()} ); }