diff --git a/webview-ui/src/components/chat/BatchDiffApproval.tsx b/webview-ui/src/components/chat/BatchDiffApproval.tsx index 24ad8d489d..e6c4a9bce9 100644 --- a/webview-ui/src/components/chat/BatchDiffApproval.tsx +++ b/webview-ui/src/components/chat/BatchDiffApproval.tsx @@ -1,5 +1,8 @@ import React, { memo, useState } from "react" +import { Eye, Code } from "lucide-react" import CodeAccordian from "../common/CodeAccordian" +import { SideBySideDiffViewer } from "./SideBySideDiffViewer" +import { StandardTooltip } from "@/components/ui" interface FileDiff { path: string @@ -19,6 +22,7 @@ interface BatchDiffApprovalProps { export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProps) => { const [expandedFiles, setExpandedFiles] = useState>({}) + const [viewMode, setViewMode] = useState<"traditional" | "side-by-side">("side-by-side") if (!files?.length) { return null @@ -31,26 +35,62 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp })) } + const renderTraditionalView = () => ( +
+ {files.map((file) => { + // Combine all diffs into a single diff string for this file + const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content + + return ( +
+ handleToggleExpand(file.path)} + /> +
+ ) + })} +
+ ) + return (
-
- {files.map((file) => { - // Combine all diffs into a single diff string for this file - const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content - - return ( -
- handleToggleExpand(file.path)} - /> -
- ) - })} + {/* View mode toggle */} +
+ Diff Preview +
+ + + + + + +
+ + {/* Render based on view mode */} + {viewMode === "side-by-side" ? : renderTraditionalView()}
) }) diff --git a/webview-ui/src/components/chat/SideBySideDiffViewer.tsx b/webview-ui/src/components/chat/SideBySideDiffViewer.tsx new file mode 100644 index 0000000000..a2c102c775 --- /dev/null +++ b/webview-ui/src/components/chat/SideBySideDiffViewer.tsx @@ -0,0 +1,382 @@ +import React, { memo, useState, useMemo } from "react" +import { ChevronDown, ChevronUp, FileText } from "lucide-react" +import { StandardTooltip } from "@/components/ui" +import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" +import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric" + +interface FileDiff { + path: string + changeCount: number + key: string + content: string + diffs?: Array<{ + content: string + startLine?: number + }> +} + +interface SideBySideDiffViewerProps { + files: FileDiff[] + ts: number +} + +interface DiffLine { + type: "unchanged" | "added" | "removed" | "context" + content: string + lineNumber?: number + originalLineNumber?: number + modifiedLineNumber?: number +} + +interface ParsedDiff { + originalContent: string + modifiedContent: string + diffLines: DiffLine[] +} + +// Parse search/replace diff format into structured diff data +const parseDiffContent = (diffContent: string): ParsedDiff => { + const lines = diffContent.split("\n") + let originalContent = "" + let modifiedContent = "" + const diffLines: DiffLine[] = [] + + let currentSection: "search" | "replace" | "none" = "none" + let searchLines: string[] = [] + let replaceLines: string[] = [] + let originalLineNum = 1 + let modifiedLineNum = 1 + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (line.startsWith("<<<<<<< SEARCH")) { + currentSection = "search" + continue + } else if (line === "=======") { + currentSection = "replace" + continue + } else if (line.startsWith(">>>>>>> REPLACE")) { + // Process the accumulated search/replace block + const maxLines = Math.max(searchLines.length, replaceLines.length) + + for (let j = 0; j < maxLines; j++) { + const searchLine = j < searchLines.length ? searchLines[j] : undefined + const replaceLine = j < replaceLines.length ? replaceLines[j] : undefined + + if (searchLine !== undefined && replaceLine !== undefined) { + if (searchLine === replaceLine) { + // Unchanged line + diffLines.push({ + type: "unchanged", + content: searchLine, + originalLineNumber: originalLineNum++, + modifiedLineNumber: modifiedLineNum++, + }) + } else { + // Changed line - show as removed then added + diffLines.push({ + type: "removed", + content: searchLine, + originalLineNumber: originalLineNum++, + modifiedLineNumber: undefined, + }) + diffLines.push({ + type: "added", + content: replaceLine, + originalLineNumber: undefined, + modifiedLineNumber: modifiedLineNum++, + }) + } + } else if (searchLine !== undefined) { + // Line only in original (removed) + diffLines.push({ + type: "removed", + content: searchLine, + originalLineNumber: originalLineNum++, + modifiedLineNumber: undefined, + }) + } else if (replaceLine !== undefined) { + // Line only in modified (added) + diffLines.push({ + type: "added", + content: replaceLine, + originalLineNumber: undefined, + modifiedLineNumber: modifiedLineNum++, + }) + } + } + + // Reset for next block + searchLines = [] + replaceLines = [] + currentSection = "none" + continue + } + + if (currentSection === "search") { + searchLines.push(line) + originalContent += line + "\n" + } else if (currentSection === "replace") { + replaceLines.push(line) + modifiedContent += line + "\n" + } + } + + return { + originalContent: originalContent.trim(), + modifiedContent: modifiedContent.trim(), + diffLines, + } +} + +const DiffLineComponent = memo(({ line, showLineNumbers = true }: { line: DiffLine; showLineNumbers?: boolean }) => { + const getLineStyle = () => { + switch (line.type) { + case "added": + return "bg-vscode-diffEditor-insertedTextBackground text-vscode-diffEditor-insertedTextForeground" + case "removed": + return "bg-vscode-diffEditor-removedTextBackground text-vscode-diffEditor-removedTextForeground" + case "unchanged": + return "bg-vscode-editor-background text-vscode-editor-foreground" + default: + return "bg-vscode-editor-background text-vscode-editor-foreground" + } + } + + const getLinePrefix = () => { + switch (line.type) { + case "added": + return "+" + case "removed": + return "-" + default: + return " " + } + } + + return ( +
+ {showLineNumbers && ( + <> +
+ {line.originalLineNumber || ""} +
+
+ {line.modifiedLineNumber || ""} +
+ + )} +
+ {getLinePrefix()} +
+
{line.content || " "}
+
+ ) +}) + +DiffLineComponent.displayName = "DiffLineComponent" + +const FileDiffViewer = memo( + ({ file, isExpanded, onToggleExpand }: { file: FileDiff; isExpanded: boolean; onToggleExpand: () => void }) => { + const [viewMode, setViewMode] = useState<"unified" | "split">("split") + + const parsedDiff = useMemo(() => { + const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content + return parseDiffContent(combinedDiff) + }, [file]) + + const language = useMemo(() => getLanguageFromPath(file.path), [file.path]) + + const renderUnifiedView = () => ( +
+
+ {/* Header */} +
+
Old
+
New
+
+
{removeLeadingNonAlphanumeric(file.path)}
+
+ + {/* Diff content */} +
+ {parsedDiff.diffLines.map((line, index) => ( + + ))} +
+
+
+ ) + + const renderSplitView = () => ( +
+
+ {/* Header */} +
+
+ Original ({removeLeadingNonAlphanumeric(file.path)}) +
+
+ Modified ({removeLeadingNonAlphanumeric(file.path)}) +
+
+ + {/* Split diff content */} +
+ {/* Original content */} +
+ {parsedDiff.diffLines + .filter((line) => line.type !== "added") + .map((line, index) => ( +
+
+ {line.originalLineNumber || ""} +
+
+ {line.content || " "} +
+
+ ))} +
+ + {/* Modified content */} +
+ {parsedDiff.diffLines + .filter((line) => line.type !== "removed") + .map((line, index) => ( +
+
+ {line.modifiedLineNumber || ""} +
+
+ {line.content || " "} +
+
+ ))} +
+
+
+
+ ) + + return ( +
+ {/* File header */} +
+
+ + + {removeLeadingNonAlphanumeric(file.path)} + + + ({file.changeCount} {file.changeCount === 1 ? "change" : "changes"}) + +
+
+ {isExpanded && ( +
+ + + +
+ )} + {isExpanded ? : } +
+
+ + {/* Diff content */} + {isExpanded && ( +
{viewMode === "split" ? renderSplitView() : renderUnifiedView()}
+ )} +
+ ) + }, +) + +FileDiffViewer.displayName = "FileDiffViewer" + +export const SideBySideDiffViewer = memo(({ files = [], ts }: SideBySideDiffViewerProps) => { + const [expandedFiles, setExpandedFiles] = useState>({}) + const [allExpanded, setAllExpanded] = useState(false) + + if (!files?.length) { + return null + } + + const handleToggleExpand = (filePath: string) => { + setExpandedFiles((prev) => ({ + ...prev, + [filePath]: !prev[filePath], + })) + } + + const handleToggleAll = () => { + const newState = !allExpanded + setAllExpanded(newState) + const newExpandedFiles: Record = {} + files.forEach((file) => { + newExpandedFiles[file.path] = newState + }) + setExpandedFiles(newExpandedFiles) + } + + const totalChanges = files.reduce((sum, file) => sum + file.changeCount, 0) + + return ( +
+ {/* Summary header */} +
+
+ + + {files.length} {files.length === 1 ? "file" : "files"} with {totalChanges}{" "} + {totalChanges === 1 ? "change" : "changes"} + +
+ + + +
+ + {/* File diffs */} +
+ {files.map((file) => ( + handleToggleExpand(file.path)} + /> + ))} +
+
+ ) +}) + +SideBySideDiffViewer.displayName = "SideBySideDiffViewer" diff --git a/webview-ui/src/components/chat/__tests__/SideBySideDiffViewer.spec.tsx b/webview-ui/src/components/chat/__tests__/SideBySideDiffViewer.spec.tsx new file mode 100644 index 0000000000..8d9bb125d6 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/SideBySideDiffViewer.spec.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { SideBySideDiffViewer } from "../SideBySideDiffViewer" + +// Mock the StandardTooltip component +vi.mock("@/components/ui", () => ({ + StandardTooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( +
{children}
+ ), +})) + +// Mock the utility functions +vi.mock("@src/utils/getLanguageFromPath", () => ({ + getLanguageFromPath: vi.fn(() => "javascript"), +})) + +vi.mock("@src/utils/removeLeadingNonAlphanumeric", () => ({ + removeLeadingNonAlphanumeric: vi.fn((path: string) => path), +})) + +describe("SideBySideDiffViewer", () => { + const mockFiles = [ + { + path: "test.js", + changeCount: 2, + key: "test.js (2 changes)", + content: "test content", + diffs: [ + { + content: `<<<<<<< SEARCH +const oldFunction = () => { + return "old"; +} +======= +const newFunction = () => { + return "new"; +} +>>>>>>> REPLACE`, + startLine: 1, + }, + ], + }, + ] + + it("renders without crashing", () => { + render() + expect(screen.getByText("1 file with 2 changes")).toBeInTheDocument() + }) + + it("renders file summary correctly", () => { + render() + expect(screen.getByText("1 file with 2 changes")).toBeInTheDocument() + expect(screen.getByText("Expand All")).toBeInTheDocument() + }) + + it("renders multiple files correctly", () => { + const multipleFiles = [ + ...mockFiles, + { + path: "test2.js", + changeCount: 1, + key: "test2.js (1 change)", + content: "test content 2", + diffs: [ + { + content: `<<<<<<< SEARCH +old line +======= +new line +>>>>>>> REPLACE`, + startLine: 1, + }, + ], + }, + ] + + render() + expect(screen.getByText("2 files with 3 changes")).toBeInTheDocument() + }) + + it("returns null when no files provided", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("handles undefined files gracefully", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index fbb362ca8f..c0c0ed0cb2 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -132,6 +132,27 @@ --color-vscode-textLink-foreground: var(--vscode-textLink-foreground); --color-vscode-textCodeBlock-background: var(--vscode-textCodeBlock-background); --color-vscode-button-hoverBackground: var(--vscode-button-hoverBackground); + + /* Diff editor colors */ + --color-vscode-diffEditor-insertedTextBackground: var(--vscode-diffEditor-insertedTextBackground); + --color-vscode-diffEditor-insertedTextForeground: var( + --vscode-diffEditor-insertedTextForeground, + var(--vscode-editor-foreground) + ); + --color-vscode-diffEditor-removedTextBackground: var(--vscode-diffEditor-removedTextBackground); + --color-vscode-diffEditor-removedTextForeground: var( + --vscode-diffEditor-removedTextForeground, + var(--vscode-editor-foreground) + ); + + /* Editor group and tab colors */ + --color-vscode-editorGroupHeader-tabsBackground: var(--vscode-editorGroupHeader-tabsBackground); + --color-vscode-tab-activeForeground: var(--vscode-tab-activeForeground); + --color-vscode-editorWidget-background: var(--vscode-editorWidget-background); + --color-vscode-editorLineNumber-foreground: var(--vscode-editorLineNumber-foreground); + + /* Symbol icon colors */ + --color-vscode-symbolIcon-fileForeground: var(--vscode-symbolIcon-fileForeground, var(--vscode-foreground)); } @layer base { diff --git a/webview-ui/vitest.config.ts b/webview-ui/vitest.config.ts index b9455584bf..c5e597521a 100644 --- a/webview-ui/vitest.config.ts +++ b/webview-ui/vitest.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ environment: "jsdom", include: ["src/**/*.spec.ts", "src/**/*.spec.tsx"], }, + define: { + "process.env.NODE_ENV": '"development"', + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),