pref: optimize the diff to make it more accurately reflect changes (#11)

* pref: optimize the diff to make it more accurately reflect changes

* pref: optimize the diff page selection
This commit is contained in:
Takagi
2025-10-14 12:11:58 +08:00
committed by GitHub
parent 4e39d41362
commit c1829e5af9
10 changed files with 560 additions and 484 deletions

View File

@@ -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}
/>
);

View File

@@ -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<string, string>();
// 格式化结果缓存,使用 Map 存储已格式化的代码
const formatCache = new Map<string, string>();
// 差异计算结果缓存
const diffCache = new Map<string, ReturnType<typeof processChanges>>();
interface VirtualizedListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
itemHeight: number;
className?: string;
overscan?: number;
}
function VirtualizedList<T>({ items, renderItem, itemHeight, className = '', overscan = 20 }: VirtualizedListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const [renderedItems, setRenderedItems] = useState<React.ReactNode[]>([]);
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 (
<div
key={actualIndex}
style={{ position: 'absolute', top: actualIndex * itemHeight, height: itemHeight, width: '100%' }}
>
{renderItem(item, actualIndex)}
</div>
);
});
setRenderedItems(newRenderedItems);
setIsRendering(false);
}, 0);
}, [items, visibleRange, itemHeight, renderItem, isRendering]);
return (
<div
ref={containerRef}
className={`virtualized-list ${className}`}
style={{ position: 'relative', height: '100%', overflow: 'hidden' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>{renderedItems}</div>
</div>
);
}
const highlightCache = new LRUCache<string, string>(1000);
const formatCache = new LRUCache<string, string>(100);
const diffCache = new LRUCache<string, ReturnType<typeof processChanges>>(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) => (
<CodeLine
key={`unchanged-${index}`}
lineNumber={block.lineNumber}
content={block.content}
type={block.type}
highlighter={highlighter}
language={language}
block={block}
theme={theme}
/>
),
[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 (
<div style={style}>
<CodeLine
lineNumber={block.lineNumber}
content={block.content}
type={block.type}
highlighter={highlighter}
language={language}
block={block}
theme={theme}
/>
</div>
);
},
[],
);
return (
@@ -456,12 +389,14 @@ const NoChangesView = memo(
</div>
<div className="overflow-auto max-h-96">
{codeBlocks.length > 0 ? (
<VirtualizedList
items={codeBlocks}
renderItem={renderCodeLine}
itemHeight={24}
className="overflow-x-auto"
overscan={10}
<List
defaultHeight={384}
rowCount={codeBlocks.length}
rowHeight={24}
rowComponent={Row}
rowProps={{ codeBlocks, highlighter, language, theme }}
className="overflow-x-auto overflow-y-hidden!"
overscanCount={10}
/>
) : (
<div className="p-4 text-center text-upage-elements-textTertiary"></div>
@@ -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(`<span class="${changeClass}">${highlighted}</span>`);
}
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<string | null>(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(`<span class="${changeClass}">${highlighted}</span>`);
}
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 <span dangerouslySetInnerHTML={{ __html: highlightedContent }} />;
}
@@ -718,20 +662,50 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
}
}, [hasChanges, loadHighlighter]);
const renderCodeLine = useCallback(
(block: DiffBlock, index: number) => (
<CodeLine
key={`${block.lineNumber}-${index}`}
lineNumber={block.lineNumber}
content={block.content}
type={block.type}
highlighter={highlighter}
language={language}
block={block}
theme={theme}
/>
),
[highlighter, language, theme],
const containerRef = useRef<HTMLDivElement>(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 (
<div style={style}>
<CodeLine
lineNumber={block.lineNumber}
content={block.content}
type={block.type}
highlighter={highlighter}
language={language}
block={block}
theme={theme}
/>
</div>
);
},
[],
);
if (error) {
@@ -749,17 +723,17 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }
beforeCode={beforeCode}
afterCode={afterCode}
/>
<div className={diffPanelStyles}>
<div ref={containerRef} className={diffPanelStyles}>
{hasChanges ? (
<div className="overflow-x-auto min-w-full">
<VirtualizedList
items={unifiedBlocks}
renderItem={renderCodeLine}
itemHeight={24}
className="overflow-x-auto"
overscan={30}
/>
</div>
<List
defaultHeight={containerHeight}
rowCount={unifiedBlocks.length}
rowHeight={24}
rowComponent={Row}
rowProps={{ unifiedBlocks, highlighter, language, theme }}
className="overflow-x-auto"
overscanCount={30}
/>
) : (
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
)}
@@ -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<string>('');
const [effectiveOriginalContent, setEffectiveOriginalContent] = useState<string>('');
const [shouldProcess, setShouldProcess] = useState(false);
const [compareVersionContent, setCompareVersionContent] = useState<string>('');
const [baselineVersionContent, setBaselineVersionContent] = useState<string>('');
// 存当前页面内容的键,用于检测内容是否变化缓
const contentCacheKey = useRef<string | null>(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 (
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary">
@@ -854,8 +833,8 @@ export const DiffView = memo(() => {
return (
<div className="h-full overflow-hidden">
<InlineDiffComparison
beforeCode={effectiveOriginalContent}
afterCode={formattedContent}
beforeCode={baselineVersionContent}
afterCode={compareVersionContent}
language={'html'}
pageName={selectedPage}
lightTheme="github-light"

View File

@@ -0,0 +1,199 @@
import { Popover, Transition } from '@headlessui/react';
import { useStore } from '@nanostores/react';
import classNames from 'classnames';
import { type Change, diffLines } from 'diff';
import { memo, useCallback, useMemo, useState } from 'react';
import { webBuilderStore } from '~/.client/stores/web-builder';
interface PageModifiedDropdownProps {
onSelectPage: (pageName: string) => 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 (
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open, close }: { open: boolean; close: () => void }) => (
<>
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-2 hover:bg-upage-elements-background-depth-3 transition-colors text-upage-elements-textPrimary border border-upage-elements-borderColor">
<span className="font-medium"></span>
{hasChanges && (
<span className="size-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
{modifiedPages.length}
</span>
)}
</Popover.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-upage-elements-background-depth-2 shadow-xl border border-upage-elements-borderColor">
<div className="p-2">
<div className="relative mx-2 mb-2">
<input
type="text"
placeholder="搜索页面..."
value={searchQuery}
onChange={(e) => 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"
/>
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-upage-elements-textTertiary">
<div className="i-ph:magnifying-glass" />
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredPages.length > 0 ? (
filteredPages.map(([pageName, history]) => {
const isActive = pageName === currentSelectedPage;
return (
<button
key={pageName}
onClick={() => handleSelectPage(pageName, close)}
disabled={isActive}
className={classNames('w-full px-3 py-2 text-left rounded-md transition-colors group', {
'bg-blue-500/10 cursor-default': isActive,
'hover:bg-upage-elements-background-depth-1 hover:bg-blue-500/10 bg-transparent cursor-pointer':
!isActive,
})}
>
<div className="flex items-center gap-2">
<div className="shrink-0 size-5 text-upage-elements-textTertiary">
<div
className={classNames({
'i-ph:file-text-duotone text-blue-500': isActive,
'i-ph:file-text': !isActive,
})}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span
className={classNames('truncate text-sm font-medium', {
'text-blue-600': isActive,
'text-upage-elements-textPrimary': !isActive,
})}
>
{pageName.split('/').pop()}
</span>
{isActive && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 font-medium">
</span>
)}
</div>
<span className="truncate text-xs text-upage-elements-textTertiary">
{pageName}
</span>
</div>
{(() => {
// 计算差异统计
const { additions, deletions } = (() => {
if (!history.originalContent) {
return { additions: 0, deletions: 0 };
}
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') ||
'';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
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 },
);
})();
const showStats = additions > 0 || deletions > 0;
return (
showStats && (
<div className="flex items-center gap-1 text-xs shrink-0">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)
);
})()}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="size-12 mb-2 text-upage-elements-textTertiary">
<div className="i-ph:file-dashed" />
</div>
<p className="text-sm font-medium text-upage-elements-textPrimary">
{searchQuery ? '没有匹配的页面' : '没有修改的页面'}
</p>
<p className="text-xs text-upage-elements-textTertiary mt-1">
{searchQuery ? '尝试其他搜索' : '更改将在此处显示'}
</p>
</div>
)}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
});
PageModifiedDropdown.displayName = 'PageModifiedDropdown';

View File

@@ -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 (
<div className="flex items-center gap-2">
<Popover className="relative">
{({ open }: { open: boolean }) => (
<>
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-2 hover:bg-upage-elements-background-depth-3 transition-colors text-upage-elements-textPrimary border border-upage-elements-borderColor">
<span className="font-medium"></span>
{hasChanges && (
<span className="size-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
{modifiedPages.length}
</span>
)}
</Popover.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-upage-elements-background-depth-2 shadow-xl border border-upage-elements-borderColor">
<div className="p-2">
<div className="relative mx-2 mb-2">
<input
type="text"
placeholder="搜索页面..."
value={searchQuery}
onChange={(e) => 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"
/>
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-upage-elements-textTertiary">
<div className="i-ph:magnifying-glass" />
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredPages.length > 0 ? (
filteredPages.map(([pageName, history]) => {
return (
<button
key={pageName}
onClick={() => onSelectPage(pageName)}
className="w-full px-3 py-2 text-left rounded-md hover:bg-upage-elements-background-depth-1 transition-colors group bg-transparent"
>
<div className="flex items-center gap-2">
<div className="shrink-0 size-5 text-upage-elements-textTertiary">
<div className="i-ph:file-text" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col min-w-0">
<span className="truncate text-sm font-medium text-upage-elements-textPrimary">
{pageName.split('/').pop()}
</span>
<span className="truncate text-xs text-upage-elements-textTertiary">
{pageName}
</span>
</div>
{(() => {
// Calculate diff stats
const { additions, deletions } = (() => {
if (!history.originalContent) {
return { additions: 0, deletions: 0 };
}
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
const normalizedCurrent =
history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') ||
'';
if (normalizedOriginal === normalizedCurrent) {
return { additions: 0, deletions: 0 };
}
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
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 },
);
})();
const showStats = additions > 0 || deletions > 0;
return (
showStats && (
<div className="flex items-center gap-1 text-xs shrink-0">
{additions > 0 && <span className="text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
</div>
)
);
})()}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="size-12 mb-2 text-upage-elements-textTertiary">
<div className="i-ph:file-dashed" />
</div>
<p className="text-sm font-medium text-upage-elements-textPrimary">
{searchQuery ? '没有匹配的页面' : '没有修改的页面'}
</p>
<p className="text-xs text-upage-elements-textTertiary mt-1">
{searchQuery ? '尝试其他搜索' : '更改将在此处显示'}
</p>
</div>
)}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
});
export const WebBuilder = memo(() => {
renderLogger.trace('webBuilder');
@@ -251,7 +102,7 @@ export const WebBuilder = memo(() => {
webBuilderStore.setSelectedPage(pageName);
}, []);
const onAutoPageSave = useCallback<OnSaveCallback>(() => {
const onPageSave = useCallback<OnSaveCallback>(() => {
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}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,42 @@
export class LRUCache<K, V> {
private cache = new Map<K, V>();
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;
}
}

View File

@@ -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();
}

View File

@@ -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[];
}