diff --git a/app/.client/components/editor/Editor.tsx b/app/.client/components/editor/Editor.tsx index c22aa8a..a2f0a92 100644 --- a/app/.client/components/editor/Editor.tsx +++ b/app/.client/components/editor/Editor.tsx @@ -164,7 +164,7 @@ export const EditorStudio = memo( [onSave], ); - const handleAutoSave = useCallback(async () => { + const handleSave = useCallback(async () => { const editor = editorRef.current; if (!editor) { return; @@ -192,7 +192,7 @@ export const EditorStudio = memo( documents={documents} onLoad={handleLoad} onReady={handleEditorReady} - onSave={handleAutoSave} + onSave={handleSave} onContentChange={handleContentChange} /> ); diff --git a/app/.client/components/webbuilder/DiffView.tsx b/app/.client/components/webbuilder/DiffView.tsx index 84fe467..a2c10df 100644 --- a/app/.client/components/webbuilder/DiffView.tsx +++ b/app/.client/components/webbuilder/DiffView.tsx @@ -1,101 +1,17 @@ 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 { formatCode } from '~/.client/utils/prettier'; +import { LRUCache } from '~/.client/utils/lru-cache'; +import { formatCode, normalizeContent } from '~/.client/utils/prettier'; import { themeStore } from '~/stores/theme'; -// 高亮结果缓存,使用 Map 存储已高亮的代码行 -const highlightCache = new Map(); -// 格式化结果缓存,使用 Map 存储已格式化的代码 -const formatCache = new Map(); - -// 差异计算结果缓存 -const diffCache = new Map>(); - -interface VirtualizedListProps { - items: T[]; - renderItem: (item: T, index: number) => React.ReactNode; - itemHeight: number; - className?: string; - overscan?: number; -} - -function VirtualizedList({ items, renderItem, itemHeight, className = '', overscan = 20 }: VirtualizedListProps) { - const containerRef = useRef(null); - const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); - const [renderedItems, setRenderedItems] = useState([]); - const [isRendering, setIsRendering] = useState(false); - const totalHeight = items.length * itemHeight; - - const handleScroll = useCallback(() => { - if (!containerRef.current) { - return; - } - - const { scrollTop, clientHeight } = containerRef.current; - const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); - const end = Math.min(items.length, Math.ceil((scrollTop + clientHeight) / itemHeight) + overscan); - - setVisibleRange({ start, end }); - }, [items.length, itemHeight, overscan]); - - useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - container.addEventListener('scroll', handleScroll); - handleScroll(); - - return () => { - container.removeEventListener('scroll', handleScroll); - }; - }, [handleScroll]); - - useEffect(() => { - handleScroll(); - }, [items.length, handleScroll]); - - useEffect(() => { - if (isRendering) { - return; - } - - setIsRendering(true); - - setTimeout(() => { - const visibleItems = items.slice(visibleRange.start, visibleRange.end); - const newRenderedItems = visibleItems.map((item, index) => { - const actualIndex = visibleRange.start + index; - return ( -
- {renderItem(item, actualIndex)} -
- ); - }); - - setRenderedItems(newRenderedItems); - setIsRendering(false); - }, 0); - }, [items, visibleRange, itemHeight, renderItem, isRendering]); - - return ( -
-
{renderedItems}
-
- ); -} +const highlightCache = new LRUCache(1000); +const formatCache = new LRUCache(100); +const diffCache = new LRUCache>(50); interface CodeComparisonProps { beforeCode?: string; @@ -148,34 +64,36 @@ const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: bool const processChanges = (beforeCode?: string, afterCode?: string) => { try { - const normalizeContent = (content: string): string[] => { + const normalizeContent = (content?: string): string[] => { + if (!content) { + return []; + } return content .replace(/\r\n/g, '\n') .split('\n') .map((line) => line.trimEnd()); }; - if (!beforeCode || !afterCode) { - return { - beforeLines: [], - afterLines: [], - hasChanges: false, - lineChanges: { before: new Set(), after: new Set() }, - unifiedBlocks: [], - }; - } - const beforeLines = normalizeContent(beforeCode); const afterLines = normalizeContent(afterCode); - if (beforeLines.join('\n') === afterLines.join('\n')) { - return { - beforeLines, - afterLines, - hasChanges: false, - lineChanges: { before: new Set(), after: new Set() }, - unifiedBlocks: [], - }; + 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 = { @@ -376,20 +294,19 @@ const processChanges = (beforeCode?: string, afterCode?: string) => { 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 group-hover:bg-upage-elements-background-depth-2 text-upage-elements-textPrimary'; +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-500/10 dark:bg-green-500/20 border-l-4 border-green-500', - removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500', + 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-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20', - removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20', + 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', }; @@ -427,20 +344,36 @@ const NoChangesView = memo( })); }, [beforeCode]); - const renderCodeLine = useCallback( - (block: DiffBlock, index: number) => ( - - ), - [highlighter, language, theme], + const Row = useCallback( + ({ + index, + style, + codeBlocks, + highlighter, + language, + theme, + }: RowComponentProps<{ + codeBlocks: DiffBlock[]; + highlighter: any; + language: string; + theme: string; + }>) => { + const block = codeBlocks[index]; + return ( +
+ +
+ ); + }, + [], ); return ( @@ -456,12 +389,14 @@ const NoChangesView = memo(
{codeBlocks.length > 0 ? ( - ) : (
无内容
@@ -473,13 +408,25 @@ const NoChangesView = memo( }, ); +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) { + if (!beforeCode && !afterCode) { return processChanges(beforeCode, afterCode); } - const cacheKey = `${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)!; @@ -491,6 +438,58 @@ const useProcessChanges = (beforeCode?: string, afterCode?: string) => { }, [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, @@ -512,68 +511,13 @@ const CodeLine = memo( const bgColor = diffLineStyles[type]; const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light'; - const [isHighlighted, setIsHighlighted] = useState(false); - const [highlightedContent, setHighlightedContent] = useState(null); - - useEffect(() => { - if (!highlighter) { - return; - } - - const timeoutId = setTimeout(() => { - if (type === 'unchanged' || !block.charChanges) { - const cacheKey = `${content}:${language}:${currentTheme}`; - - if (highlightCache.has(cacheKey)) { - setHighlightedContent(highlightCache.get(cacheKey)!); - setIsHighlighted(true); - return; - } - - const highlighted = highlighter - .codeToHtml(content, { lang: language, theme: currentTheme }) - .replace(/<\/?pre[^>]*>/g, '') - .replace(/<\/?code[^>]*>/g, ''); - - highlightCache.set(cacheKey, highlighted); - setHighlightedContent(highlighted); - setIsHighlighted(true); - } else { - const fragments: string[] = []; - - for (let i = 0; i < block.charChanges!.length; i++) { - const change = block.charChanges![i]; - 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}`); - } - - setHighlightedContent(fragments.join('')); - setIsHighlighted(true); - } - }, 10); - - return () => { - clearTimeout(timeoutId); - }; - }, [content, language, currentTheme, highlighter, type, block.charChanges]); + const highlightedContent = useMemo( + () => getHighlightedContent(content, language, currentTheme, highlighter, type, block.charChanges), + [content, language, currentTheme, highlighter, type, block.charChanges], + ); const renderContent = () => { - if (isHighlighted && highlightedContent) { + if (highlightedContent) { return ; } @@ -718,20 +662,50 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language } } }, [hasChanges, loadHighlighter]); - const renderCodeLine = useCallback( - (block: DiffBlock, index: number) => ( - - ), - [highlighter, language, theme], + 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) { @@ -749,17 +723,17 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language } beforeCode={beforeCode} afterCode={afterCode} /> -
+
{hasChanges ? ( -
- -
+ ) : ( )} @@ -771,18 +745,13 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language } export const DiffView = memo(() => { const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); - const pages = useStore(webBuilderStore.pagesStore.pages); const selectedPage = useStore(webBuilderStore.pagesStore.activePage); - const currentPage = useStore(webBuilderStore.pagesStore.currentPage); const currentView = useStore(webBuilderStore.currentView); - const [formattedContent, setFormattedContent] = useState(''); - const [effectiveOriginalContent, setEffectiveOriginalContent] = useState(''); + const [shouldProcess, setShouldProcess] = useState(false); + const [compareVersionContent, setCompareVersionContent] = useState(''); + const [baselineVersionContent, setBaselineVersionContent] = useState(''); - // 存当前页面内容的键,用于检测内容是否变化缓 - const contentCacheKey = useRef(null); - - // 当视图切换到 diff 时,标记需要处理 useEffect(() => { if (currentView === 'diff') { setShouldProcess(true); @@ -790,59 +759,69 @@ export const DiffView = memo(() => { }, [currentView]); useEffect(() => { - if (!selectedPage || !currentPage || currentView !== 'diff' || !shouldProcess) { + if (currentView !== 'diff' || !selectedPage || !shouldProcess) { return; } - const page = pages[selectedPage]; - const originalContent = page && 'content' in page ? page.content : ''; - const history = pageHistory[selectedPage]; - const originalContentToFormat = history?.originalContent || originalContent; + // 使用最新版本 + const lastVersion = history?.versions.find((version) => version.version === history.latestVersion); + const lastVersionContent = normalizeContent(lastVersion?.content); - if (currentPage?.content) { - const currentContent = currentPage.content as string; - - if (formatCache.has(currentContent)) { - setFormattedContent(formatCache.get(currentContent)!); - - contentCacheKey.current = currentContent; + if (lastVersionContent) { + const lastVersionCacheKey = `${selectedPage}:${history?.latestVersion}`; + if (formatCache.has(lastVersionCacheKey)) { + setCompareVersionContent(formatCache.get(lastVersionCacheKey)!); } else { - formatCode(currentContent, { parser: 'html' }) + formatCode(lastVersionContent, { parser: 'html' }) .then((formatted) => { - formatCache.set(currentContent, formatted); - setFormattedContent(formatted); - - contentCacheKey.current = currentContent; + formatCache.set(lastVersionCacheKey, formatted); + setCompareVersionContent(formatted); }) .catch((error) => { console.error('格式化当前内容失败:', error); - setFormattedContent(currentContent); - contentCacheKey.current = currentContent; + setCompareVersionContent(lastVersionContent); }); } } - if (originalContentToFormat) { - if (formatCache.has(originalContentToFormat)) { - setEffectiveOriginalContent(formatCache.get(originalContentToFormat)!); + // 获取上一次由聊天所触发的历史版本(不含本次版本),或者第一个初始化版本 + 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 { - formatCode(originalContentToFormat, { parser: 'html' }) + 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(originalContentToFormat, formatted); - setEffectiveOriginalContent(formatted); + formatCache.set(lastTimeChatVersionCacheKey, formatted); + setBaselineVersionContent(formatted); }) .catch((error) => { console.error('格式化原始内容失败:', error); - setEffectiveOriginalContent(originalContentToFormat); + setBaselineVersionContent(lastTimeChatVersionContent); }); } } setShouldProcess(false); - }, [currentPage?.content, selectedPage, currentView, shouldProcess, pageHistory, pages]); + }, [selectedPage, currentView, shouldProcess, pageHistory]); - if (!selectedPage || !currentPage) { + if (!selectedPage) { return (
选择一个页面来查看差异 @@ -854,8 +833,8 @@ export const DiffView = memo(() => { return (
void; +} + +export const PageModifiedDropdown = memo(({ onSelectPage }: PageModifiedDropdownProps) => { + const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); + const currentSelectedPage = useStore(webBuilderStore.editorStore.selectedDocument); + const modifiedPages = Object.entries(pageHistory); + const hasChanges = modifiedPages.length > 0; + const [searchQuery, setSearchQuery] = useState(''); + + const filteredPages = useMemo(() => { + return modifiedPages.filter(([pageName]) => pageName.toLowerCase().includes(searchQuery.toLowerCase())); + }, [modifiedPages, searchQuery]); + + const handleSelectPage = useCallback( + (pageName: string, close: () => void) => { + // 如果是当前已选中的页面,不执行任何操作 + if (pageName === currentSelectedPage) { + return; + } + onSelectPage(pageName); + webBuilderStore.currentView.set('diff'); + // 关闭下拉菜单 + close(); + }, + [onSelectPage, currentSelectedPage], + ); + + return ( +
+ + {({ open, close }: { open: boolean; close: () => void }) => ( + <> + + 更改页面 + {hasChanges && ( + + {modifiedPages.length} + + )} + + + +
+
+ setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-1 border border-upage-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50" + /> +
+
+
+
+ +
+ {filteredPages.length > 0 ? ( + filteredPages.map(([pageName, history]) => { + const isActive = pageName === currentSelectedPage; + + return ( + + ); + }) + ) : ( +
+
+
+
+

+ {searchQuery ? '没有匹配的页面' : '没有修改的页面'} +

+

+ {searchQuery ? '尝试其他搜索' : '更改将在此处显示'} +

+
+ )} +
+
+ + + + )} + +
+ ); +}); + +PageModifiedDropdown.displayName = 'PageModifiedDropdown'; diff --git a/app/.client/components/webbuilder/WebBuilder.client.tsx b/app/.client/components/webbuilder/WebBuilder.client.tsx index 5d8b979..04983f7 100644 --- a/app/.client/components/webbuilder/WebBuilder.client.tsx +++ b/app/.client/components/webbuilder/WebBuilder.client.tsx @@ -1,7 +1,5 @@ -import { Popover, Transition } from '@headlessui/react'; import { useStore } from '@nanostores/react'; import classNames from 'classnames'; -import { type Change, diffLines } from 'diff'; import { type HTMLMotionProps, motion, type Variants } from 'framer-motion'; import { memo, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -26,6 +24,7 @@ import type { PageMap } from '~/types/pages'; import { renderLogger } from '~/utils/logger'; import { DiffView } from './DiffView'; import { EditorPanel } from './EditorPanel'; +import { PageModifiedDropdown } from './PageModifiedDropdown'; import { Preview } from './Preview'; const viewTransition = { ease: cubicEasingFn }; @@ -62,154 +61,6 @@ const workbenchVariants = { }, } satisfies Variants; -const PageModifiedDropdown = memo(({ onSelectPage }: { onSelectPage: (pageName: string) => void }) => { - const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory); - const modifiedPages = Object.entries(pageHistory); - const hasChanges = modifiedPages.length > 0; - const [searchQuery, setSearchQuery] = useState(''); - - const filteredPages = useMemo(() => { - return modifiedPages.filter(([pageName]) => pageName.toLowerCase().includes(searchQuery.toLowerCase())); - }, [modifiedPages, searchQuery]); - - return ( -
- - {({ open }: { open: boolean }) => ( - <> - - 更改页面 - {hasChanges && ( - - {modifiedPages.length} - - )} - - - -
-
- setSearchQuery(e.target.value)} - className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-1 border border-upage-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50" - /> -
-
-
-
- -
- {filteredPages.length > 0 ? ( - filteredPages.map(([pageName, history]) => { - return ( - - ); - }) - ) : ( -
-
-
-
-

- {searchQuery ? '没有匹配的页面' : '没有修改的页面'} -

-

- {searchQuery ? '尝试其他搜索' : '更改将在此处显示'} -

-
- )} -
-
- - - - )} - -
- ); -}); - export const WebBuilder = memo(() => { renderLogger.trace('webBuilder'); @@ -251,7 +102,7 @@ export const WebBuilder = memo(() => { webBuilderStore.setSelectedPage(pageName); }, []); - const onAutoPageSave = useCallback(() => { + const onPageSave = useCallback(() => { if (isStreaming) { return; } @@ -259,7 +110,7 @@ export const WebBuilder = memo(() => { }, [isStreaming]); const doPageSave = useCallback(() => { - webBuilderStore.saveAllPages().catch(() => { + webBuilderStore.saveAllPages('user').catch(() => { toast.error('文件内容更新失败'); }); const currentMessageId = webBuilderStore.chatStore.currentMessageId.get(); @@ -377,7 +228,7 @@ export const WebBuilder = memo(() => { unsavedPages={unsavedPages} onPageSelect={onPageSelect} onEditorChange={onEditorChange} - onPageSave={onAutoPageSave} + onPageSave={onPageSave} onPageReset={onPageReset} onLoad={onLoad} onReady={onReady} diff --git a/app/.client/hooks/useProject.ts b/app/.client/hooks/useProject.ts index 6b8dfc8..6e79a69 100644 --- a/app/.client/hooks/useProject.ts +++ b/app/.client/hooks/useProject.ts @@ -25,7 +25,7 @@ export function useProject() { } // 保存之前,先保存所有页面 - await webBuilderStore.saveAllPages(); + await webBuilderStore.saveAllPages('auto-save'); const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined); const projectSections = Object.values(webBuilderStore.pagesStore.sections.get()) .filter((section) => section !== undefined) diff --git a/app/.client/stores/pages.ts b/app/.client/stores/pages.ts index f99ae67..5310632 100644 --- a/app/.client/stores/pages.ts +++ b/app/.client/stores/pages.ts @@ -3,7 +3,8 @@ import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostore import { type EditorBridge, type EventPayload, editorBridge } from '~/.client/bridge'; import { computePageModifications, diffPages } from '~/.client/utils/diff'; import { isValidContent } from '~/.client/utils/html-parse'; -import type { Page, PageHistory } from '~/types/actions'; +import { normalizeContent } from '~/.client/utils/prettier'; +import type { ChangeSource, Page, PageHistory } from '~/types/actions'; import type { PageMap, PageSection, SectionMap } from '~/types/pages'; import { createScopedLogger } from '~/utils/logger'; @@ -156,69 +157,67 @@ export class PagesStore { this.modifiedPages.clear(); } - async savePage(pageName: string, content: string) { + async savePage(pageName: string, content: string, changeSource: ChangeSource) { const page = this.getPage(pageName); if (!page) { return false; } - // 保存上一次的页面内容 - this.savePageHistory(pageName, content); try { this.pages.setKey(pageName, { ...page, content }); logger.info('Page updated'); + // 保存上一次的页面内容 + this.savePageHistory(pageName, content, changeSource); } catch (error) { logger.error('Failed to update page content\n\n', error); - throw error; } } - async savePageHistory(pageName: string, newContent: string) { + async savePageHistory(pageName: string, newContent: string, changeSource: ChangeSource) { const page = this.getPage(pageName); if (!page) { return; } - const pageHistory = this.pageHistory.get()[pageName]; // 如果不存在历史记录,则创建一个新的历史记录 - const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim(); - const originalContent = pageHistory?.originalContent || page.content!; + if (!pageHistory) { + const newHistory: PageHistory = { + originalContent: newContent, + latestVersion: 1, + latestModified: Date.now(), + versions: [ + { + version: 1, + timestamp: Date.now(), + content: newContent, + changeSource, + }, + ], + }; + this.pageHistory.setKey(pageName, newHistory); + return; + } + + const lastVersion = pageHistory.versions.find((version) => version.version === pageHistory.latestVersion); + if (!lastVersion) { + return; + } + // 如果存在历史记录,则检查自上次版本以来是否有实际变化 + const originalContent = lastVersion?.content || page.content!; if (!originalContent) { return; } - const normalizedOriginalContent = (pageHistory?.originalContent || page.content!).replace(/\r\n/g, '\n').trim(); - if (!pageHistory) { - if (normalizedCurrentContent !== normalizedOriginalContent) { - const newChanges = diffLines(page.content!, newContent); - const newHistory: PageHistory = { - originalContent: page.content!, - lastModified: Date.now(), - changes: newChanges, - versions: [ - { - timestamp: Date.now(), - content: newContent, - }, - ], - changeSource: 'auto-save', - }; - this.pageHistory.setKey(pageName, newHistory); - } - return; - } - - // 如果存在历史记录,则检查自上次版本以来是否有实际变化 - const lastVersion = pageHistory.versions[pageHistory.versions.length - 1]; - const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim(); + const normalizedCurrentContent = normalizeContent(newContent); + const normalizedLastContent = normalizeContent(lastVersion?.content); if (normalizedCurrentContent === normalizedLastContent) { return; } - const unifiedDiff = diffPages(pageName, pageHistory.originalContent, newContent); + const unifiedDiff = diffPages(pageName, lastVersion.content, newContent); if (!unifiedDiff) { return; } - const newChanges = diffLines(pageHistory.originalContent, newContent); + const newChanges = diffLines(lastVersion.content, newContent); // 检查是否有显著变化 const hasSignificantChanges = newChanges.some( @@ -230,16 +229,17 @@ export class PagesStore { const newHistory: PageHistory = { originalContent: pageHistory.originalContent, - lastModified: Date.now(), - changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças + latestVersion: pageHistory.latestVersion + 1, + latestModified: Date.now(), versions: [ ...pageHistory.versions, { + version: pageHistory.latestVersion + 1, timestamp: Date.now(), content: newContent, + changeSource, }, - ].slice(-10), // 只保留最近的 10 个版本 - changeSource: 'auto-save', + ].slice(-20), // 只保留最近的 20 个版本 }; this.pageHistory.setKey(pageName, newHistory); diff --git a/app/.client/stores/web-builder.ts b/app/.client/stores/web-builder.ts index e1ec772..3bf0e3e 100644 --- a/app/.client/stores/web-builder.ts +++ b/app/.client/stores/web-builder.ts @@ -4,7 +4,7 @@ import JSZip from 'jszip'; import { atom, type WritableAtom } from 'nanostores'; import { toast } from 'sonner'; import { formatFile } from '~/.client/utils/prettier'; -import type { Page } from '~/types/actions'; +import type { ChangeSource, Page } from '~/types/actions'; import type { PageMap } from '~/types/pages'; import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils'; import { createScopedLogger } from '~/utils/logger'; @@ -66,11 +66,16 @@ export class WebBuilderStore { }); } + /** + * 手动设置页面数据,通常用于初始化页面数据。 + * @param pages 页面数据 + */ setPages(pages: PageMap) { this.editorStore.setDocuments(pages, true); for (const [pageName, page] of Object.entries(pages)) { if (page) { this.pagesStore.setPage(pageName, page); + this.pagesStore.savePageHistory(pageName, page.content as string, 'initial'); } } @@ -121,27 +126,14 @@ export class WebBuilderStore { this.editorStore.updateDocumentContent(pageName, _html); } - /** - * 执行保存,将 editorStore 中的当前页面内容同步保存至 PagesStore 中。 - * @returns - */ - async saveCurrentDocument() { - const currentPage = this.editorStore.currentDocument.get(); - if (!currentPage) { - return; - } - - this.saveDocument(currentPage.name as string); - } - - async saveDocument(pageName: string) { + async saveDocument(pageName: string, changeSource: ChangeSource) { const documents = this.editorStore.editorDocuments.get(); const pageProperties = documents[pageName]; if (pageProperties === undefined) { return; } // 触发 page 的保存 - this.pagesStore.savePage(pageName, pageProperties.content as string).then(() => { + this.pagesStore.savePage(pageName, pageProperties.content as string, changeSource).then(() => { this.editorStore.removeUnsavedDocument(pageName, true); }); } @@ -164,9 +156,9 @@ export class WebBuilderStore { this.editorStore.updateDocumentContent(pageName as string, page.content as string); } - async saveAllPages() { + async saveAllPages(changeSource: ChangeSource) { for (const pageName of this.editorStore.unsavedDocuments.get()) { - await this.saveDocument(pageName); + await this.saveDocument(pageName, changeSource); } } diff --git a/app/.client/utils/lru-cache.ts b/app/.client/utils/lru-cache.ts new file mode 100644 index 0000000..4354688 --- /dev/null +++ b/app/.client/utils/lru-cache.ts @@ -0,0 +1,42 @@ +export class LRUCache { + private cache = new Map(); + private maxSize: number; + + constructor(maxSize: number) { + this.maxSize = maxSize; + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) { + return undefined; + } + const value = this.cache.get(key)!; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + this.cache.set(key, value); + } + + has(key: K): boolean { + return this.cache.has(key); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } +} diff --git a/app/.client/utils/prettier.ts b/app/.client/utils/prettier.ts index 3bc82ad..244d604 100644 --- a/app/.client/utils/prettier.ts +++ b/app/.client/utils/prettier.ts @@ -50,3 +50,7 @@ export function getParser(filePath: string): BuiltInParserName | undefined { return undefined; } } + +export function normalizeContent(content?: string) { + return content?.replace(/\r\n/g, '\n').trim(); +} diff --git a/app/types/actions.ts b/app/types/actions.ts index e2fc2a8..c652f7e 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -1,5 +1,3 @@ -import type { Change } from 'diff'; - export interface Page { name: string; title: string; @@ -37,15 +35,26 @@ export interface ActionAlert { source?: 'preview'; } -export interface PageHistory { - originalContent: string; - lastModified: number; - changes: Change[]; - versions: { - timestamp: number; - content: string; - }[]; +export type ChangeSource = 'user' | 'auto-save' | 'initial'; - // 记录变更来源 - changeSource?: 'user' | 'auto-save' | 'external'; +export interface PageHistoryVersion { + // 版本号 + version: number; + // 时间戳 + timestamp: number; + // 内容 + content: string; + // 变更来源 + changeSource: ChangeSource; +} + +export interface PageHistory { + // 最初的内容 + originalContent: string; + // 最新修改时间 + latestModified: number; + // 最新版本 + latestVersion: number; + // 版本历史 + versions: PageHistoryVersion[]; }