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