refactor: Enhance Diff View with advanced line and character-level change detection

- Improved diff algorithm to detect more granular line and character-level changes
- Added support for character-level highlighting in diff view
- Simplified diff view mode by removing side-by-side option
- Updated component rendering to support more detailed change visualization
- Optimized line change detection with improved matching strategy
This commit is contained in:
Toddyclipsgg 2025-02-23 19:34:27 -03:00
parent b3ec53fa42
commit 36872ee6a0
4 changed files with 1199 additions and 507 deletions

View File

@ -25,6 +25,10 @@ interface DiffBlock {
content: string;
type: 'added' | 'removed' | 'unchanged';
correspondingLine?: number;
charChanges?: Array<{
value: string;
type: 'added' | 'removed' | 'unchanged';
}>;
}
interface FullscreenButtonProps {
@ -74,93 +78,211 @@ const processChanges = (beforeCode: string, afterCode: string) => {
};
}
// Normalizar quebras de linha para evitar falsos positivos
const normalizedBefore = beforeCode.replace(/\r\n/g, '\n');
const normalizedAfter = afterCode.replace(/\r\n/g, '\n');
// Normalize line endings and content
const normalizeContent = (content: string): string[] => {
return content
.replace(/\r\n/g, '\n')
.split('\n')
.map(line => line.trimEnd());
};
// Dividir em linhas preservando linhas vazias
const beforeLines = normalizedBefore.split('\n');
const afterLines = normalizedAfter.split('\n');
const beforeLines = normalizeContent(beforeCode);
const afterLines = normalizeContent(afterCode);
// Se os conteúdos são idênticos após normalização, não há mudanças
if (normalizedBefore === normalizedAfter) {
// Early return if files are identical
if (beforeLines.join('\n') === afterLines.join('\n')) {
return {
beforeLines,
afterLines,
hasChanges: false,
lineChanges: { before: new Set(), after: new Set() },
unifiedBlocks: []
unifiedBlocks: [],
isBinary: false
};
}
// Processar as diferenças com configurações otimizadas para detecção por linha
const changes = diffLines(normalizedBefore, normalizedAfter, {
newlineIsToken: false, // Não tratar quebras de linha como tokens separados
ignoreWhitespace: true, // Ignorar diferenças de espaços em branco
ignoreCase: false // Manter sensibilidade a maiúsculas/minúsculas
});
const lineChanges = {
before: new Set<number>(),
after: new Set<number>()
};
let beforeLineNumber = 0;
let afterLineNumber = 0;
const unifiedBlocks: DiffBlock[] = [];
const unifiedBlocks = changes.reduce((blocks: DiffBlock[], change) => {
// Dividir o conteúdo em linhas preservando linhas vazias
const lines = change.value.split('\n');
if (change.added) {
// Processar linhas adicionadas
const addedBlocks = lines.map((line, i) => {
lineChanges.after.add(afterLineNumber + i);
return {
lineNumber: afterLineNumber + i,
content: line,
type: 'added' as const
};
// Compare lines directly for more accurate diff
let i = 0, j = 0;
while (i < beforeLines.length || j < afterLines.length) {
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
// Unchanged line
unifiedBlocks.push({
lineNumber: j,
content: afterLines[j],
type: 'unchanged',
correspondingLine: i
});
afterLineNumber += lines.length;
return [...blocks, ...addedBlocks];
}
i++;
j++;
} else {
// Look ahead for potential matches
let matchFound = false;
const lookAhead = 3; // Number of lines to look ahead
if (change.removed) {
// Processar linhas removidas
const removedBlocks = lines.map((line, i) => {
lineChanges.before.add(beforeLineNumber + i);
return {
lineNumber: beforeLineNumber + i,
content: line,
type: 'removed' as const
};
});
beforeLineNumber += lines.length;
return [...blocks, ...removedBlocks];
}
// Try to find matching lines ahead
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
if (beforeLines[i + k] === afterLines[j]) {
// Found match in after lines - mark lines as removed
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]) {
// Found match in before lines - mark lines as added
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;
}
}
// Processar linhas não modificadas
const unchangedBlocks = lines.map((line, i) => {
const block = {
lineNumber: afterLineNumber + i,
content: line,
type: 'unchanged' as const,
correspondingLine: beforeLineNumber + i
};
return block;
});
beforeLineNumber += lines.length;
afterLineNumber += lines.length;
return [...blocks, ...unchangedBlocks];
}, []);
if (!matchFound) {
// No match found - try to find character-level changes
if (i < beforeLines.length && j < afterLines.length) {
const beforeLine = beforeLines[i];
const afterLine = afterLines[j];
// Find common prefix and suffix
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) {
// There are character-level changes
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 {
// No character-level changes found, treat as regular line changes
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,
unifiedBlocks: processedBlocks,
isBinary: false
};
} catch (error) {
@ -177,8 +299,14 @@ const processChanges = (beforeCode: string, afterCode: string) => {
}
};
const lineNumberStyles = "w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1";
const lineContentStyles = "px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
const lineNumberStyles = "w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1";
const lineContentStyles = "px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
const diffPanelStyles = "h-full overflow-auto diff-panel-content";
const diffLineStyles = {
added: 'bg-green-500/20 border-l-4 border-green-500',
removed: 'bg-red-500/20 border-l-4 border-red-500',
unchanged: ''
};
const renderContentWarning = (type: 'binary' | 'error') => (
<div className="h-full flex items-center justify-center p-4">
@ -243,13 +371,15 @@ const CodeLine = memo(({
content,
type,
highlighter,
language
language,
block
}: {
lineNumber: number;
content: string;
type: 'added' | 'removed' | 'unchanged';
highlighter: any;
language: string;
block: DiffBlock;
}) => {
const bgColor = {
added: 'bg-green-500/20 border-l-4 border-green-500',
@ -257,13 +387,42 @@ const CodeLine = memo(({
unchanged: ''
}[type];
const highlightedCode = useMemo(() => {
if (!highlighter) return content;
return highlighter.codeToHtml(content, {
lang: language,
theme: 'github-dark'
}).replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
}, [content, highlighter, language]);
const renderContent = () => {
if (type === 'unchanged' || !block.charChanges) {
const highlightedCode = highlighter ?
highlighter.codeToHtml(content, { lang: language, theme: 'github-dark' })
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: content;
return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
}
return (
<>
{block.charChanges.map((change, index) => {
const changeClass = {
added: 'text-green-500 bg-green-500/20',
removed: 'text-red-500 bg-red-500/20',
unchanged: ''
}[change.type];
const highlightedCode = highlighter ?
highlighter.codeToHtml(change.value, { lang: language, theme: 'github-dark' })
.replace(/<\/?pre[^>]*>/g, '')
.replace(/<\/?code[^>]*>/g, '')
: change.value;
return (
<span
key={index}
className={changeClass}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
);
})}
</>
);
};
return (
<div className="flex group min-w-fit">
@ -274,7 +433,7 @@ const CodeLine = memo(({
{type === 'removed' && '-'}
{type === 'unchanged' && ' '}
</span>
<span dangerouslySetInnerHTML={{ __html: highlightedCode }} />
{renderContent()}
</div>
</div>
);
@ -380,9 +539,9 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
beforeCode={beforeCode}
afterCode={afterCode}
/>
<div className="flex-1 overflow-auto diff-panel-content">
<div className={diffPanelStyles}>
{hasChanges ? (
<div className="overflow-x-auto">
<div className="overflow-x-auto min-w-full">
{unifiedBlocks.map((block, index) => (
<CodeLine
key={`${block.lineNumber}-${index}`}
@ -391,6 +550,7 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
type={block.type}
highlighter={highlighter}
language={language}
block={block}
/>
))}
</div>
@ -407,103 +567,13 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
);
});
const SideBySideComparison = memo(({
beforeCode,
afterCode,
language,
filename,
lightTheme,
darkTheme,
}: CodeComparisonProps) => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [highlighter, setHighlighter] = useState<any>(null);
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
const { beforeLines, afterLines, hasChanges, lineChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
useEffect(() => {
getHighlighter({
themes: ['github-dark'],
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
}).then(setHighlighter);
}, []);
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
const renderCode = (code: string) => {
if (!highlighter) return code;
const highlightedCode = highlighter.codeToHtml(code, {
lang: language,
theme: 'github-dark'
});
return highlightedCode.replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
};
return (
<FullscreenOverlay isFullscreen={isFullscreen}>
<div className="w-full h-full flex flex-col">
<FileInfo
filename={filename}
hasChanges={hasChanges}
onToggleFullscreen={toggleFullscreen}
isFullscreen={isFullscreen}
beforeCode={beforeCode}
afterCode={afterCode}
/>
<div className="flex-1 overflow-auto diff-panel-content">
{hasChanges ? (
<div className="grid md:grid-cols-2 divide-x divide-bolt-elements-borderColor relative h-full">
<div className="overflow-auto">
{beforeLines.map((line, index) => (
<div key={`before-${index}`} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={`${lineContentStyles} ${lineChanges.before.has(index) ? 'bg-red-500/20 border-l-4 border-red-500' : ''}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{lineChanges.before.has(index) ? '-' : ' '}
</span>
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
</div>
</div>
))}
</div>
<div className="overflow-auto">
{afterLines.map((line, index) => (
<div key={`after-${index}`} className="flex group min-w-fit">
<div className={lineNumberStyles}>{index + 1}</div>
<div className={`${lineContentStyles} ${lineChanges.after.has(index) ? 'bg-green-500/20 border-l-4 border-green-500' : ''}`}>
<span className="mr-2 text-bolt-elements-textTertiary">
{lineChanges.after.has(index) ? '+' : ' '}
</span>
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
</div>
</div>
))}
</div>
</div>
) : (
<NoChangesView
beforeCode={beforeCode}
language={language}
highlighter={highlighter}
/>
)}
</div>
</div>
</FullscreenOverlay>
);
});
interface DiffViewProps {
fileHistory: Record<string, FileHistory>;
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
diffViewMode: 'inline' | 'side';
actionRunner: ActionRunner;
}
export const DiffView = memo(({ fileHistory, setFileHistory, diffViewMode, actionRunner }: DiffViewProps) => {
export const DiffView = memo(({ fileHistory, setFileHistory, actionRunner }: DiffViewProps) => {
const files = useStore(workbenchStore.files) as FileMap;
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
@ -612,25 +682,14 @@ export const DiffView = memo(({ fileHistory, setFileHistory, diffViewMode, actio
try {
return (
<div className="h-full overflow-hidden">
{diffViewMode === 'inline' ? (
<InlineDiffComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
) : (
<SideBySideComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
)}
<InlineDiffComparison
beforeCode={effectiveOriginalContent}
afterCode={currentContent}
language={language}
filename={selectedFile}
lightTheme="github-light"
darkTheme="github-dark"
/>
</div>
);
} catch (error) {

View File

@ -74,13 +74,9 @@ const workbenchVariants = {
const FileModifiedDropdown = memo(({
fileHistory,
onSelectFile,
diffViewMode,
toggleDiffViewMode,
}: {
fileHistory: Record<string, FileHistory>,
onSelectFile: (filePath: string) => void,
diffViewMode: 'inline' | 'side',
toggleDiffViewMode: () => void,
}) => {
const modifiedFiles = Object.entries(fileHistory);
const hasChanges = modifiedFiles.length > 0;
@ -251,12 +247,6 @@ const FileModifiedDropdown = memo(({
</>
)}
</Popover>
<button
onClick={(e) => { e.stopPropagation(); toggleDiffViewMode(); }}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
>
<span className="font-medium">{diffViewMode === 'inline' ? 'Inline' : 'Side by Side'}</span>
</button>
</div>
);
});
@ -272,7 +262,6 @@ export const Workbench = memo(({
const [isSyncing, setIsSyncing] = useState(false);
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
const [diffViewMode, setDiffViewMode] = useState<'inline' | 'side'>('inline');
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
@ -343,10 +332,6 @@ export const Workbench = memo(({
workbenchStore.currentView.set('diff');
}, []);
const toggleDiffViewMode = useCallback(() => {
setDiffViewMode(prev => prev === 'inline' ? 'side' : 'inline');
}, []);
return (
chatStarted && (
<motion.div
@ -405,8 +390,6 @@ export const Workbench = memo(({
<FileModifiedDropdown
fileHistory={fileHistory}
onSelectFile={handleSelectFile}
diffViewMode={diffViewMode}
toggleDiffViewMode={toggleDiffViewMode}
/>
)}
<IconButton
@ -444,7 +427,6 @@ export const Workbench = memo(({
<DiffView
fileHistory={fileHistory}
setFileHistory={setFileHistory}
diffViewMode={diffViewMode}
actionRunner={actionRunner}
/>
</View>

View File

@ -74,12 +74,11 @@
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.4",
"lucide-react": "^0.474.0",
"next-themes": "^0.4.4",
"@remix-run/cloudflare": "^2.15.2",
"@remix-run/cloudflare-pages": "^2.15.2",
"@remix-run/node": "^2.15.2",
"@remix-run/react": "^2.15.2",
"@tanstack/react-virtual": "^3.13.0",
"@types/react-beautiful-dnd": "^13.1.8",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
@ -105,7 +104,9 @@
"js-cookie": "^3.0.5",
"jspdf": "^2.5.2",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
"nanostores": "^0.10.3",
"next-themes": "^0.4.4",
"ollama-ai-provider": "^0.15.2",
"path-browserify": "^1.0.1",
"react": "^18.3.1",
@ -135,6 +136,8 @@
"@iconify-json/ph": "^1.2.1",
"@iconify/types": "^2.0.0",
"@remix-run/dev": "^2.15.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4",
"@types/file-saver": "^2.0.7",
@ -142,9 +145,11 @@
"@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"fast-glob": "^3.3.2",
"husky": "9.1.7",
"is-ci": "^3.0.1",
"jsdom": "^26.0.0",
"node-fetch": "^3.3.2",
"pnpm": "^9.14.4",
"prettier": "^3.4.1",

File diff suppressed because it is too large Load Diff