refactor: Enhance Diff View with advanced line and character-level change detection
Browse files- 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
- app/components/workbench/DiffView.tsx +244 -185
- app/components/workbench/Workbench.client.tsx +0 -18
- package.json +7 -2
- pnpm-lock.yaml +0 -0
app/components/workbench/DiffView.tsx
CHANGED
|
@@ -25,6 +25,10 @@ interface DiffBlock {
|
|
| 25 |
content: string;
|
| 26 |
type: 'added' | 'removed' | 'unchanged';
|
| 27 |
correspondingLine?: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
interface FullscreenButtonProps {
|
|
@@ -74,93 +78,211 @@ const processChanges = (beforeCode: string, afterCode: string) => {
|
|
| 74 |
};
|
| 75 |
}
|
| 76 |
|
| 77 |
-
//
|
| 78 |
-
const
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
const
|
| 83 |
-
const afterLines = normalizedAfter.split('\n');
|
| 84 |
|
| 85 |
-
//
|
| 86 |
-
if (
|
| 87 |
return {
|
| 88 |
beforeLines,
|
| 89 |
afterLines,
|
| 90 |
hasChanges: false,
|
| 91 |
lineChanges: { before: new Set(), after: new Set() },
|
| 92 |
-
unifiedBlocks: []
|
|
|
|
| 93 |
};
|
| 94 |
}
|
| 95 |
|
| 96 |
-
// Processar as diferenças com configurações otimizadas para detecção por linha
|
| 97 |
-
const changes = diffLines(normalizedBefore, normalizedAfter, {
|
| 98 |
-
newlineIsToken: false, // Não tratar quebras de linha como tokens separados
|
| 99 |
-
ignoreWhitespace: true, // Ignorar diferenças de espaços em branco
|
| 100 |
-
ignoreCase: false // Manter sensibilidade a maiúsculas/minúsculas
|
| 101 |
-
});
|
| 102 |
-
|
| 103 |
const lineChanges = {
|
| 104 |
before: new Set<number>(),
|
| 105 |
after: new Set<number>()
|
| 106 |
};
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
lineNumber: afterLineNumber + i,
|
| 121 |
-
content: line,
|
| 122 |
-
type: 'added' as const
|
| 123 |
-
};
|
| 124 |
});
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
|
|
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
const block = {
|
| 146 |
-
lineNumber: afterLineNumber + i,
|
| 147 |
-
content: line,
|
| 148 |
-
type: 'unchanged' as const,
|
| 149 |
-
correspondingLine: beforeLineNumber + i
|
| 150 |
-
};
|
| 151 |
-
return block;
|
| 152 |
-
});
|
| 153 |
-
beforeLineNumber += lines.length;
|
| 154 |
-
afterLineNumber += lines.length;
|
| 155 |
-
return [...blocks, ...unchangedBlocks];
|
| 156 |
-
}, []);
|
| 157 |
|
| 158 |
return {
|
| 159 |
beforeLines,
|
| 160 |
afterLines,
|
| 161 |
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
| 162 |
lineChanges,
|
| 163 |
-
unifiedBlocks,
|
| 164 |
isBinary: false
|
| 165 |
};
|
| 166 |
} catch (error) {
|
|
@@ -177,8 +299,14 @@ const processChanges = (beforeCode: string, afterCode: string) => {
|
|
| 177 |
}
|
| 178 |
};
|
| 179 |
|
| 180 |
-
const lineNumberStyles = "w-
|
| 181 |
-
const lineContentStyles = "px-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
const renderContentWarning = (type: 'binary' | 'error') => (
|
| 184 |
<div className="h-full flex items-center justify-center p-4">
|
|
@@ -243,13 +371,15 @@ const CodeLine = memo(({
|
|
| 243 |
content,
|
| 244 |
type,
|
| 245 |
highlighter,
|
| 246 |
-
language
|
|
|
|
| 247 |
}: {
|
| 248 |
lineNumber: number;
|
| 249 |
content: string;
|
| 250 |
type: 'added' | 'removed' | 'unchanged';
|
| 251 |
highlighter: any;
|
| 252 |
language: string;
|
|
|
|
| 253 |
}) => {
|
| 254 |
const bgColor = {
|
| 255 |
added: 'bg-green-500/20 border-l-4 border-green-500',
|
|
@@ -257,13 +387,42 @@ const CodeLine = memo(({
|
|
| 257 |
unchanged: ''
|
| 258 |
}[type];
|
| 259 |
|
| 260 |
-
const
|
| 261 |
-
if (!
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
return (
|
| 269 |
<div className="flex group min-w-fit">
|
|
@@ -274,7 +433,7 @@ const CodeLine = memo(({
|
|
| 274 |
{type === 'removed' && '-'}
|
| 275 |
{type === 'unchanged' && ' '}
|
| 276 |
</span>
|
| 277 |
-
|
| 278 |
</div>
|
| 279 |
</div>
|
| 280 |
);
|
|
@@ -380,9 +539,9 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
|
|
| 380 |
beforeCode={beforeCode}
|
| 381 |
afterCode={afterCode}
|
| 382 |
/>
|
| 383 |
-
<div className=
|
| 384 |
{hasChanges ? (
|
| 385 |
-
<div className="overflow-x-auto">
|
| 386 |
{unifiedBlocks.map((block, index) => (
|
| 387 |
<CodeLine
|
| 388 |
key={`${block.lineNumber}-${index}`}
|
|
@@ -391,6 +550,7 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
|
|
| 391 |
type={block.type}
|
| 392 |
highlighter={highlighter}
|
| 393 |
language={language}
|
|
|
|
| 394 |
/>
|
| 395 |
))}
|
| 396 |
</div>
|
|
@@ -407,103 +567,13 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language,
|
|
| 407 |
);
|
| 408 |
});
|
| 409 |
|
| 410 |
-
const SideBySideComparison = memo(({
|
| 411 |
-
beforeCode,
|
| 412 |
-
afterCode,
|
| 413 |
-
language,
|
| 414 |
-
filename,
|
| 415 |
-
lightTheme,
|
| 416 |
-
darkTheme,
|
| 417 |
-
}: CodeComparisonProps) => {
|
| 418 |
-
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 419 |
-
const [highlighter, setHighlighter] = useState<any>(null);
|
| 420 |
-
|
| 421 |
-
const toggleFullscreen = useCallback(() => {
|
| 422 |
-
setIsFullscreen(prev => !prev);
|
| 423 |
-
}, []);
|
| 424 |
-
|
| 425 |
-
const { beforeLines, afterLines, hasChanges, lineChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
|
| 426 |
-
|
| 427 |
-
useEffect(() => {
|
| 428 |
-
getHighlighter({
|
| 429 |
-
themes: ['github-dark'],
|
| 430 |
-
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
|
| 431 |
-
}).then(setHighlighter);
|
| 432 |
-
}, []);
|
| 433 |
-
|
| 434 |
-
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
|
| 435 |
-
|
| 436 |
-
const renderCode = (code: string) => {
|
| 437 |
-
if (!highlighter) return code;
|
| 438 |
-
const highlightedCode = highlighter.codeToHtml(code, {
|
| 439 |
-
lang: language,
|
| 440 |
-
theme: 'github-dark'
|
| 441 |
-
});
|
| 442 |
-
return highlightedCode.replace(/<\/?pre[^>]*>/g, '').replace(/<\/?code[^>]*>/g, '');
|
| 443 |
-
};
|
| 444 |
-
|
| 445 |
-
return (
|
| 446 |
-
<FullscreenOverlay isFullscreen={isFullscreen}>
|
| 447 |
-
<div className="w-full h-full flex flex-col">
|
| 448 |
-
<FileInfo
|
| 449 |
-
filename={filename}
|
| 450 |
-
hasChanges={hasChanges}
|
| 451 |
-
onToggleFullscreen={toggleFullscreen}
|
| 452 |
-
isFullscreen={isFullscreen}
|
| 453 |
-
beforeCode={beforeCode}
|
| 454 |
-
afterCode={afterCode}
|
| 455 |
-
/>
|
| 456 |
-
<div className="flex-1 overflow-auto diff-panel-content">
|
| 457 |
-
{hasChanges ? (
|
| 458 |
-
<div className="grid md:grid-cols-2 divide-x divide-bolt-elements-borderColor relative h-full">
|
| 459 |
-
<div className="overflow-auto">
|
| 460 |
-
{beforeLines.map((line, index) => (
|
| 461 |
-
<div key={`before-${index}`} className="flex group min-w-fit">
|
| 462 |
-
<div className={lineNumberStyles}>{index + 1}</div>
|
| 463 |
-
<div className={`${lineContentStyles} ${lineChanges.before.has(index) ? 'bg-red-500/20 border-l-4 border-red-500' : ''}`}>
|
| 464 |
-
<span className="mr-2 text-bolt-elements-textTertiary">
|
| 465 |
-
{lineChanges.before.has(index) ? '-' : ' '}
|
| 466 |
-
</span>
|
| 467 |
-
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
|
| 468 |
-
</div>
|
| 469 |
-
</div>
|
| 470 |
-
))}
|
| 471 |
-
</div>
|
| 472 |
-
<div className="overflow-auto">
|
| 473 |
-
{afterLines.map((line, index) => (
|
| 474 |
-
<div key={`after-${index}`} className="flex group min-w-fit">
|
| 475 |
-
<div className={lineNumberStyles}>{index + 1}</div>
|
| 476 |
-
<div className={`${lineContentStyles} ${lineChanges.after.has(index) ? 'bg-green-500/20 border-l-4 border-green-500' : ''}`}>
|
| 477 |
-
<span className="mr-2 text-bolt-elements-textTertiary">
|
| 478 |
-
{lineChanges.after.has(index) ? '+' : ' '}
|
| 479 |
-
</span>
|
| 480 |
-
<span dangerouslySetInnerHTML={{ __html: renderCode(line) }} />
|
| 481 |
-
</div>
|
| 482 |
-
</div>
|
| 483 |
-
))}
|
| 484 |
-
</div>
|
| 485 |
-
</div>
|
| 486 |
-
) : (
|
| 487 |
-
<NoChangesView
|
| 488 |
-
beforeCode={beforeCode}
|
| 489 |
-
language={language}
|
| 490 |
-
highlighter={highlighter}
|
| 491 |
-
/>
|
| 492 |
-
)}
|
| 493 |
-
</div>
|
| 494 |
-
</div>
|
| 495 |
-
</FullscreenOverlay>
|
| 496 |
-
);
|
| 497 |
-
});
|
| 498 |
-
|
| 499 |
interface DiffViewProps {
|
| 500 |
fileHistory: Record<string, FileHistory>;
|
| 501 |
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
|
| 502 |
-
diffViewMode: 'inline' | 'side';
|
| 503 |
actionRunner: ActionRunner;
|
| 504 |
}
|
| 505 |
|
| 506 |
-
export const DiffView = memo(({ fileHistory, setFileHistory,
|
| 507 |
const files = useStore(workbenchStore.files) as FileMap;
|
| 508 |
const selectedFile = useStore(workbenchStore.selectedFile);
|
| 509 |
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
|
@@ -612,25 +682,14 @@ export const DiffView = memo(({ fileHistory, setFileHistory, diffViewMode, actio
|
|
| 612 |
try {
|
| 613 |
return (
|
| 614 |
<div className="h-full overflow-hidden">
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
/>
|
| 624 |
-
) : (
|
| 625 |
-
<SideBySideComparison
|
| 626 |
-
beforeCode={effectiveOriginalContent}
|
| 627 |
-
afterCode={currentContent}
|
| 628 |
-
language={language}
|
| 629 |
-
filename={selectedFile}
|
| 630 |
-
lightTheme="github-light"
|
| 631 |
-
darkTheme="github-dark"
|
| 632 |
-
/>
|
| 633 |
-
)}
|
| 634 |
</div>
|
| 635 |
);
|
| 636 |
} catch (error) {
|
|
|
|
| 25 |
content: string;
|
| 26 |
type: 'added' | 'removed' | 'unchanged';
|
| 27 |
correspondingLine?: number;
|
| 28 |
+
charChanges?: Array<{
|
| 29 |
+
value: string;
|
| 30 |
+
type: 'added' | 'removed' | 'unchanged';
|
| 31 |
+
}>;
|
| 32 |
}
|
| 33 |
|
| 34 |
interface FullscreenButtonProps {
|
|
|
|
| 78 |
};
|
| 79 |
}
|
| 80 |
|
| 81 |
+
// Normalize line endings and content
|
| 82 |
+
const normalizeContent = (content: string): string[] => {
|
| 83 |
+
return content
|
| 84 |
+
.replace(/\r\n/g, '\n')
|
| 85 |
+
.split('\n')
|
| 86 |
+
.map(line => line.trimEnd());
|
| 87 |
+
};
|
| 88 |
|
| 89 |
+
const beforeLines = normalizeContent(beforeCode);
|
| 90 |
+
const afterLines = normalizeContent(afterCode);
|
|
|
|
| 91 |
|
| 92 |
+
// Early return if files are identical
|
| 93 |
+
if (beforeLines.join('\n') === afterLines.join('\n')) {
|
| 94 |
return {
|
| 95 |
beforeLines,
|
| 96 |
afterLines,
|
| 97 |
hasChanges: false,
|
| 98 |
lineChanges: { before: new Set(), after: new Set() },
|
| 99 |
+
unifiedBlocks: [],
|
| 100 |
+
isBinary: false
|
| 101 |
};
|
| 102 |
}
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const lineChanges = {
|
| 105 |
before: new Set<number>(),
|
| 106 |
after: new Set<number>()
|
| 107 |
};
|
| 108 |
|
| 109 |
+
const unifiedBlocks: DiffBlock[] = [];
|
| 110 |
+
|
| 111 |
+
// Compare lines directly for more accurate diff
|
| 112 |
+
let i = 0, j = 0;
|
| 113 |
+
while (i < beforeLines.length || j < afterLines.length) {
|
| 114 |
+
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
|
| 115 |
+
// Unchanged line
|
| 116 |
+
unifiedBlocks.push({
|
| 117 |
+
lineNumber: j,
|
| 118 |
+
content: afterLines[j],
|
| 119 |
+
type: 'unchanged',
|
| 120 |
+
correspondingLine: i
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
});
|
| 122 |
+
i++;
|
| 123 |
+
j++;
|
| 124 |
+
} else {
|
| 125 |
+
// Look ahead for potential matches
|
| 126 |
+
let matchFound = false;
|
| 127 |
+
const lookAhead = 3; // Number of lines to look ahead
|
| 128 |
+
|
| 129 |
+
// Try to find matching lines ahead
|
| 130 |
+
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
|
| 131 |
+
if (beforeLines[i + k] === afterLines[j]) {
|
| 132 |
+
// Found match in after lines - mark lines as removed
|
| 133 |
+
for (let l = 0; l < k; l++) {
|
| 134 |
+
lineChanges.before.add(i + l);
|
| 135 |
+
unifiedBlocks.push({
|
| 136 |
+
lineNumber: i + l,
|
| 137 |
+
content: beforeLines[i + l],
|
| 138 |
+
type: 'removed',
|
| 139 |
+
correspondingLine: j,
|
| 140 |
+
charChanges: [{ value: beforeLines[i + l], type: 'removed' }]
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
i += k;
|
| 144 |
+
matchFound = true;
|
| 145 |
+
break;
|
| 146 |
+
} else if (beforeLines[i] === afterLines[j + k]) {
|
| 147 |
+
// Found match in before lines - mark lines as added
|
| 148 |
+
for (let l = 0; l < k; l++) {
|
| 149 |
+
lineChanges.after.add(j + l);
|
| 150 |
+
unifiedBlocks.push({
|
| 151 |
+
lineNumber: j + l,
|
| 152 |
+
content: afterLines[j + l],
|
| 153 |
+
type: 'added',
|
| 154 |
+
correspondingLine: i,
|
| 155 |
+
charChanges: [{ value: afterLines[j + l], type: 'added' }]
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
j += k;
|
| 159 |
+
matchFound = true;
|
| 160 |
+
break;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
|
| 164 |
+
if (!matchFound) {
|
| 165 |
+
// No match found - try to find character-level changes
|
| 166 |
+
if (i < beforeLines.length && j < afterLines.length) {
|
| 167 |
+
const beforeLine = beforeLines[i];
|
| 168 |
+
const afterLine = afterLines[j];
|
| 169 |
+
|
| 170 |
+
// Find common prefix and suffix
|
| 171 |
+
let prefixLength = 0;
|
| 172 |
+
while (prefixLength < beforeLine.length &&
|
| 173 |
+
prefixLength < afterLine.length &&
|
| 174 |
+
beforeLine[prefixLength] === afterLine[prefixLength]) {
|
| 175 |
+
prefixLength++;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
let suffixLength = 0;
|
| 179 |
+
while (suffixLength < beforeLine.length - prefixLength &&
|
| 180 |
+
suffixLength < afterLine.length - prefixLength &&
|
| 181 |
+
beforeLine[beforeLine.length - 1 - suffixLength] ===
|
| 182 |
+
afterLine[afterLine.length - 1 - suffixLength]) {
|
| 183 |
+
suffixLength++;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
const prefix = beforeLine.slice(0, prefixLength);
|
| 187 |
+
const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
|
| 188 |
+
const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
|
| 189 |
+
const suffix = beforeLine.slice(beforeLine.length - suffixLength);
|
| 190 |
+
|
| 191 |
+
if (beforeMiddle || afterMiddle) {
|
| 192 |
+
// There are character-level changes
|
| 193 |
+
if (beforeMiddle) {
|
| 194 |
+
lineChanges.before.add(i);
|
| 195 |
+
unifiedBlocks.push({
|
| 196 |
+
lineNumber: i,
|
| 197 |
+
content: beforeLine,
|
| 198 |
+
type: 'removed',
|
| 199 |
+
correspondingLine: j,
|
| 200 |
+
charChanges: [
|
| 201 |
+
{ value: prefix, type: 'unchanged' },
|
| 202 |
+
{ value: beforeMiddle, type: 'removed' },
|
| 203 |
+
{ value: suffix, type: 'unchanged' }
|
| 204 |
+
]
|
| 205 |
+
});
|
| 206 |
+
i++;
|
| 207 |
+
}
|
| 208 |
+
if (afterMiddle) {
|
| 209 |
+
lineChanges.after.add(j);
|
| 210 |
+
unifiedBlocks.push({
|
| 211 |
+
lineNumber: j,
|
| 212 |
+
content: afterLine,
|
| 213 |
+
type: 'added',
|
| 214 |
+
correspondingLine: i - 1,
|
| 215 |
+
charChanges: [
|
| 216 |
+
{ value: prefix, type: 'unchanged' },
|
| 217 |
+
{ value: afterMiddle, type: 'added' },
|
| 218 |
+
{ value: suffix, type: 'unchanged' }
|
| 219 |
+
]
|
| 220 |
+
});
|
| 221 |
+
j++;
|
| 222 |
+
}
|
| 223 |
+
} else {
|
| 224 |
+
// No character-level changes found, treat as regular line changes
|
| 225 |
+
if (i < beforeLines.length) {
|
| 226 |
+
lineChanges.before.add(i);
|
| 227 |
+
unifiedBlocks.push({
|
| 228 |
+
lineNumber: i,
|
| 229 |
+
content: beforeLines[i],
|
| 230 |
+
type: 'removed',
|
| 231 |
+
correspondingLine: j,
|
| 232 |
+
charChanges: [{ value: beforeLines[i], type: 'removed' }]
|
| 233 |
+
});
|
| 234 |
+
i++;
|
| 235 |
+
}
|
| 236 |
+
if (j < afterLines.length) {
|
| 237 |
+
lineChanges.after.add(j);
|
| 238 |
+
unifiedBlocks.push({
|
| 239 |
+
lineNumber: j,
|
| 240 |
+
content: afterLines[j],
|
| 241 |
+
type: 'added',
|
| 242 |
+
correspondingLine: i - 1,
|
| 243 |
+
charChanges: [{ value: afterLines[j], type: 'added' }]
|
| 244 |
+
});
|
| 245 |
+
j++;
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
} else {
|
| 249 |
+
// Handle remaining lines
|
| 250 |
+
if (i < beforeLines.length) {
|
| 251 |
+
lineChanges.before.add(i);
|
| 252 |
+
unifiedBlocks.push({
|
| 253 |
+
lineNumber: i,
|
| 254 |
+
content: beforeLines[i],
|
| 255 |
+
type: 'removed',
|
| 256 |
+
correspondingLine: j,
|
| 257 |
+
charChanges: [{ value: beforeLines[i], type: 'removed' }]
|
| 258 |
+
});
|
| 259 |
+
i++;
|
| 260 |
+
}
|
| 261 |
+
if (j < afterLines.length) {
|
| 262 |
+
lineChanges.after.add(j);
|
| 263 |
+
unifiedBlocks.push({
|
| 264 |
+
lineNumber: j,
|
| 265 |
+
content: afterLines[j],
|
| 266 |
+
type: 'added',
|
| 267 |
+
correspondingLine: i - 1,
|
| 268 |
+
charChanges: [{ value: afterLines[j], type: 'added' }]
|
| 269 |
+
});
|
| 270 |
+
j++;
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
}
|
| 275 |
+
}
|
| 276 |
|
| 277 |
+
// Sort blocks by line number
|
| 278 |
+
const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
return {
|
| 281 |
beforeLines,
|
| 282 |
afterLines,
|
| 283 |
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
| 284 |
lineChanges,
|
| 285 |
+
unifiedBlocks: processedBlocks,
|
| 286 |
isBinary: false
|
| 287 |
};
|
| 288 |
} catch (error) {
|
|
|
|
| 299 |
}
|
| 300 |
};
|
| 301 |
|
| 302 |
+
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";
|
| 303 |
+
const lineContentStyles = "px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
|
| 304 |
+
const diffPanelStyles = "h-full overflow-auto diff-panel-content";
|
| 305 |
+
const diffLineStyles = {
|
| 306 |
+
added: 'bg-green-500/20 border-l-4 border-green-500',
|
| 307 |
+
removed: 'bg-red-500/20 border-l-4 border-red-500',
|
| 308 |
+
unchanged: ''
|
| 309 |
+
};
|
| 310 |
|
| 311 |
const renderContentWarning = (type: 'binary' | 'error') => (
|
| 312 |
<div className="h-full flex items-center justify-center p-4">
|
|
|
|
| 371 |
content,
|
| 372 |
type,
|
| 373 |
highlighter,
|
| 374 |
+
language,
|
| 375 |
+
block
|
| 376 |
}: {
|
| 377 |
lineNumber: number;
|
| 378 |
content: string;
|
| 379 |
type: 'added' | 'removed' | 'unchanged';
|
| 380 |
highlighter: any;
|
| 381 |
language: string;
|
| 382 |
+
block: DiffBlock;
|
| 383 |
}) => {
|
| 384 |
const bgColor = {
|
| 385 |
added: 'bg-green-500/20 border-l-4 border-green-500',
|
|
|
|
| 387 |
unchanged: ''
|
| 388 |
}[type];
|
| 389 |
|
| 390 |
+
const renderContent = () => {
|
| 391 |
+
if (type === 'unchanged' || !block.charChanges) {
|
| 392 |
+
const highlightedCode = highlighter ?
|
| 393 |
+
highlighter.codeToHtml(content, { lang: language, theme: 'github-dark' })
|
| 394 |
+
.replace(/<\/?pre[^>]*>/g, '')
|
| 395 |
+
.replace(/<\/?code[^>]*>/g, '')
|
| 396 |
+
: content;
|
| 397 |
+
return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
return (
|
| 401 |
+
<>
|
| 402 |
+
{block.charChanges.map((change, index) => {
|
| 403 |
+
const changeClass = {
|
| 404 |
+
added: 'text-green-500 bg-green-500/20',
|
| 405 |
+
removed: 'text-red-500 bg-red-500/20',
|
| 406 |
+
unchanged: ''
|
| 407 |
+
}[change.type];
|
| 408 |
+
|
| 409 |
+
const highlightedCode = highlighter ?
|
| 410 |
+
highlighter.codeToHtml(change.value, { lang: language, theme: 'github-dark' })
|
| 411 |
+
.replace(/<\/?pre[^>]*>/g, '')
|
| 412 |
+
.replace(/<\/?code[^>]*>/g, '')
|
| 413 |
+
: change.value;
|
| 414 |
+
|
| 415 |
+
return (
|
| 416 |
+
<span
|
| 417 |
+
key={index}
|
| 418 |
+
className={changeClass}
|
| 419 |
+
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
| 420 |
+
/>
|
| 421 |
+
);
|
| 422 |
+
})}
|
| 423 |
+
</>
|
| 424 |
+
);
|
| 425 |
+
};
|
| 426 |
|
| 427 |
return (
|
| 428 |
<div className="flex group min-w-fit">
|
|
|
|
| 433 |
{type === 'removed' && '-'}
|
| 434 |
{type === 'unchanged' && ' '}
|
| 435 |
</span>
|
| 436 |
+
{renderContent()}
|
| 437 |
</div>
|
| 438 |
</div>
|
| 439 |
);
|
|
|
|
| 539 |
beforeCode={beforeCode}
|
| 540 |
afterCode={afterCode}
|
| 541 |
/>
|
| 542 |
+
<div className={diffPanelStyles}>
|
| 543 |
{hasChanges ? (
|
| 544 |
+
<div className="overflow-x-auto min-w-full">
|
| 545 |
{unifiedBlocks.map((block, index) => (
|
| 546 |
<CodeLine
|
| 547 |
key={`${block.lineNumber}-${index}`}
|
|
|
|
| 550 |
type={block.type}
|
| 551 |
highlighter={highlighter}
|
| 552 |
language={language}
|
| 553 |
+
block={block}
|
| 554 |
/>
|
| 555 |
))}
|
| 556 |
</div>
|
|
|
|
| 567 |
);
|
| 568 |
});
|
| 569 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
interface DiffViewProps {
|
| 571 |
fileHistory: Record<string, FileHistory>;
|
| 572 |
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
|
|
|
|
| 573 |
actionRunner: ActionRunner;
|
| 574 |
}
|
| 575 |
|
| 576 |
+
export const DiffView = memo(({ fileHistory, setFileHistory, actionRunner }: DiffViewProps) => {
|
| 577 |
const files = useStore(workbenchStore.files) as FileMap;
|
| 578 |
const selectedFile = useStore(workbenchStore.selectedFile);
|
| 579 |
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
|
|
|
| 682 |
try {
|
| 683 |
return (
|
| 684 |
<div className="h-full overflow-hidden">
|
| 685 |
+
<InlineDiffComparison
|
| 686 |
+
beforeCode={effectiveOriginalContent}
|
| 687 |
+
afterCode={currentContent}
|
| 688 |
+
language={language}
|
| 689 |
+
filename={selectedFile}
|
| 690 |
+
lightTheme="github-light"
|
| 691 |
+
darkTheme="github-dark"
|
| 692 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
</div>
|
| 694 |
);
|
| 695 |
} catch (error) {
|
app/components/workbench/Workbench.client.tsx
CHANGED
|
@@ -74,13 +74,9 @@ const workbenchVariants = {
|
|
| 74 |
const FileModifiedDropdown = memo(({
|
| 75 |
fileHistory,
|
| 76 |
onSelectFile,
|
| 77 |
-
diffViewMode,
|
| 78 |
-
toggleDiffViewMode,
|
| 79 |
}: {
|
| 80 |
fileHistory: Record<string, FileHistory>,
|
| 81 |
onSelectFile: (filePath: string) => void,
|
| 82 |
-
diffViewMode: 'inline' | 'side',
|
| 83 |
-
toggleDiffViewMode: () => void,
|
| 84 |
}) => {
|
| 85 |
const modifiedFiles = Object.entries(fileHistory);
|
| 86 |
const hasChanges = modifiedFiles.length > 0;
|
|
@@ -251,12 +247,6 @@ const FileModifiedDropdown = memo(({
|
|
| 251 |
</>
|
| 252 |
)}
|
| 253 |
</Popover>
|
| 254 |
-
<button
|
| 255 |
-
onClick={(e) => { e.stopPropagation(); toggleDiffViewMode(); }}
|
| 256 |
-
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"
|
| 257 |
-
>
|
| 258 |
-
<span className="font-medium">{diffViewMode === 'inline' ? 'Inline' : 'Side by Side'}</span>
|
| 259 |
-
</button>
|
| 260 |
</div>
|
| 261 |
);
|
| 262 |
});
|
|
@@ -272,7 +262,6 @@ export const Workbench = memo(({
|
|
| 272 |
|
| 273 |
const [isSyncing, setIsSyncing] = useState(false);
|
| 274 |
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
| 275 |
-
const [diffViewMode, setDiffViewMode] = useState<'inline' | 'side'>('inline');
|
| 276 |
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
| 277 |
|
| 278 |
const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
|
@@ -343,10 +332,6 @@ export const Workbench = memo(({
|
|
| 343 |
workbenchStore.currentView.set('diff');
|
| 344 |
}, []);
|
| 345 |
|
| 346 |
-
const toggleDiffViewMode = useCallback(() => {
|
| 347 |
-
setDiffViewMode(prev => prev === 'inline' ? 'side' : 'inline');
|
| 348 |
-
}, []);
|
| 349 |
-
|
| 350 |
return (
|
| 351 |
chatStarted && (
|
| 352 |
<motion.div
|
|
@@ -405,8 +390,6 @@ export const Workbench = memo(({
|
|
| 405 |
<FileModifiedDropdown
|
| 406 |
fileHistory={fileHistory}
|
| 407 |
onSelectFile={handleSelectFile}
|
| 408 |
-
diffViewMode={diffViewMode}
|
| 409 |
-
toggleDiffViewMode={toggleDiffViewMode}
|
| 410 |
/>
|
| 411 |
)}
|
| 412 |
<IconButton
|
|
@@ -444,7 +427,6 @@ export const Workbench = memo(({
|
|
| 444 |
<DiffView
|
| 445 |
fileHistory={fileHistory}
|
| 446 |
setFileHistory={setFileHistory}
|
| 447 |
-
diffViewMode={diffViewMode}
|
| 448 |
actionRunner={actionRunner}
|
| 449 |
/>
|
| 450 |
</View>
|
|
|
|
| 74 |
const FileModifiedDropdown = memo(({
|
| 75 |
fileHistory,
|
| 76 |
onSelectFile,
|
|
|
|
|
|
|
| 77 |
}: {
|
| 78 |
fileHistory: Record<string, FileHistory>,
|
| 79 |
onSelectFile: (filePath: string) => void,
|
|
|
|
|
|
|
| 80 |
}) => {
|
| 81 |
const modifiedFiles = Object.entries(fileHistory);
|
| 82 |
const hasChanges = modifiedFiles.length > 0;
|
|
|
|
| 247 |
</>
|
| 248 |
)}
|
| 249 |
</Popover>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
</div>
|
| 251 |
);
|
| 252 |
});
|
|
|
|
| 262 |
|
| 263 |
const [isSyncing, setIsSyncing] = useState(false);
|
| 264 |
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
|
|
|
| 265 |
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
| 266 |
|
| 267 |
const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
|
|
|
| 332 |
workbenchStore.currentView.set('diff');
|
| 333 |
}, []);
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
return (
|
| 336 |
chatStarted && (
|
| 337 |
<motion.div
|
|
|
|
| 390 |
<FileModifiedDropdown
|
| 391 |
fileHistory={fileHistory}
|
| 392 |
onSelectFile={handleSelectFile}
|
|
|
|
|
|
|
| 393 |
/>
|
| 394 |
)}
|
| 395 |
<IconButton
|
|
|
|
| 427 |
<DiffView
|
| 428 |
fileHistory={fileHistory}
|
| 429 |
setFileHistory={setFileHistory}
|
|
|
|
| 430 |
actionRunner={actionRunner}
|
| 431 |
/>
|
| 432 |
</View>
|
package.json
CHANGED
|
@@ -74,12 +74,11 @@
|
|
| 74 |
"@radix-ui/react-switch": "^1.1.1",
|
| 75 |
"@radix-ui/react-tabs": "^1.1.2",
|
| 76 |
"@radix-ui/react-tooltip": "^1.1.4",
|
| 77 |
-
"lucide-react": "^0.474.0",
|
| 78 |
-
"next-themes": "^0.4.4",
|
| 79 |
"@remix-run/cloudflare": "^2.15.2",
|
| 80 |
"@remix-run/cloudflare-pages": "^2.15.2",
|
| 81 |
"@remix-run/node": "^2.15.2",
|
| 82 |
"@remix-run/react": "^2.15.2",
|
|
|
|
| 83 |
"@types/react-beautiful-dnd": "^13.1.8",
|
| 84 |
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
| 85 |
"@unocss/reset": "^0.61.9",
|
|
@@ -105,7 +104,9 @@
|
|
| 105 |
"js-cookie": "^3.0.5",
|
| 106 |
"jspdf": "^2.5.2",
|
| 107 |
"jszip": "^3.10.1",
|
|
|
|
| 108 |
"nanostores": "^0.10.3",
|
|
|
|
| 109 |
"ollama-ai-provider": "^0.15.2",
|
| 110 |
"path-browserify": "^1.0.1",
|
| 111 |
"react": "^18.3.1",
|
|
@@ -135,6 +136,8 @@
|
|
| 135 |
"@iconify-json/ph": "^1.2.1",
|
| 136 |
"@iconify/types": "^2.0.0",
|
| 137 |
"@remix-run/dev": "^2.15.2",
|
|
|
|
|
|
|
| 138 |
"@types/diff": "^5.2.3",
|
| 139 |
"@types/dom-speech-recognition": "^0.0.4",
|
| 140 |
"@types/file-saver": "^2.0.7",
|
|
@@ -142,9 +145,11 @@
|
|
| 142 |
"@types/path-browserify": "^1.0.3",
|
| 143 |
"@types/react": "^18.3.12",
|
| 144 |
"@types/react-dom": "^18.3.1",
|
|
|
|
| 145 |
"fast-glob": "^3.3.2",
|
| 146 |
"husky": "9.1.7",
|
| 147 |
"is-ci": "^3.0.1",
|
|
|
|
| 148 |
"node-fetch": "^3.3.2",
|
| 149 |
"pnpm": "^9.14.4",
|
| 150 |
"prettier": "^3.4.1",
|
|
|
|
| 74 |
"@radix-ui/react-switch": "^1.1.1",
|
| 75 |
"@radix-ui/react-tabs": "^1.1.2",
|
| 76 |
"@radix-ui/react-tooltip": "^1.1.4",
|
|
|
|
|
|
|
| 77 |
"@remix-run/cloudflare": "^2.15.2",
|
| 78 |
"@remix-run/cloudflare-pages": "^2.15.2",
|
| 79 |
"@remix-run/node": "^2.15.2",
|
| 80 |
"@remix-run/react": "^2.15.2",
|
| 81 |
+
"@tanstack/react-virtual": "^3.13.0",
|
| 82 |
"@types/react-beautiful-dnd": "^13.1.8",
|
| 83 |
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
| 84 |
"@unocss/reset": "^0.61.9",
|
|
|
|
| 104 |
"js-cookie": "^3.0.5",
|
| 105 |
"jspdf": "^2.5.2",
|
| 106 |
"jszip": "^3.10.1",
|
| 107 |
+
"lucide-react": "^0.474.0",
|
| 108 |
"nanostores": "^0.10.3",
|
| 109 |
+
"next-themes": "^0.4.4",
|
| 110 |
"ollama-ai-provider": "^0.15.2",
|
| 111 |
"path-browserify": "^1.0.1",
|
| 112 |
"react": "^18.3.1",
|
|
|
|
| 136 |
"@iconify-json/ph": "^1.2.1",
|
| 137 |
"@iconify/types": "^2.0.0",
|
| 138 |
"@remix-run/dev": "^2.15.2",
|
| 139 |
+
"@testing-library/jest-dom": "^6.6.3",
|
| 140 |
+
"@testing-library/react": "^16.2.0",
|
| 141 |
"@types/diff": "^5.2.3",
|
| 142 |
"@types/dom-speech-recognition": "^0.0.4",
|
| 143 |
"@types/file-saver": "^2.0.7",
|
|
|
|
| 145 |
"@types/path-browserify": "^1.0.3",
|
| 146 |
"@types/react": "^18.3.12",
|
| 147 |
"@types/react-dom": "^18.3.1",
|
| 148 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 149 |
"fast-glob": "^3.3.2",
|
| 150 |
"husky": "9.1.7",
|
| 151 |
"is-ci": "^3.0.1",
|
| 152 |
+
"jsdom": "^26.0.0",
|
| 153 |
"node-fetch": "^3.3.2",
|
| 154 |
"pnpm": "^9.14.4",
|
| 155 |
"prettier": "^3.4.1",
|
pnpm-lock.yaml
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|