mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(chat): Improve diff appearance in main chat view (#8932)
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
parent
d631aa643c
commit
0fd7023ea7
19 changed files with 1141 additions and 57 deletions
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -1020,6 +1020,9 @@ importers:
|
|||
debounce:
|
||||
specifier: ^2.1.1
|
||||
version: 2.2.0
|
||||
diff:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
fast-deep-equal:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
|
|
@ -1153,6 +1156,9 @@ importers:
|
|||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.0)
|
||||
'@types/diff':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.3
|
||||
'@types/jest':
|
||||
specifier: ^29.0.0
|
||||
version: 29.5.14
|
||||
|
|
@ -14020,7 +14026,7 @@ snapshots:
|
|||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
|
|
|
|||
71
src/core/diff/stats.ts
Normal file
71
src/core/diff/stats.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { parsePatch, createTwoFilesPatch } from "diff"
|
||||
|
||||
/**
|
||||
* Diff utilities for backend (extension) use.
|
||||
* Source of truth for diff normalization and stats.
|
||||
*/
|
||||
|
||||
export interface DiffStats {
|
||||
added: number
|
||||
removed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove non-semantic diff noise like "No newline at end of file"
|
||||
*/
|
||||
export function sanitizeUnifiedDiff(diff: string): string {
|
||||
if (!diff) return diff
|
||||
return diff.replace(/\r\n/g, "\n").replace(/(^|\n)[ \t]*(?:\\ )?No newline at end of file[ \t]*(?=\n|$)/gi, "$1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute +/− counts from a unified diff (ignores headers/hunk lines)
|
||||
*/
|
||||
export function computeUnifiedDiffStats(diff?: string): DiffStats | null {
|
||||
if (!diff) return null
|
||||
|
||||
try {
|
||||
const patches = parsePatch(diff)
|
||||
if (!patches || patches.length === 0) return null
|
||||
|
||||
let added = 0
|
||||
let removed = 0
|
||||
|
||||
for (const p of patches) {
|
||||
for (const h of (p as any).hunks ?? []) {
|
||||
for (const l of h.lines ?? []) {
|
||||
const ch = (l as string)[0]
|
||||
if (ch === "+") added++
|
||||
else if (ch === "-") removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (added > 0 || removed > 0) return { added, removed }
|
||||
return { added: 0, removed: 0 }
|
||||
} catch {
|
||||
// If parsing fails for any reason, signal no stats
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute diff stats from any supported diff format (unified or search-replace)
|
||||
* Tries unified diff format first, then falls back to search-replace format
|
||||
*/
|
||||
export function computeDiffStats(diff?: string): DiffStats | null {
|
||||
if (!diff) return null
|
||||
return computeUnifiedDiffStats(diff)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unified diff for a brand new file (all content lines are additions).
|
||||
* Trailing newline is ignored for line counting and emission.
|
||||
*/
|
||||
export function convertNewFileToUnifiedDiff(content: string, filePath?: string): string {
|
||||
const newFileName = filePath || "file"
|
||||
// Normalize EOLs; rely on library for unified patch formatting
|
||||
const normalized = (content || "").replace(/\r\n/g, "\n")
|
||||
// Old file is empty (/dev/null), new file has content; zero context to show all lines as additions
|
||||
return createTwoFilesPatch("/dev/null", newFileName, "", normalized, undefined, undefined, { context: 0 })
|
||||
}
|
||||
|
|
@ -177,7 +177,9 @@ Otherwise, if you have not completed the task and do not need additional informa
|
|||
|
||||
createPrettyPatch: (filename = "file", oldStr?: string, newStr?: string) => {
|
||||
// strings cannot be undefined or diff throws exception
|
||||
const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "")
|
||||
const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "", undefined, undefined, {
|
||||
context: 3,
|
||||
})
|
||||
const lines = patch.split("\n")
|
||||
const prettyPatchLines = lines.slice(4)
|
||||
return prettyPatchLines.join("\n")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { fileExistsAtPath } from "../../utils/fs"
|
|||
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
||||
import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
||||
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
||||
import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
||||
|
||||
export async function applyDiffToolLegacy(
|
||||
cline: Task,
|
||||
|
|
@ -140,6 +141,11 @@ export async function applyDiffToolLegacy(
|
|||
cline.consecutiveMistakeCount = 0
|
||||
cline.consecutiveMistakeCountForApplyDiff.delete(relPath)
|
||||
|
||||
// Generate backend-unified diff for display in chat/webview
|
||||
const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, diffResult.content)
|
||||
const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw)
|
||||
const diffStats = computeDiffStats(unifiedPatch) || undefined
|
||||
|
||||
// Check if preventFocusDisruption experiment is enabled
|
||||
const provider = cline.providerRef.deref()
|
||||
const state = await provider?.getState()
|
||||
|
|
@ -158,6 +164,8 @@ export async function applyDiffToolLegacy(
|
|||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
diff: diffContent,
|
||||
content: unifiedPatch,
|
||||
diffStats,
|
||||
isProtected: isWriteProtected,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
|
|
@ -194,6 +202,8 @@ export async function applyDiffToolLegacy(
|
|||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
diff: diffContent,
|
||||
content: unifiedPatch,
|
||||
diffStats,
|
||||
isProtected: isWriteProtected,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs"
|
|||
import { insertGroups } from "../diff/insert-groups"
|
||||
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
|
||||
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
||||
import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
||||
|
||||
export async function insertContentTool(
|
||||
cline: Task,
|
||||
|
|
@ -101,7 +102,7 @@ export async function insertContentTool(
|
|||
cline.diffViewProvider.originalContent = fileContent
|
||||
const lines = fileExists ? fileContent.split("\n") : []
|
||||
|
||||
const updatedContent = insertGroups(lines, [
|
||||
let updatedContent = insertGroups(lines, [
|
||||
{
|
||||
index: lineNumber - 1,
|
||||
elements: content.split("\n"),
|
||||
|
|
@ -118,31 +119,31 @@ export async function insertContentTool(
|
|||
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
||||
)
|
||||
|
||||
// For consistency with writeToFileTool, handle new files differently
|
||||
let diff: string | undefined
|
||||
let approvalContent: string | undefined
|
||||
|
||||
// Build unified diff for display (normalize EOLs only for diff generation)
|
||||
let unified: string
|
||||
if (fileExists) {
|
||||
// For existing files, generate diff and check for changes
|
||||
diff = formatResponse.createPrettyPatch(relPath, fileContent, updatedContent)
|
||||
if (!diff) {
|
||||
const oldForDiff = fileContent.replace(/\r\n/g, "\n")
|
||||
const newForDiff = updatedContent.replace(/\r\n/g, "\n")
|
||||
unified = formatResponse.createPrettyPatch(relPath, oldForDiff, newForDiff)
|
||||
if (!unified) {
|
||||
pushToolResult(`No changes needed for '${relPath}'`)
|
||||
return
|
||||
}
|
||||
approvalContent = undefined
|
||||
} else {
|
||||
// For new files, skip diff generation and provide full content
|
||||
diff = undefined
|
||||
approvalContent = updatedContent
|
||||
const newForDiff = updatedContent.replace(/\r\n/g, "\n")
|
||||
unified = convertNewFileToUnifiedDiff(newForDiff, relPath)
|
||||
}
|
||||
unified = sanitizeUnifiedDiff(unified)
|
||||
const diffStats = computeDiffStats(unified) || undefined
|
||||
|
||||
// Prepare the approval message (same for both flows)
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
diff,
|
||||
content: approvalContent,
|
||||
// Send unified diff as content for render-only webview
|
||||
content: unified,
|
||||
lineNumber: lineNumber,
|
||||
isProtected: isWriteProtected,
|
||||
diffStats,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
// Show diff view if focus disruption prevention is disabled
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
|||
import { parseXmlForDiff } from "../../utils/xml"
|
||||
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
||||
import { applyDiffToolLegacy } from "./applyDiffTool"
|
||||
import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
||||
|
||||
interface DiffOperation {
|
||||
path: string
|
||||
|
|
@ -282,31 +283,70 @@ Original error: ${errorMessage}`
|
|||
(opResult) => cline.rooProtectedController?.isWriteProtected(opResult.path) || false,
|
||||
)
|
||||
|
||||
// Prepare batch diff data
|
||||
const batchDiffs = operationsToApprove.map((opResult) => {
|
||||
// Stream batch diffs progressively for better UX
|
||||
const batchDiffs: Array<{
|
||||
path: string
|
||||
changeCount: number
|
||||
key: string
|
||||
content: string
|
||||
diffStats?: { added: number; removed: number }
|
||||
diffs?: Array<{ content: string; startLine?: number }>
|
||||
}> = []
|
||||
|
||||
for (const opResult of operationsToApprove) {
|
||||
const readablePath = getReadablePath(cline.cwd, opResult.path)
|
||||
const changeCount = opResult.diffItems?.length || 0
|
||||
const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes`
|
||||
|
||||
return {
|
||||
let unified = ""
|
||||
try {
|
||||
const original = await fs.readFile(opResult.absolutePath!, "utf-8")
|
||||
const processed = !cline.api.getModel().id.includes("claude")
|
||||
? (opResult.diffItems || []).map((item) => ({
|
||||
...item,
|
||||
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
|
||||
}))
|
||||
: opResult.diffItems || []
|
||||
|
||||
const applyRes =
|
||||
(await cline.diffStrategy?.applyDiff(original, processed)) ?? ({ success: false } as any)
|
||||
const newContent = applyRes.success && applyRes.content ? applyRes.content : original
|
||||
unified = formatResponse.createPrettyPatch(opResult.path, original, newContent)
|
||||
} catch {
|
||||
unified = ""
|
||||
}
|
||||
|
||||
const unifiedSanitized = sanitizeUnifiedDiff(unified)
|
||||
const stats = computeDiffStats(unifiedSanitized) || undefined
|
||||
batchDiffs.push({
|
||||
path: readablePath,
|
||||
changeCount,
|
||||
key: `${readablePath} (${changeText})`,
|
||||
content: opResult.path, // Full relative path
|
||||
content: unifiedSanitized,
|
||||
diffStats: stats,
|
||||
diffs: opResult.diffItems?.map((item) => ({
|
||||
content: item.content,
|
||||
startLine: item.startLine,
|
||||
})),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Send a partial update after each file preview is ready
|
||||
const partialMessage = JSON.stringify({
|
||||
tool: "appliedDiff",
|
||||
batchDiffs,
|
||||
isProtected: hasProtectedFiles,
|
||||
} satisfies ClineSayTool)
|
||||
await cline.ask("tool", partialMessage, true).catch(() => {})
|
||||
}
|
||||
|
||||
// Final approval message (non-partial)
|
||||
const completeMessage = JSON.stringify({
|
||||
tool: "appliedDiff",
|
||||
batchDiffs,
|
||||
isProtected: hasProtectedFiles,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
const { response, text, images } = await cline.ask("tool", completeMessage, hasProtectedFiles)
|
||||
const { response, text, images } = await cline.ask("tool", completeMessage, false)
|
||||
|
||||
// Process batch response
|
||||
if (response === "yesButtonClicked") {
|
||||
|
|
@ -418,6 +458,7 @@ Original error: ${errorMessage}`
|
|||
|
||||
try {
|
||||
let originalContent: string | null = await fs.readFile(absolutePath, "utf-8")
|
||||
let beforeContent: string | null = originalContent
|
||||
let successCount = 0
|
||||
let formattedError = ""
|
||||
|
||||
|
|
@ -540,9 +581,13 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""}
|
|||
if (operationsToApprove.length === 1) {
|
||||
// Prepare common data for single file operation
|
||||
const diffContents = diffItems.map((item) => item.content).join("\n\n")
|
||||
const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!)
|
||||
const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw)
|
||||
const operationMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
diff: diffContents,
|
||||
content: unifiedPatch,
|
||||
diffStats: computeDiffStats(unifiedPatch) || undefined,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
let toolProgressStatus
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { detectCodeOmission } from "../../integrations/editor/detect-omission"
|
|||
import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
||||
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
|
||||
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
||||
import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
||||
|
||||
export async function writeToFileTool(
|
||||
cline: Task,
|
||||
|
|
@ -173,6 +174,15 @@ export async function writeToFileTool(
|
|||
|
||||
if (isPreventFocusDisruptionEnabled) {
|
||||
// Direct file write without diff view
|
||||
// Set up diffViewProvider properties needed for diff generation and saveDirectly
|
||||
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
|
||||
if (fileExists) {
|
||||
const absolutePath = path.resolve(cline.cwd, relPath)
|
||||
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
|
||||
} else {
|
||||
cline.diffViewProvider.originalContent = ""
|
||||
}
|
||||
|
||||
// Check for code omissions before proceeding
|
||||
if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
|
||||
if (cline.diffStrategy) {
|
||||
|
|
@ -202,9 +212,15 @@ export async function writeToFileTool(
|
|||
}
|
||||
}
|
||||
|
||||
// Build unified diff for both existing and new files
|
||||
let unified = fileExists
|
||||
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
|
||||
: convertNewFileToUnifiedDiff(newContent, relPath)
|
||||
unified = sanitizeUnifiedDiff(unified)
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: newContent,
|
||||
content: unified,
|
||||
diffStats: computeDiffStats(unified) || undefined,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
||||
|
|
@ -213,15 +229,6 @@ export async function writeToFileTool(
|
|||
return
|
||||
}
|
||||
|
||||
// Set up diffViewProvider properties needed for saveDirectly
|
||||
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
|
||||
if (fileExists) {
|
||||
const absolutePath = path.resolve(cline.cwd, relPath)
|
||||
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
|
||||
} else {
|
||||
cline.diffViewProvider.originalContent = ""
|
||||
}
|
||||
|
||||
// Save directly without showing diff view or opening the file
|
||||
await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
|
||||
} else {
|
||||
|
|
@ -275,12 +282,15 @@ export async function writeToFileTool(
|
|||
}
|
||||
}
|
||||
|
||||
// Build unified diff for both existing and new files
|
||||
let unified = fileExists
|
||||
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
|
||||
: convertNewFileToUnifiedDiff(newContent, relPath)
|
||||
unified = sanitizeUnifiedDiff(unified)
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: fileExists ? undefined : newContent,
|
||||
diff: fileExists
|
||||
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
|
||||
: undefined,
|
||||
content: unified,
|
||||
diffStats: computeDiffStats(unified) || undefined,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ export interface ClineSayTool {
|
|||
path?: string
|
||||
diff?: string
|
||||
content?: string
|
||||
// Unified diff statistics computed by the extension
|
||||
diffStats?: { added: number; removed: number }
|
||||
regex?: string
|
||||
filePattern?: string
|
||||
mode?: string
|
||||
|
|
@ -407,6 +409,8 @@ export interface ClineSayTool {
|
|||
changeCount: number
|
||||
key: string
|
||||
content: string
|
||||
// Per-file unified diff statistics computed by the extension
|
||||
diffStats?: { added: number; removed: number }
|
||||
diffs?: Array<{
|
||||
content: string
|
||||
startLine?: number
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"debounce": "^2.1.1",
|
||||
"diff": "^5.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fzf": "^0.5.2",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
|
|
@ -87,6 +88,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "20.x",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface FileDiff {
|
|||
changeCount: number
|
||||
key: string
|
||||
content: string
|
||||
diffStats?: { added: number; removed: number }
|
||||
diffs?: Array<{
|
||||
content: string
|
||||
startLine?: number
|
||||
|
|
@ -35,17 +36,18 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
|
|||
<div className="pt-[5px]">
|
||||
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
|
||||
{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
|
||||
// Use backend-provided unified diff only. Stats also provided by backend.
|
||||
const unified = file.content || ""
|
||||
|
||||
return (
|
||||
<div key={`${file.path}-${ts}`}>
|
||||
<CodeAccordian
|
||||
path={file.path}
|
||||
code={combinedDiff}
|
||||
code={unified}
|
||||
language="diff"
|
||||
isExpanded={expandedFiles[file.path] || false}
|
||||
onToggleExpand={() => handleToggleExpand(file.path)}
|
||||
diffStats={file.diffStats ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
|
|||
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { formatPathTooltip } from "@src/utils/formatPathTooltip"
|
||||
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
|
||||
|
||||
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
|
||||
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
|
||||
|
|
@ -336,6 +335,12 @@ export const ChatRowContent = ({
|
|||
[message.ask, message.text],
|
||||
)
|
||||
|
||||
// Unified diff content (provided by backend when relevant)
|
||||
const unifiedDiff = useMemo(() => {
|
||||
if (!tool) return undefined
|
||||
return (tool.content ?? tool.diff) as string | undefined
|
||||
}, [tool])
|
||||
|
||||
const followUpData = useMemo(() => {
|
||||
if (message.type === "ask" && message.ask === "followup" && !message.partial) {
|
||||
return safeJsonParse<FollowUpData>(message.text)
|
||||
|
|
@ -350,7 +355,7 @@ export const ChatRowContent = ({
|
|||
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
switch (tool.tool as string) {
|
||||
case "editedExistingFile":
|
||||
case "appliedDiff":
|
||||
// Check if this is a batch diff request
|
||||
|
|
@ -391,12 +396,13 @@ export const ChatRowContent = ({
|
|||
<div className="pl-6">
|
||||
<CodeAccordian
|
||||
path={tool.path}
|
||||
code={tool.content ?? tool.diff}
|
||||
code={unifiedDiff ?? tool.content ?? tool.diff}
|
||||
language="diff"
|
||||
progressStatus={message.progressStatus}
|
||||
isLoading={message.partial}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
diffStats={tool.diffStats}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -428,12 +434,47 @@ export const ChatRowContent = ({
|
|||
<div className="pl-6">
|
||||
<CodeAccordian
|
||||
path={tool.path}
|
||||
code={tool.diff}
|
||||
code={unifiedDiff ?? tool.diff}
|
||||
language="diff"
|
||||
progressStatus={message.progressStatus}
|
||||
isLoading={message.partial}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
diffStats={tool.diffStats}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "searchAndReplace":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{tool.isProtected ? (
|
||||
<span
|
||||
className="codicon codicon-lock"
|
||||
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
) : (
|
||||
toolIcon("replace")
|
||||
)}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{tool.isProtected && message.type === "ask"
|
||||
? t("chat:fileOperations.wantsToEditProtected")
|
||||
: message.type === "ask"
|
||||
? t("chat:fileOperations.wantsToSearchReplace")
|
||||
: t("chat:fileOperations.didSearchReplace")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-6">
|
||||
<CodeAccordian
|
||||
path={tool.path}
|
||||
code={unifiedDiff ?? tool.diff}
|
||||
language="diff"
|
||||
progressStatus={message.progressStatus}
|
||||
isLoading={message.partial}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
diffStats={tool.diffStats}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -465,7 +506,7 @@ export const ChatRowContent = ({
|
|||
return (
|
||||
<UpdateTodoListToolBlock
|
||||
todos={todos}
|
||||
content={(tool as any).content}
|
||||
content={tool.content}
|
||||
onChange={(updatedTodos) => {
|
||||
if (typeof vscode !== "undefined" && vscode?.postMessage) {
|
||||
vscode.postMessage({ type: "updateTodoList", payload: { todos: updatedTodos } })
|
||||
|
|
@ -496,12 +537,13 @@ export const ChatRowContent = ({
|
|||
<div className="pl-6">
|
||||
<CodeAccordian
|
||||
path={tool.path}
|
||||
code={tool.content}
|
||||
language={getLanguageFromPath(tool.path || "") || "log"}
|
||||
code={unifiedDiff ?? ""}
|
||||
language="diff"
|
||||
isLoading={message.partial}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })}
|
||||
diffStats={tool.diffStats}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
// Reset UI states
|
||||
// Reset UI states only when task changes
|
||||
setExpandedRows({})
|
||||
everVisibleMessagesTsRef.current.clear() // Clear for new task
|
||||
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import React from "react"
|
||||
import { render, screen } from "@/utils/test-utils"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
import { ChatRowContent } from "../ChatRow"
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
"chat:fileOperations.wantsToEdit": "Roo wants to edit this file",
|
||||
}
|
||||
return map[key] || key
|
||||
},
|
||||
}),
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}))
|
||||
|
||||
// Mock CodeBlock (avoid ESM/highlighter costs)
|
||||
vi.mock("@src/components/common/CodeBlock", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function renderChatRow(message: any, isExpanded = false) {
|
||||
return render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatRowContent
|
||||
message={message}
|
||||
isExpanded={isExpanded}
|
||||
isLast={false}
|
||||
isStreaming={false}
|
||||
onToggleExpand={() => {}}
|
||||
onSuggestionClick={() => {}}
|
||||
onBatchFileResponse={() => {}}
|
||||
onFollowUpUnmount={() => {}}
|
||||
isFollowUpAnswered={false}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("ChatRow - inline diff stats and actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("shows + and - counts for editedExistingFile ask", () => {
|
||||
const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n"
|
||||
const message: any = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
partial: false,
|
||||
text: JSON.stringify({
|
||||
tool: "editedExistingFile",
|
||||
path: "src/file.ts",
|
||||
diff,
|
||||
diffStats: { added: 1, removed: 1 },
|
||||
}),
|
||||
}
|
||||
|
||||
renderChatRow(message, false)
|
||||
|
||||
// Plus/minus counts
|
||||
expect(screen.getByText("+1")).toBeInTheDocument()
|
||||
expect(screen.getByText("-1")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("derives counts from searchAndReplace diff", () => {
|
||||
const diff = "-a\n-b\n+c\n"
|
||||
const message: any = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
partial: false,
|
||||
text: JSON.stringify({
|
||||
tool: "searchAndReplace",
|
||||
path: "src/file.ts",
|
||||
diff,
|
||||
diffStats: { added: 1, removed: 2 },
|
||||
}),
|
||||
}
|
||||
|
||||
renderChatRow(message)
|
||||
|
||||
expect(screen.getByText("+1")).toBeInTheDocument()
|
||||
expect(screen.getByText("-2")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("counts only added lines for newFileCreated (ignores diff headers)", () => {
|
||||
const content = "a\nb\nc"
|
||||
const message: any = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
partial: false,
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: "src/new-file.ts",
|
||||
content,
|
||||
diffStats: { added: 3, removed: 0 },
|
||||
}),
|
||||
}
|
||||
|
||||
renderChatRow(message)
|
||||
|
||||
// Should only count the three content lines as additions
|
||||
expect(screen.getByText("+3")).toBeInTheDocument()
|
||||
expect(screen.getByText("-0")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("counts only added lines for newFileCreated with trailing newline", () => {
|
||||
const content = "a\nb\nc\n"
|
||||
const message: any = {
|
||||
type: "ask",
|
||||
ask: "tool",
|
||||
ts: Date.now(),
|
||||
partial: false,
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: "src/new-file.ts",
|
||||
content,
|
||||
diffStats: { added: 3, removed: 0 },
|
||||
}),
|
||||
}
|
||||
|
||||
renderChatRow(message)
|
||||
|
||||
// Trailing newline should not increase the added count
|
||||
expect(screen.getByText("+3")).toBeInTheDocument()
|
||||
expect(screen.getByText("-0")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ import { formatPathTooltip } from "@src/utils/formatPathTooltip"
|
|||
import { ToolUseBlock, ToolUseBlockHeader } from "./ToolUseBlock"
|
||||
import CodeBlock from "./CodeBlock"
|
||||
import { PathTooltip } from "../ui/PathTooltip"
|
||||
import DiffView from "./DiffView"
|
||||
|
||||
interface CodeAccordianProps {
|
||||
path?: string
|
||||
|
|
@ -19,6 +20,8 @@ interface CodeAccordianProps {
|
|||
onToggleExpand: () => void
|
||||
header?: string
|
||||
onJumpToFile?: () => void
|
||||
// New props for diff stats
|
||||
diffStats?: { added: number; removed: number }
|
||||
}
|
||||
|
||||
const CodeAccordian = ({
|
||||
|
|
@ -32,11 +35,20 @@ const CodeAccordian = ({
|
|||
onToggleExpand,
|
||||
header,
|
||||
onJumpToFile,
|
||||
diffStats,
|
||||
}: CodeAccordianProps) => {
|
||||
const inferredLanguage = useMemo(() => language ?? (path ? getLanguageFromPath(path) : "txt"), [path, language])
|
||||
const source = useMemo(() => code.trim(), [code])
|
||||
const hasHeader = Boolean(path || isFeedback || header)
|
||||
|
||||
// Use provided diff stats only (render-only)
|
||||
const derivedStats = useMemo(() => {
|
||||
if (diffStats && (diffStats.added > 0 || diffStats.removed > 0)) return diffStats
|
||||
return null
|
||||
}, [diffStats])
|
||||
|
||||
const hasValidStats = Boolean(derivedStats && (derivedStats.added > 0 || derivedStats.removed > 0))
|
||||
|
||||
return (
|
||||
<ToolUseBlock>
|
||||
{hasHeader && (
|
||||
|
|
@ -67,13 +79,24 @@ const CodeAccordian = ({
|
|||
</>
|
||||
)}
|
||||
<div className="flex-grow-1" />
|
||||
{progressStatus && progressStatus.text && (
|
||||
<>
|
||||
{progressStatus.icon && <span className={`codicon codicon-${progressStatus.icon} mr-1`} />}
|
||||
<span className="mr-1 ml-auto text-vscode-descriptionForeground">
|
||||
{progressStatus.text}
|
||||
</span>
|
||||
</>
|
||||
{/* Prefer diff stats over generic progress indicator if available */}
|
||||
{hasValidStats ? (
|
||||
<div className="flex items-center gap-2 mr-1">
|
||||
<span className="text-xs font-medium text-vscode-charts-green">+{derivedStats!.added}</span>
|
||||
<span className="text-xs font-medium text-vscode-charts-red">-{derivedStats!.removed}</span>
|
||||
</div>
|
||||
) : (
|
||||
progressStatus &&
|
||||
progressStatus.text && (
|
||||
<>
|
||||
{progressStatus.icon && (
|
||||
<span className={`codicon codicon-${progressStatus.icon} mr-1`} />
|
||||
)}
|
||||
<span className="mr-1 ml-auto text-vscode-descriptionForeground">
|
||||
{progressStatus.text}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{onJumpToFile && path && (
|
||||
<span
|
||||
|
|
@ -93,8 +116,12 @@ const CodeAccordian = ({
|
|||
</ToolUseBlockHeader>
|
||||
)}
|
||||
{(!hasHeader || isExpanded) && (
|
||||
<div className="overflow-x-auto overflow-y-hidden max-w-full">
|
||||
<CodeBlock source={source} language={inferredLanguage} />
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[300px] max-w-full">
|
||||
{inferredLanguage === "diff" ? (
|
||||
<DiffView source={source} filePath={path} />
|
||||
) : (
|
||||
<CodeBlock source={source} language={inferredLanguage} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
|
|
|
|||
247
webview-ui/src/components/common/DiffView.tsx
Normal file
247
webview-ui/src/components/common/DiffView.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { memo, useMemo, useEffect, useState } from "react"
|
||||
import { parseUnifiedDiff, type DiffLine } from "@src/utils/parseUnifiedDiff"
|
||||
import { normalizeLanguage } from "@src/utils/highlighter"
|
||||
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"
|
||||
import { highlightHunks } from "@src/utils/highlightDiff"
|
||||
|
||||
interface DiffViewProps {
|
||||
source: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
// Interface for hunk data
|
||||
interface Hunk {
|
||||
lines: DiffLine[]
|
||||
oldText: string
|
||||
newText: string
|
||||
highlightedOldLines?: React.ReactNode[]
|
||||
highlightedNewLines?: React.ReactNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffView component renders unified diffs with side-by-side line numbers
|
||||
* matching VSCode's diff editor style
|
||||
*/
|
||||
const DiffView = memo(({ source, filePath }: DiffViewProps) => {
|
||||
// Determine language from file path
|
||||
const normalizedLang = useMemo(() => normalizeLanguage(getLanguageFromPath(filePath || "") || "txt"), [filePath])
|
||||
|
||||
const isLightTheme = useMemo(() => {
|
||||
if (typeof document === "undefined") return false
|
||||
const cls = document.body.className
|
||||
return /\bvscode-light\b|\bvscode-high-contrast-light\b/i.test(cls)
|
||||
}, [])
|
||||
|
||||
// Disable syntax highlighting for large diffs (performance optimization)
|
||||
const shouldHighlight = useMemo(() => {
|
||||
const lineCount = source.split("\n").length
|
||||
return lineCount <= 1000 // Only highlight diffs with <= 1000 lines
|
||||
}, [source])
|
||||
|
||||
// Parse diff and group into hunks
|
||||
const diffLines = useMemo(() => parseUnifiedDiff(source, filePath), [source, filePath])
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
const result: Hunk[] = []
|
||||
let currentHunk: DiffLine[] = []
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === "gap") {
|
||||
// Finish current hunk if it has content
|
||||
if (currentHunk.length > 0) {
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
|
||||
for (const hunkLine of currentHunk) {
|
||||
if (hunkLine.type === "deletion" || hunkLine.type === "context") {
|
||||
oldLines.push(hunkLine.content)
|
||||
}
|
||||
if (hunkLine.type === "addition" || hunkLine.type === "context") {
|
||||
newLines.push(hunkLine.content)
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
lines: [...currentHunk],
|
||||
oldText: oldLines.join("\n"),
|
||||
newText: newLines.join("\n"),
|
||||
})
|
||||
}
|
||||
|
||||
// Start new hunk with the gap
|
||||
currentHunk = [line]
|
||||
} else {
|
||||
currentHunk.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last hunk if it has content
|
||||
if (currentHunk.length > 0 && currentHunk.some((line) => line.type !== "gap")) {
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
|
||||
for (const hunkLine of currentHunk) {
|
||||
if (hunkLine.type === "deletion" || hunkLine.type === "context") {
|
||||
oldLines.push(hunkLine.content)
|
||||
}
|
||||
if (hunkLine.type === "addition" || hunkLine.type === "context") {
|
||||
newLines.push(hunkLine.content)
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
lines: [...currentHunk],
|
||||
oldText: oldLines.join("\n"),
|
||||
newText: newLines.join("\n"),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [diffLines])
|
||||
|
||||
// State for the processed hunks with highlighting
|
||||
const [processedHunks, setProcessedHunks] = useState<Hunk[]>(hunks)
|
||||
|
||||
// Effect to handle async highlighting
|
||||
useEffect(() => {
|
||||
if (!shouldHighlight) {
|
||||
setProcessedHunks(hunks)
|
||||
return
|
||||
}
|
||||
|
||||
const processHunks = async () => {
|
||||
const processed: Hunk[] = []
|
||||
|
||||
for (let i = 0; i < hunks.length; i++) {
|
||||
const hunk = hunks[i]
|
||||
try {
|
||||
const highlighted = await highlightHunks(
|
||||
hunk.oldText,
|
||||
hunk.newText,
|
||||
normalizedLang,
|
||||
isLightTheme ? "light" : "dark",
|
||||
i,
|
||||
filePath,
|
||||
)
|
||||
processed.push({
|
||||
...hunk,
|
||||
highlightedOldLines: highlighted.oldLines,
|
||||
highlightedNewLines: highlighted.newLines,
|
||||
})
|
||||
} catch {
|
||||
// Fall back to unhighlighted on error
|
||||
processed.push(hunk)
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedHunks(processed)
|
||||
}
|
||||
|
||||
processHunks()
|
||||
}, [hunks, shouldHighlight, normalizedLang, isLightTheme, filePath])
|
||||
|
||||
// Render helper that uses precomputed highlighting
|
||||
const renderContent = (line: DiffLine, hunk: Hunk, lineIndexInHunk: number): React.ReactNode => {
|
||||
if (!shouldHighlight || !hunk.highlightedOldLines || !hunk.highlightedNewLines) {
|
||||
return line.content
|
||||
}
|
||||
|
||||
// Find the line index within the old/new text for this hunk
|
||||
const hunkLinesBeforeThis = hunk.lines.slice(0, lineIndexInHunk).filter((l) => l.type !== "gap")
|
||||
|
||||
if (line.type === "deletion") {
|
||||
// Count deletions and context lines before this line
|
||||
const oldLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "deletion" || l.type === "context").length
|
||||
return hunk.highlightedOldLines[oldLineIndex] || line.content
|
||||
} else if (line.type === "addition") {
|
||||
// Count additions and context lines before this line
|
||||
const newLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "addition" || l.type === "context").length
|
||||
return hunk.highlightedNewLines[newLineIndex] || line.content
|
||||
} else if (line.type === "context") {
|
||||
// For context lines, prefer new-side highlighting, fall back to old-side
|
||||
const newLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "addition" || l.type === "context").length
|
||||
const oldLineIndex = hunkLinesBeforeThis.filter((l) => l.type === "deletion" || l.type === "context").length
|
||||
return hunk.highlightedNewLines[newLineIndex] || hunk.highlightedOldLines[oldLineIndex] || line.content
|
||||
}
|
||||
|
||||
return line.content
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="diff-view bg-[var(--vscode-editor-background)] rounded-md overflow-hidden text-[0.95em]">
|
||||
<div className="overflow-x-hidden">
|
||||
<table className="w-full border-collapse table-auto">
|
||||
<tbody>
|
||||
{processedHunks.flatMap((hunk, hunkIndex) =>
|
||||
hunk.lines.map((line, lineIndex) => {
|
||||
const globalIndex = `${hunkIndex}-${lineIndex}`
|
||||
|
||||
// Render compact separator between hunks
|
||||
if (line.type === "gap") {
|
||||
return (
|
||||
<tr key={globalIndex}>
|
||||
<td className="w-[45px] text-right pr-3 pl-2 select-none align-top whitespace-nowrap bg-[var(--vscode-editor-background)]" />
|
||||
<td className="w-[45px] text-right pr-3 select-none align-top whitespace-nowrap bg-[var(--vscode-editor-background)]" />
|
||||
<td className="w-[12px] align-top bg-[var(--vscode-editor-background)]" />
|
||||
{/* +/- column (empty for gap) */}
|
||||
<td className="w-[16px] text-center select-none bg-[var(--vscode-editor-background)]" />
|
||||
<td className="pr-3 whitespace-pre-wrap break-words w-full italic bg-[var(--vscode-editor-background)]">
|
||||
{`${line.hiddenCount ?? 0} hidden lines`}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// Use VSCode's built-in diff editor color variables as classes for gutters
|
||||
const gutterBgClass =
|
||||
line.type === "addition"
|
||||
? "bg-[var(--vscode-diffEditor-insertedTextBackground)]"
|
||||
: line.type === "deletion"
|
||||
? "bg-[var(--vscode-diffEditor-removedTextBackground)]"
|
||||
: "bg-[var(--vscode-editorGroup-border)]"
|
||||
|
||||
const contentBgClass =
|
||||
line.type === "addition"
|
||||
? "diff-content-inserted"
|
||||
: line.type === "deletion"
|
||||
? "diff-content-removed"
|
||||
: "diff-content-context"
|
||||
|
||||
const sign = line.type === "addition" ? "+" : line.type === "deletion" ? "-" : ""
|
||||
|
||||
return (
|
||||
<tr key={globalIndex}>
|
||||
{/* Old line number */}
|
||||
<td
|
||||
className={`w-[45px] text-right pr-1 pl-1 select-none align-top whitespace-nowrap ${gutterBgClass}`}>
|
||||
{line.oldLineNum || ""}
|
||||
</td>
|
||||
{/* New line number */}
|
||||
<td
|
||||
className={`w-[45px] text-right pr-1 select-none align-top whitespace-nowrap ${gutterBgClass}`}>
|
||||
{line.newLineNum || ""}
|
||||
</td>
|
||||
{/* Narrow colored gutter */}
|
||||
<td className={`w-[12px] ${gutterBgClass} align-top`} />
|
||||
{/* +/- fixed column to prevent wrapping into it */}
|
||||
<td
|
||||
className={`w-[16px] text-center select-none whitespace-nowrap px-1 ${gutterBgClass}`}>
|
||||
{sign}
|
||||
</td>
|
||||
{/* Code content (no +/- prefix here) */}
|
||||
<td
|
||||
className={`pl-1 pr-3 whitespace-pre-wrap break-words w-full ${contentBgClass}`}>
|
||||
{renderContent(line, hunk, lineIndex)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default DiffView
|
||||
|
|
@ -124,6 +124,7 @@
|
|||
--color-vscode-titleBar-inactiveForeground: var(--vscode-titleBar-inactiveForeground);
|
||||
|
||||
--color-vscode-charts-green: var(--vscode-charts-green);
|
||||
--color-vscode-charts-red: var(--vscode-charts-red);
|
||||
--color-vscode-charts-yellow: var(--vscode-charts-yellow);
|
||||
|
||||
--color-vscode-inputValidation-infoForeground: var(--vscode-inputValidation-infoForeground);
|
||||
|
|
@ -490,3 +491,28 @@ input[cmdk-input]:focus {
|
|||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* DiffView code font: use VS Code editor font and enable ligatures */
|
||||
.diff-view,
|
||||
.diff-view pre,
|
||||
.diff-view code,
|
||||
.diff-view .hljs {
|
||||
font-family:
|
||||
var(--vscode-editor-font-family), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
font-variant-ligatures: contextual;
|
||||
font-feature-settings:
|
||||
"calt" 1,
|
||||
"liga" 1;
|
||||
}
|
||||
|
||||
/* DiffView background tints via CSS classes instead of inline styles */
|
||||
.diff-content-inserted {
|
||||
background-color: color-mix(in srgb, var(--vscode-diffEditor-insertedTextBackground) 70%, transparent);
|
||||
}
|
||||
.diff-content-removed {
|
||||
background-color: color-mix(in srgb, var(--vscode-diffEditor-removedTextBackground) 70%, transparent);
|
||||
}
|
||||
.diff-content-context {
|
||||
background-color: color-mix(in srgb, var(--vscode-editorGroup-border) 100%, transparent);
|
||||
}
|
||||
|
|
|
|||
223
webview-ui/src/utils/__tests__/highlightDiff.spec.ts
Normal file
223
webview-ui/src/utils/__tests__/highlightDiff.spec.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { highlightHunks } from "../highlightDiff"
|
||||
import { getHighlighter } from "../highlighter"
|
||||
|
||||
// Mock the highlighter
|
||||
vi.mock("../highlighter", () => ({
|
||||
getHighlighter: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock hast-util-to-jsx-runtime
|
||||
vi.mock("hast-util-to-jsx-runtime", () => ({
|
||||
toJsxRuntime: vi.fn((node, _options) => {
|
||||
// Simple mock that returns a string representation
|
||||
if (node.children) {
|
||||
return node.children
|
||||
.map((child: any) => {
|
||||
if (child.type === "text") {
|
||||
return child.value
|
||||
}
|
||||
return `<span>${child.value || ""}</span>`
|
||||
})
|
||||
.join("")
|
||||
}
|
||||
return node.value || "highlighted-content"
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockHighlighter = {
|
||||
codeToHast: vi.fn((text: string, options: any) => ({
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
tagName: "code",
|
||||
properties: { class: `hljs language-${options.lang}` },
|
||||
children: text.split("\n").map((line) => ({
|
||||
tagName: "span",
|
||||
properties: { className: ["line"] },
|
||||
children: [{ type: "text", value: `highlighted(${line})` }],
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(getHighlighter as any).mockResolvedValue(mockHighlighter)
|
||||
})
|
||||
|
||||
describe("highlightHunks", () => {
|
||||
it("should highlight simple old and new text", async () => {
|
||||
const result = await highlightHunks(
|
||||
"const x = 1\nconsole.log(x)",
|
||||
"const x = 2\nconsole.log(x)",
|
||||
"javascript",
|
||||
"light",
|
||||
)
|
||||
|
||||
expect(result.oldLines).toHaveLength(2)
|
||||
expect(result.newLines).toHaveLength(2)
|
||||
expect(getHighlighter).toHaveBeenCalledWith("javascript")
|
||||
expect(mockHighlighter.codeToHast).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should handle empty text", async () => {
|
||||
const result = await highlightHunks("", "", "javascript", "light")
|
||||
|
||||
expect(result.oldLines).toEqual([""])
|
||||
expect(result.newLines).toEqual([""])
|
||||
})
|
||||
|
||||
it("should handle single-line text", async () => {
|
||||
const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "dark")
|
||||
|
||||
expect(result.oldLines).toHaveLength(1)
|
||||
expect(result.newLines).toHaveLength(1)
|
||||
expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
|
||||
"const x = 1",
|
||||
expect.objectContaining({
|
||||
lang: "javascript",
|
||||
theme: "github-dark",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multi-line text with different lengths", async () => {
|
||||
const oldText = "line1\nline2\nline3"
|
||||
const newText = "line1\nmodified line2"
|
||||
|
||||
const result = await highlightHunks(oldText, newText, "txt", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(3)
|
||||
expect(result.newLines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should map light theme to github-light", async () => {
|
||||
await highlightHunks("test", "test", "javascript", "light")
|
||||
|
||||
expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
|
||||
"test",
|
||||
expect.objectContaining({
|
||||
theme: "github-light",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should map dark theme to github-dark", async () => {
|
||||
await highlightHunks("test", "test", "javascript", "dark")
|
||||
|
||||
expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
|
||||
"test",
|
||||
expect.objectContaining({
|
||||
theme: "github-dark",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use correct transformers", async () => {
|
||||
await highlightHunks("test", "test", "javascript", "light")
|
||||
|
||||
expect(mockHighlighter.codeToHast).toHaveBeenCalledWith(
|
||||
"test",
|
||||
expect.objectContaining({
|
||||
transformers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pre: expect.any(Function),
|
||||
code: expect.any(Function),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle highlighting errors gracefully", async () => {
|
||||
mockHighlighter.codeToHast.mockImplementation(() => {
|
||||
throw new Error("Highlighting failed")
|
||||
})
|
||||
|
||||
const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "light")
|
||||
|
||||
// Should fall back to plain text
|
||||
expect(result.oldLines).toEqual(["const x = 1"])
|
||||
expect(result.newLines).toEqual(["const x = 2"])
|
||||
})
|
||||
|
||||
it("should handle getHighlighter rejection", async () => {
|
||||
;(getHighlighter as any).mockRejectedValueOnce(new Error("Highlighter failed"))
|
||||
|
||||
const result = await highlightHunks("const x = 1", "const x = 2", "javascript", "light")
|
||||
|
||||
// Should fall back to plain text
|
||||
expect(result.oldLines).toEqual(["const x = 1"])
|
||||
expect(result.newLines).toEqual(["const x = 2"])
|
||||
})
|
||||
|
||||
it("should handle text with trailing newlines", async () => {
|
||||
const result = await highlightHunks("line1\nline2\n", "line1\nline2\n", "txt", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(3) // Including empty line from trailing newline
|
||||
expect(result.newLines).toHaveLength(3)
|
||||
// The empty line at the end is preserved as-is (performance optimization)
|
||||
expect(result.oldLines[2]).toBe("")
|
||||
expect(result.newLines[2]).toBe("")
|
||||
})
|
||||
|
||||
it("should preserve whitespace-only lines", async () => {
|
||||
const result = await highlightHunks("line1\n \nline3", "line1\n\t\nline3", "txt", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(3)
|
||||
expect(result.newLines).toHaveLength(3)
|
||||
// Whitespace-only lines are preserved as-is (performance optimization)
|
||||
expect(result.oldLines[1]).toBe(" ")
|
||||
expect(result.newLines[1]).toBe("\t")
|
||||
})
|
||||
})
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle typical single hunk scenario", async () => {
|
||||
const oldText = "function hello() {\n console.log('old')\n}"
|
||||
const newText = "function hello() {\n console.log('new')\n}"
|
||||
|
||||
const result = await highlightHunks(oldText, newText, "javascript", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(3)
|
||||
expect(result.newLines).toHaveLength(3)
|
||||
// Each line should be processed by the highlighter
|
||||
result.oldLines.forEach((line) => {
|
||||
expect(typeof line === "string" || typeof line === "object").toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle addition-only hunk", async () => {
|
||||
const oldText = ""
|
||||
const newText = "// New comment\nconst x = 1"
|
||||
|
||||
const result = await highlightHunks(oldText, newText, "javascript", "light")
|
||||
|
||||
expect(result.oldLines).toEqual([""])
|
||||
expect(result.newLines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle deletion-only hunk", async () => {
|
||||
const oldText = "// Deleted comment\nconst x = 1"
|
||||
const newText = ""
|
||||
|
||||
const result = await highlightHunks(oldText, newText, "javascript", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(2)
|
||||
expect(result.newLines).toEqual([""])
|
||||
})
|
||||
|
||||
it("should handle context with mixed changes", async () => {
|
||||
const oldText = "line1\nold line\nline3\nold line2"
|
||||
const newText = "line1\nnew line\nline3\nnew line2"
|
||||
|
||||
const result = await highlightHunks(oldText, newText, "txt", "light")
|
||||
|
||||
expect(result.oldLines).toHaveLength(4)
|
||||
expect(result.newLines).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
131
webview-ui/src/utils/highlightDiff.ts
Normal file
131
webview-ui/src/utils/highlightDiff.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { ReactNode } from "react"
|
||||
import { getHighlighter } from "./highlighter"
|
||||
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
|
||||
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
|
||||
|
||||
/**
|
||||
* Highlight two pieces of code (old and new) in a single pass and return
|
||||
* arrays of ReactNode representing each line
|
||||
*/
|
||||
export async function highlightHunks(
|
||||
oldText: string,
|
||||
newText: string,
|
||||
lang: string,
|
||||
theme: "light" | "dark",
|
||||
_hunkIndex = 0,
|
||||
_filePath?: string,
|
||||
): Promise<{ oldLines: ReactNode[]; newLines: ReactNode[] }> {
|
||||
try {
|
||||
const highlighter = await getHighlighter(lang)
|
||||
const shikiTheme = theme === "light" ? "github-light" : "github-dark"
|
||||
|
||||
// Helper to highlight text and extract lines
|
||||
const highlightAndExtractLines = (text: string): ReactNode[] => {
|
||||
const textLines = text.split("\n")
|
||||
|
||||
if (!text.trim()) {
|
||||
return textLines.map((line) => line || "")
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Shiki's line transformer to get per-line highlighting
|
||||
const hast: any = highlighter.codeToHast(text, {
|
||||
lang,
|
||||
theme: shikiTheme,
|
||||
transformers: [
|
||||
{
|
||||
pre(node: any) {
|
||||
node.properties.style = "padding:0;margin:0;background:none;"
|
||||
return node
|
||||
},
|
||||
code(node: any) {
|
||||
node.properties.class = `hljs language-${lang}`
|
||||
return node
|
||||
},
|
||||
line(node: any, line: number) {
|
||||
// Add a line marker to help with extraction
|
||||
node.properties["data-line"] = line
|
||||
return node
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Extract the <code> element's children (which should be line elements)
|
||||
const codeEl = hast?.children?.[0]?.children?.[0]
|
||||
if (!codeEl || !codeEl.children) {
|
||||
return textLines.map((line) => line || "")
|
||||
}
|
||||
|
||||
// Convert each line element to a ReactNode
|
||||
const highlightedLines: ReactNode[] = []
|
||||
|
||||
for (const lineNode of codeEl.children) {
|
||||
if (lineNode.tagName === "span" && lineNode.properties?.className?.includes("line")) {
|
||||
// This is a line span from Shiki
|
||||
const reactNode = toJsxRuntime(
|
||||
{ type: "element", tagName: "span", properties: {}, children: lineNode.children || [] },
|
||||
{ Fragment, jsx, jsxs },
|
||||
)
|
||||
highlightedLines.push(reactNode)
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't get the expected structure, fall back to simple approach
|
||||
if (highlightedLines.length !== textLines.length) {
|
||||
// For each line, highlight it individually (fallback)
|
||||
return textLines.map((line) => {
|
||||
if (!line.trim()) return line
|
||||
|
||||
try {
|
||||
const lineHast: any = highlighter.codeToHast(line, {
|
||||
lang,
|
||||
theme: shikiTheme,
|
||||
transformers: [
|
||||
{
|
||||
pre(node: any) {
|
||||
node.properties.style = "padding:0;margin:0;background:none;"
|
||||
return node
|
||||
},
|
||||
code(node: any) {
|
||||
node.properties.class = `hljs language-${lang}`
|
||||
return node
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const lineCodeEl = lineHast?.children?.[0]?.children?.[0]
|
||||
if (!lineCodeEl || !lineCodeEl.children) {
|
||||
return line
|
||||
}
|
||||
|
||||
return toJsxRuntime(
|
||||
{ type: "element", tagName: "span", properties: {}, children: lineCodeEl.children },
|
||||
{ Fragment, jsx, jsxs },
|
||||
)
|
||||
} catch {
|
||||
return line
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return highlightedLines
|
||||
} catch {
|
||||
return textLines.map((line) => line || "")
|
||||
}
|
||||
}
|
||||
|
||||
// Process both old and new text
|
||||
const oldLines = highlightAndExtractLines(oldText)
|
||||
const newLines = highlightAndExtractLines(newText)
|
||||
|
||||
return { oldLines, newLines }
|
||||
} catch {
|
||||
// Fallback to plain text on any error
|
||||
return {
|
||||
oldLines: oldText.split("\n").map((line) => line || ""),
|
||||
newLines: newText.split("\n").map((line) => line || ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
96
webview-ui/src/utils/parseUnifiedDiff.ts
Normal file
96
webview-ui/src/utils/parseUnifiedDiff.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { parsePatch } from "diff"
|
||||
|
||||
export interface DiffLine {
|
||||
oldLineNum: number | null
|
||||
newLineNum: number | null
|
||||
type: "context" | "addition" | "deletion" | "gap"
|
||||
content: string
|
||||
hiddenCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a unified diff string into a flat list of renderable lines with
|
||||
* line numbers, addition/deletion/context flags, and compact "gap" separators
|
||||
* between hunks.
|
||||
*/
|
||||
export function parseUnifiedDiff(source: string, filePath?: string): DiffLine[] {
|
||||
if (!source) return []
|
||||
|
||||
try {
|
||||
const patches = parsePatch(source)
|
||||
if (!patches || patches.length === 0) return []
|
||||
|
||||
const patch = filePath
|
||||
? (patches.find((p) =>
|
||||
[p.newFileName, p.oldFileName].some(
|
||||
(n) => typeof n === "string" && (n === filePath || (n as string).endsWith("/" + filePath)),
|
||||
),
|
||||
) ?? patches[0])
|
||||
: patches[0]
|
||||
|
||||
if (!patch) return []
|
||||
|
||||
const lines: DiffLine[] = []
|
||||
let prevHunk: any = null
|
||||
for (const hunk of (patch as any).hunks || []) {
|
||||
// Insert a compact "hidden lines" separator between hunks
|
||||
if (prevHunk) {
|
||||
const gapNew = hunk.newStart - (prevHunk.newStart + prevHunk.newLines)
|
||||
const gapOld = hunk.oldStart - (prevHunk.oldStart + prevHunk.oldLines)
|
||||
const hidden = Math.max(gapNew, gapOld)
|
||||
if (hidden > 0) {
|
||||
lines.push({
|
||||
oldLineNum: null,
|
||||
newLineNum: null,
|
||||
type: "gap",
|
||||
content: "",
|
||||
hiddenCount: hidden,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let oldLine = hunk.oldStart
|
||||
let newLine = hunk.newStart
|
||||
|
||||
for (const raw of hunk.lines || []) {
|
||||
const firstChar = (raw as string)[0]
|
||||
const content = (raw as string).slice(1)
|
||||
|
||||
if (firstChar === "-") {
|
||||
lines.push({
|
||||
oldLineNum: oldLine,
|
||||
newLineNum: null,
|
||||
type: "deletion",
|
||||
content,
|
||||
})
|
||||
oldLine++
|
||||
} else if (firstChar === "+") {
|
||||
lines.push({
|
||||
oldLineNum: null,
|
||||
newLineNum: newLine,
|
||||
type: "addition",
|
||||
content,
|
||||
})
|
||||
newLine++
|
||||
} else {
|
||||
// Context line
|
||||
lines.push({
|
||||
oldLineNum: oldLine,
|
||||
newLineNum: newLine,
|
||||
type: "context",
|
||||
content,
|
||||
})
|
||||
oldLine++
|
||||
newLine++
|
||||
}
|
||||
}
|
||||
|
||||
prevHunk = hunk
|
||||
}
|
||||
|
||||
return lines
|
||||
} catch {
|
||||
// swallow parse errors and render nothing rather than breaking the UI
|
||||
return []
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue