import { useStore } from '@nanostores/react'; import { type Change, diffLines } from 'diff'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { List, type RowComponentProps } from 'react-window'; import { createHighlighter } from 'shiki'; import '~/styles/diff-view.css'; import { webBuilderStore } from '~/.client/stores/web-builder'; import { LRUCache } from '~/.client/utils/lru-cache'; import { formatCode, normalizeContent } from '~/.client/utils/prettier'; import { themeStore } from '~/stores/theme'; const highlightCache = new LRUCache(1000); const formatCache = new LRUCache(100); const diffCache = new LRUCache>(50); interface CodeComparisonProps { beforeCode?: string; afterCode: string; language: string; pageName: string; lightTheme: string; darkTheme: string; } interface DiffBlock { lineNumber: number; content: string; type: 'added' | 'removed' | 'unchanged'; correspondingLine?: number; charChanges?: Array<{ value: string; type: 'added' | 'removed' | 'unchanged'; }>; } interface FullscreenButtonProps { onClick: () => void; isFullscreen: boolean; } const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => ( )); const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => { if (!isFullscreen) { return <>{children}; } return (
{children}
); }); const processChanges = (beforeCode?: string, afterCode?: string) => { try { const normalizeContent = (content?: string): string[] => { if (!content) { return []; } return content .replace(/\r\n/g, '\n') .split('\n') .map((line) => line.trimEnd()); }; const beforeLines = normalizeContent(beforeCode); const afterLines = normalizeContent(afterCode); if (beforeLines.length === afterLines.length) { let isEqual = true; for (let idx = 0; idx < beforeLines.length; idx++) { if (beforeLines[idx] !== afterLines[idx]) { isEqual = false; break; } } if (isEqual) { return { beforeLines, afterLines, hasChanges: false, lineChanges: { before: new Set(), after: new Set() }, unifiedBlocks: [], }; } } const lineChanges = { before: new Set(), after: new Set(), }; const unifiedBlocks: DiffBlock[] = []; let i = 0, j = 0; while (i < beforeLines.length || j < afterLines.length) { if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) { unifiedBlocks.push({ lineNumber: j, content: afterLines[j], type: 'unchanged', correspondingLine: i, }); i++; j++; } else { let matchFound = false; const lookAhead = 3; for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) { if (beforeLines[i + k] === afterLines[j]) { for (let l = 0; l < k; l++) { lineChanges.before.add(i + l); unifiedBlocks.push({ lineNumber: i + l, content: beforeLines[i + l], type: 'removed', correspondingLine: j, charChanges: [{ value: beforeLines[i + l], type: 'removed' }], }); } i += k; matchFound = true; break; } else if (beforeLines[i] === afterLines[j + k]) { for (let l = 0; l < k; l++) { lineChanges.after.add(j + l); unifiedBlocks.push({ lineNumber: j + l, content: afterLines[j + l], type: 'added', correspondingLine: i, charChanges: [{ value: afterLines[j + l], type: 'added' }], }); } j += k; matchFound = true; break; } } if (!matchFound) { if (i < beforeLines.length && j < afterLines.length) { const beforeLine = beforeLines[i]; const afterLine = afterLines[j]; let prefixLength = 0; while ( prefixLength < beforeLine.length && prefixLength < afterLine.length && beforeLine[prefixLength] === afterLine[prefixLength] ) { prefixLength++; } let suffixLength = 0; while ( suffixLength < beforeLine.length - prefixLength && suffixLength < afterLine.length - prefixLength && beforeLine[beforeLine.length - 1 - suffixLength] === afterLine[afterLine.length - 1 - suffixLength] ) { suffixLength++; } const prefix = beforeLine.slice(0, prefixLength); const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength); const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength); const suffix = beforeLine.slice(beforeLine.length - suffixLength); if (beforeMiddle || afterMiddle) { if (beforeMiddle) { lineChanges.before.add(i); unifiedBlocks.push({ lineNumber: i, content: beforeLine, type: 'removed', correspondingLine: j, charChanges: [ { value: prefix, type: 'unchanged' }, { value: beforeMiddle, type: 'removed' }, { value: suffix, type: 'unchanged' }, ], }); i++; } if (afterMiddle) { lineChanges.after.add(j); unifiedBlocks.push({ lineNumber: j, content: afterLine, type: 'added', correspondingLine: i - 1, charChanges: [ { value: prefix, type: 'unchanged' }, { value: afterMiddle, type: 'added' }, { value: suffix, type: 'unchanged' }, ], }); j++; } } else { if (i < beforeLines.length) { lineChanges.before.add(i); unifiedBlocks.push({ lineNumber: i, content: beforeLines[i], type: 'removed', correspondingLine: j, charChanges: [{ value: beforeLines[i], type: 'removed' }], }); i++; } if (j < afterLines.length) { lineChanges.after.add(j); unifiedBlocks.push({ lineNumber: j, content: afterLines[j], type: 'added', correspondingLine: i - 1, charChanges: [{ value: afterLines[j], type: 'added' }], }); j++; } } } else { // Handle remaining lines if (i < beforeLines.length) { lineChanges.before.add(i); unifiedBlocks.push({ lineNumber: i, content: beforeLines[i], type: 'removed', correspondingLine: j, charChanges: [{ value: beforeLines[i], type: 'removed' }], }); i++; } if (j < afterLines.length) { lineChanges.after.add(j); unifiedBlocks.push({ lineNumber: j, content: afterLines[j], type: 'added', correspondingLine: i - 1, charChanges: [{ value: afterLines[j], type: 'added' }], }); j++; } } } } } // Sort blocks by line number const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber); return { beforeLines, afterLines, hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0, lineChanges, unifiedBlocks: processedBlocks, }; } catch (error) { console.error('Error processing changes:', error); return { beforeLines: [], afterLines: [], hasChanges: false, lineChanges: { before: new Set(), after: new Set() }, unifiedBlocks: [], error: true, }; } }; const lineNumberStyles = 'w-9 shrink-0 pl-2 py-1 text-left font-mono text-upage-elements-textTertiary border-r border-upage-elements-borderColor bg-upage-elements-background-depth-1'; const lineContentStyles = 'px-1 py-1 font-mono whitespace-pre flex-1 text-upage-elements-textPrimary'; const diffPanelStyles = 'h-full overflow-auto diff-panel-content'; // Updated color styles for better consistency const diffLineStyles = { added: 'bg-green-50/30 dark:bg-green-500/5 border-l-4 border-green-500', removed: 'bg-red-50/30 dark:bg-red-500/5 border-l-4 border-red-500', unchanged: '', }; const changeColorStyles = { added: 'text-green-800 dark:text-green-400 bg-green-500/35 dark:bg-green-500/45 px-0.5 rounded font-medium', removed: 'text-red-800 dark:text-red-400 bg-red-500/35 dark:bg-red-500/45 px-0.5 rounded font-medium', unchanged: 'text-upage-elements-textPrimary', }; const renderContentWarning = () => (

Error processing page

Could not generate diff preview

); const NoChangesView = memo( ({ beforeCode, language, highlighter, theme, }: { beforeCode?: string; language: string; highlighter: any; theme: string; }) => { const codeBlocks = useMemo(() => { if (!beforeCode) { return []; } return beforeCode.split('\n').map((line, index) => ({ lineNumber: index, content: line, type: 'unchanged' as const, correspondingLine: index, })); }, [beforeCode]); const Row = useCallback( ({ index, style, codeBlocks, highlighter, language, theme, }: RowComponentProps<{ codeBlocks: DiffBlock[]; highlighter: any; language: string; theme: string; }>) => { const block = codeBlocks[index]; return (
); }, [], ); return (

页面内容相同

两个版本完全相同

当前内容
{codeBlocks.length > 0 ? ( ) : (
无内容
)}
); }, ); const simpleHash = (str: string): string => { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash.toString(36); }; const useProcessChanges = (beforeCode?: string, afterCode?: string) => { return useMemo(() => { if (!beforeCode && !afterCode) { return processChanges(beforeCode, afterCode); } const beforeHash = beforeCode ? `${simpleHash(beforeCode)}:${beforeCode.length}` : 'empty'; const afterHash = afterCode ? `${simpleHash(afterCode)}:${afterCode.length}` : 'empty'; const cacheKey = `${beforeHash}:${afterHash}`; if (diffCache.has(cacheKey)) { return diffCache.get(cacheKey)!; } const result = processChanges(beforeCode, afterCode); diffCache.set(cacheKey, result); return result; }, [beforeCode, afterCode]); }; const getHighlightedContent = ( content: string, language: string, currentTheme: string, highlighter: any, type: 'added' | 'removed' | 'unchanged', charChanges?: Array<{ value: string; type: 'added' | 'removed' | 'unchanged' }>, ): string | null => { if (!highlighter) { return null; } if (type === 'unchanged' || !charChanges) { const cacheKey = `${content}:${language}:${currentTheme}`; if (highlightCache.has(cacheKey)) { return highlightCache.get(cacheKey)!; } const highlighted = highlighter .codeToHtml(content, { lang: language, theme: currentTheme }) .replace(/<\/?pre[^>]*>/g, '') .replace(/<\/?code[^>]*>/g, ''); highlightCache.set(cacheKey, highlighted); return highlighted; } const fragments: string[] = []; for (const change of charChanges) { const changeClass = changeColorStyles[change.type]; const cacheKey = `${change.value}:${language}:${currentTheme}:${change.type}`; let highlighted; if (highlightCache.has(cacheKey)) { highlighted = highlightCache.get(cacheKey)!; } else { highlighted = highlighter .codeToHtml(change.value, { lang: language, theme: currentTheme }) .replace(/<\/?pre[^>]*>/g, '') .replace(/<\/?code[^>]*>/g, ''); highlightCache.set(cacheKey, highlighted); } fragments.push(`${highlighted}`); } return fragments.join(''); }; const CodeLine = memo( ({ lineNumber, content, type, highlighter, language, block, theme, }: { lineNumber: number; content: string; type: 'added' | 'removed' | 'unchanged'; highlighter: any; language: string; block: DiffBlock; theme: string; }) => { const bgColor = diffLineStyles[type]; const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light'; const highlightedContent = useMemo( () => getHighlightedContent(content, language, currentTheme, highlighter, type, block.charChanges), [content, language, currentTheme, highlighter, type, block.charChanges], ); const renderContent = () => { if (highlightedContent) { return ; } if (type === 'unchanged' || !block.charChanges) { return {content}; } return ( <> {block.charChanges.map((change, index) => { const changeClass = changeColorStyles[change.type]; return ( {change.value} ); })} ); }; return (
{lineNumber + 1}
{type === 'added' && +} {type === 'removed' && -} {type === 'unchanged' && ' '} {renderContent()}
); }, ); // 显示文件信息 const PageInfo = memo( ({ pageName, hasChanges, onToggleFullscreen, isFullscreen, beforeCode, afterCode, }: { pageName: string; hasChanges: boolean; onToggleFullscreen: () => void; isFullscreen: boolean; beforeCode?: string; afterCode: string; }) => { const { additions, deletions } = useMemo(() => { if (!hasChanges) { return { additions: 0, deletions: 0 }; } if (!beforeCode || !afterCode) { return { additions: 0, deletions: 0 }; } const changes = diffLines(beforeCode, afterCode, { newlineIsToken: false, ignoreWhitespace: true, }); return changes.reduce( (acc: { additions: number; deletions: number }, change: Change) => { if (change.added) { acc.additions += change.value.split('\n').length; } if (change.removed) { acc.deletions += change.value.split('\n').length; } return acc; }, { additions: 0, deletions: 0 }, ); }, [hasChanges, beforeCode, afterCode]); const showStats = additions > 0 || deletions > 0; return (
{pageName} {hasChanges ? ( <> {showStats && (
{additions > 0 && +{additions}} {deletions > 0 && -{deletions}}
)} 已修改 {new Date().toLocaleTimeString()} ) : ( 无变化 )}
); }, ); const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }: CodeComparisonProps) => { const [isFullscreen, setIsFullscreen] = useState(false); const [highlighter, setHighlighter] = useState(null); const [isHighlighterLoading, setIsHighlighterLoading] = useState(false); const theme = useStore(themeStore); const toggleFullscreen = useCallback(() => { setIsFullscreen((prev) => !prev); }, []); const { unifiedBlocks, hasChanges, error } = useProcessChanges(beforeCode, afterCode); const loadHighlighter = useCallback(() => { if (!highlighter && !isHighlighterLoading && hasChanges) { setIsHighlighterLoading(true); createHighlighter({ themes: ['github-dark', 'github-light'], langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx', 'plaintext'], }) .then(setHighlighter) .finally(() => { setIsHighlighterLoading(false); }); } }, [highlighter, isHighlighterLoading, hasChanges]); useEffect(() => { if (hasChanges) { loadHighlighter(); } }, [hasChanges, loadHighlighter]); const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(600); useEffect(() => { const updateHeight = () => { if (containerRef.current) { setContainerHeight(containerRef.current.clientHeight); } }; updateHeight(); window.addEventListener('resize', updateHeight); return () => window.removeEventListener('resize', updateHeight); }, []); const Row = useCallback( ({ index, style, unifiedBlocks, highlighter, language, theme, }: RowComponentProps<{ unifiedBlocks: DiffBlock[]; highlighter: any; language: string; theme: string; }>) => { const block = unifiedBlocks[index]; return (
); }, [], ); if (error) { return renderContentWarning(); } return (
{hasChanges ? ( ) : ( )}
); }); export const DiffView = memo(() => { const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); const selectedPage = useStore(webBuilderStore.pagesStore.activePage); const currentView = useStore(webBuilderStore.currentView); const [shouldProcess, setShouldProcess] = useState(false); const [compareVersionContent, setCompareVersionContent] = useState(''); const [baselineVersionContent, setBaselineVersionContent] = useState(''); useEffect(() => { if (currentView === 'diff') { setShouldProcess(true); } }, [currentView]); useEffect(() => { if (currentView !== 'diff' || !selectedPage || !shouldProcess) { return; } const history = pageHistory[selectedPage]; // 使用最新版本 const lastVersion = history?.versions.find((version) => version.version === history.latestVersion); const lastVersionContent = normalizeContent(lastVersion?.content); if (lastVersionContent) { const lastVersionCacheKey = `${selectedPage}:${history?.latestVersion}`; if (formatCache.has(lastVersionCacheKey)) { setCompareVersionContent(formatCache.get(lastVersionCacheKey)!); } else { formatCode(lastVersionContent, { parser: 'html' }) .then((formatted) => { formatCache.set(lastVersionCacheKey, formatted); setCompareVersionContent(formatted); }) .catch((error) => { console.error('格式化当前内容失败:', error); setCompareVersionContent(lastVersionContent); }); } } // 获取上一次由聊天所触发的历史版本(不含本次版本),或者第一个初始化版本 const autoSaveHistories = history?.versions.filter((version) => version.changeSource === 'auto-save') || []; let lastTimeChatVersionVersion = 0; if (autoSaveHistories.length > 1) { lastTimeChatVersionVersion = autoSaveHistories[autoSaveHistories.length - 2].version; } else { const firstHistory = history?.versions[0]; if (firstHistory && firstHistory.changeSource === 'initial') { lastTimeChatVersionVersion = firstHistory.version; } else { lastTimeChatVersionVersion = 0; } } const lastTimeChatVersion = history?.versions.find((version) => version.version === lastTimeChatVersionVersion); const lastTimeChatVersionContent = normalizeContent(lastTimeChatVersion?.content); if (lastTimeChatVersionContent) { const lastTimeChatVersionCacheKey = `${selectedPage}:${lastTimeChatVersionVersion}`; if (formatCache.has(lastTimeChatVersionCacheKey)) { setBaselineVersionContent(formatCache.get(lastTimeChatVersionCacheKey)!); } else { formatCode(lastTimeChatVersionContent, { parser: 'html' }) .then((formatted) => { formatCache.set(lastTimeChatVersionCacheKey, formatted); setBaselineVersionContent(formatted); }) .catch((error) => { console.error('格式化原始内容失败:', error); setBaselineVersionContent(lastTimeChatVersionContent); }); } } setShouldProcess(false); }, [selectedPage, currentView, shouldProcess, pageHistory]); if (!selectedPage) { return (
选择一个页面来查看差异
); } try { return (
); } catch (error) { console.error('DiffView render error:', error); return (

渲染差异视图失败

); } });