Initial commit
This commit is contained in:
198
src/components/diff/diff-viewer.tsx
Normal file
198
src/components/diff/diff-viewer.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import type { DiffOnMount } from "@monaco-editor/react"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MonacoDiffEditor = dynamic(
|
||||
async () => {
|
||||
const mod = await import("@monaco-editor/react")
|
||||
return { default: mod.DiffEditor }
|
||||
},
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export interface DiffViewerProps {
|
||||
original: string
|
||||
modified: string
|
||||
originalLabel?: string
|
||||
modifiedLabel?: string
|
||||
language?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
original,
|
||||
modified,
|
||||
originalLabel = "Original",
|
||||
modifiedLabel = "Modified",
|
||||
language = "plaintext",
|
||||
className,
|
||||
}: DiffViewerProps) {
|
||||
const editorTheme = useMonacoThemeSync()
|
||||
const diffEditorRef = useRef<MonacoEditorNs.IStandaloneDiffEditor | null>(
|
||||
null
|
||||
)
|
||||
const [diffChanges, setDiffChanges] = useState<MonacoEditorNs.ILineChange[]>(
|
||||
[]
|
||||
)
|
||||
const [currentChangeIndex, setCurrentChangeIndex] = useState(-1)
|
||||
|
||||
const handleEditorMount: DiffOnMount = useCallback((editor) => {
|
||||
diffEditorRef.current = editor
|
||||
let scrolledToFirst = false
|
||||
|
||||
const updateDiffs = () => {
|
||||
const changes = editor.getLineChanges()
|
||||
setDiffChanges(changes ?? [])
|
||||
if (changes && changes.length > 0) {
|
||||
setCurrentChangeIndex(0)
|
||||
// Auto-scroll to the first change only once
|
||||
if (!scrolledToFirst) {
|
||||
scrolledToFirst = true
|
||||
const first = changes[0]
|
||||
const lineNumber =
|
||||
first.modifiedStartLineNumber || first.originalStartLineNumber || 1
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
modifiedEditor.revealLineInCenter(lineNumber)
|
||||
modifiedEditor.setPosition({ lineNumber, column: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editor.onDidUpdateDiff(updateDiffs)
|
||||
setTimeout(updateDiffs, 300)
|
||||
}, [])
|
||||
|
||||
const navigateToChange = useCallback(
|
||||
(index: number) => {
|
||||
const editor = diffEditorRef.current
|
||||
if (!editor || diffChanges.length === 0) return
|
||||
|
||||
const clampedIndex = Math.max(0, Math.min(index, diffChanges.length - 1))
|
||||
setCurrentChangeIndex(clampedIndex)
|
||||
|
||||
const change = diffChanges[clampedIndex]
|
||||
const lineNumber =
|
||||
change.modifiedStartLineNumber || change.originalStartLineNumber || 1
|
||||
|
||||
const modifiedEditor = editor.getModifiedEditor()
|
||||
modifiedEditor.revealLineInCenter(lineNumber)
|
||||
modifiedEditor.setPosition({ lineNumber, column: 1 })
|
||||
},
|
||||
[diffChanges]
|
||||
)
|
||||
|
||||
const handlePrevChange = useCallback(() => {
|
||||
if (currentChangeIndex > 0) {
|
||||
navigateToChange(currentChangeIndex - 1)
|
||||
}
|
||||
}, [currentChangeIndex, navigateToChange])
|
||||
|
||||
const handleNextChange = useCallback(() => {
|
||||
if (currentChangeIndex < diffChanges.length - 1) {
|
||||
navigateToChange(currentChangeIndex + 1)
|
||||
}
|
||||
}, [currentChangeIndex, diffChanges.length, navigateToChange])
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
let add = 0
|
||||
let del = 0
|
||||
for (const change of diffChanges) {
|
||||
// Monaco ILineChange: endLineNumber === 0 means no lines on that side
|
||||
// Pure insertion: originalEndLineNumber === 0
|
||||
// Pure deletion: modifiedEndLineNumber === 0
|
||||
const isInsertion = change.originalEndLineNumber === 0
|
||||
const isDeletion = change.modifiedEndLineNumber === 0
|
||||
|
||||
if (isInsertion) {
|
||||
add += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1
|
||||
} else if (isDeletion) {
|
||||
del += change.originalEndLineNumber - change.originalStartLineNumber + 1
|
||||
} else {
|
||||
del += change.originalEndLineNumber - change.originalStartLineNumber + 1
|
||||
add += change.modifiedEndLineNumber - change.modifiedStartLineNumber + 1
|
||||
}
|
||||
}
|
||||
return { additions: add, deletions: del }
|
||||
}, [diffChanges])
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
<div className="flex items-center gap-3 border-b bg-muted/50 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{originalLabel}</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">{modifiedLabel}</span>
|
||||
{diffChanges.length > 0 && (
|
||||
<>
|
||||
<span className="ml-2 font-mono text-green-600 dark:text-green-400">
|
||||
+{additions}
|
||||
</span>
|
||||
<span className="font-mono text-red-600 dark:text-red-400">
|
||||
-{deletions}
|
||||
</span>
|
||||
<span>
|
||||
{diffChanges.length}{" "}
|
||||
{diffChanges.length === 1 ? "change" : "changes"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevChange}
|
||||
disabled={currentChangeIndex <= 0}
|
||||
className="rounded border border-border bg-background px-2 py-0.5 text-[10px] disabled:opacity-40 hover:bg-muted transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
Prev
|
||||
</button>
|
||||
<span className="tabular-nums text-[10px]">
|
||||
{currentChangeIndex + 1} / {diffChanges.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextChange}
|
||||
disabled={currentChangeIndex >= diffChanges.length - 1}
|
||||
className="rounded border border-border bg-background px-2 py-0.5 text-[10px] disabled:opacity-40 hover:bg-muted transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MonacoDiffEditor
|
||||
original={original}
|
||||
modified={modified}
|
||||
language={language}
|
||||
theme={editorTheme}
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={handleEditorMount}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
Loading diff viewer...
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderOverviewRuler: false,
|
||||
ignoreTrimWhitespace: true,
|
||||
renderIndicators: true,
|
||||
originalEditable: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
700
src/components/diff/unified-diff-preview.tsx
Normal file
700
src/components/diff/unified-diff-preview.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type RowMarker = "none" | "added" | "deleted" | "modified"
|
||||
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
||||
|
||||
interface RawDiffRow {
|
||||
kind: "context" | "add" | "del"
|
||||
text: string
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
}
|
||||
|
||||
interface ParsedDiffRow {
|
||||
type: "context" | "added" | "deleted" | "modified"
|
||||
text: string
|
||||
sign: " " | "+" | "-"
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
key: string
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
rows: ParsedDiffRow[]
|
||||
}
|
||||
|
||||
interface ParsedDiffFile {
|
||||
key: string
|
||||
path: string
|
||||
oldPath: string | null
|
||||
newPath: string | null
|
||||
mode: DiffFileMode
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: ParsedDiffHunk[]
|
||||
}
|
||||
|
||||
interface WorkingHunk {
|
||||
key: string
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
rows: RawDiffRow[]
|
||||
}
|
||||
|
||||
interface WorkingFile {
|
||||
key: string
|
||||
path: string
|
||||
oldPath: string | null
|
||||
newPath: string | null
|
||||
mode: DiffFileMode
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: WorkingHunk[]
|
||||
}
|
||||
|
||||
interface HunkPreviewLine {
|
||||
text: string
|
||||
marker: RowMarker
|
||||
}
|
||||
|
||||
const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
function normalizePath(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^"|"$/g, "")
|
||||
if (!trimmed || trimmed === "/dev/null") return null
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
||||
return trimmed.slice(2).replace(/\\/g, "/")
|
||||
}
|
||||
return trimmed.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function parsePathFromDiffGitLine(line: string): string | null {
|
||||
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
|
||||
if (!match) return null
|
||||
return normalizePath(match[2]) ?? normalizePath(match[1])
|
||||
}
|
||||
|
||||
function parseApplyPatchMarker(line: string): {
|
||||
path: string | null
|
||||
mode: DiffFileMode
|
||||
} | null {
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Update File: ".length)),
|
||||
mode: "modified",
|
||||
}
|
||||
}
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Add File: ".length)),
|
||||
mode: "added",
|
||||
}
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
return {
|
||||
path: normalizePath(line.slice("*** Delete File: ".length)),
|
||||
mode: "deleted",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseHunkHeader(line: string): {
|
||||
oldStart: number | null
|
||||
oldCount: number | null
|
||||
newStart: number | null
|
||||
newCount: number | null
|
||||
} | null {
|
||||
if (!line.startsWith("@@")) return null
|
||||
|
||||
const match = line.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/)
|
||||
if (!match) {
|
||||
return {
|
||||
oldStart: null,
|
||||
oldCount: null,
|
||||
newStart: null,
|
||||
newCount: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldStart: Number(match[1]),
|
||||
oldCount: match[2] ? Number(match[2]) : 1,
|
||||
newStart: Number(match[3]),
|
||||
newCount: match[4] ? Number(match[4]) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
|
||||
const parsed: ParsedDiffRow[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < rows.length) {
|
||||
const current = rows[index]
|
||||
if (!current) break
|
||||
|
||||
if (current.kind === "context") {
|
||||
parsed.push({
|
||||
type: "context",
|
||||
text: current.text,
|
||||
sign: " ",
|
||||
oldLine: current.oldLine,
|
||||
newLine: current.newLine,
|
||||
})
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current.kind === "add") {
|
||||
let addEnd = index
|
||||
while (addEnd < rows.length && rows[addEnd]?.kind === "add") {
|
||||
const row = rows[addEnd]
|
||||
if (!row) break
|
||||
parsed.push({
|
||||
type: "added",
|
||||
text: row.text,
|
||||
sign: "+",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
addEnd += 1
|
||||
}
|
||||
index = addEnd
|
||||
continue
|
||||
}
|
||||
|
||||
let delEnd = index
|
||||
while (delEnd < rows.length && rows[delEnd]?.kind === "del") {
|
||||
delEnd += 1
|
||||
}
|
||||
|
||||
let addEnd = delEnd
|
||||
while (addEnd < rows.length && rows[addEnd]?.kind === "add") {
|
||||
addEnd += 1
|
||||
}
|
||||
|
||||
const delRows = rows.slice(index, delEnd)
|
||||
const addRows = rows.slice(delEnd, addEnd)
|
||||
const modifiedPairs = Math.min(delRows.length, addRows.length)
|
||||
|
||||
for (const [delta, row] of delRows.entries()) {
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "deleted",
|
||||
text: row.text,
|
||||
sign: "-",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [delta, row] of addRows.entries()) {
|
||||
parsed.push({
|
||||
type: delta < modifiedPairs ? "modified" : "added",
|
||||
text: row.text,
|
||||
sign: "+",
|
||||
oldLine: row.oldLine,
|
||||
newLine: row.newLine,
|
||||
})
|
||||
}
|
||||
|
||||
index = addEnd
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function resolveFileMode(file: WorkingFile): DiffFileMode {
|
||||
if (file.mode !== "modified") return file.mode
|
||||
if (file.oldPath && !file.newPath) return "deleted"
|
||||
if (!file.oldPath && file.newPath) return "added"
|
||||
if (file.oldPath && file.newPath && file.oldPath !== file.newPath) {
|
||||
return "renamed"
|
||||
}
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText: string): ParsedDiffFile[] {
|
||||
const lines = diffText.replace(/\r\n/g, "\n").split("\n")
|
||||
const files: WorkingFile[] = []
|
||||
|
||||
let fileIndex = 1
|
||||
let hunkIndex = 1
|
||||
let currentFile: WorkingFile | null = null
|
||||
let currentHunk: WorkingHunk | null = null
|
||||
let oldLineCursor: number | null = null
|
||||
let newLineCursor: number | null = null
|
||||
let inferredOldCursorForNextHunk = 1
|
||||
let inferredNewCursorForNextHunk = 1
|
||||
|
||||
const getActiveFile = (): WorkingFile | null =>
|
||||
currentFile ?? files[files.length - 1] ?? null
|
||||
const getActiveHunk = (): WorkingHunk | null => currentHunk
|
||||
const getOldLineCursor = (): number | null => oldLineCursor
|
||||
const getNewLineCursor = (): number | null => newLineCursor
|
||||
|
||||
const flushHunk = () => {
|
||||
const file = getActiveFile()
|
||||
if (!file || !currentHunk) return
|
||||
file.hunks.push(currentHunk)
|
||||
if (oldLineCursor !== null) {
|
||||
inferredOldCursorForNextHunk = Math.max(1, oldLineCursor)
|
||||
}
|
||||
if (newLineCursor !== null) {
|
||||
inferredNewCursorForNextHunk = Math.max(1, newLineCursor)
|
||||
}
|
||||
currentHunk = null
|
||||
}
|
||||
|
||||
const startFile = (
|
||||
path: string | null,
|
||||
mode: DiffFileMode = "modified"
|
||||
): WorkingFile => {
|
||||
flushHunk()
|
||||
currentFile = {
|
||||
key: `file-${fileIndex}`,
|
||||
path: path ?? `Diff #${fileIndex}`,
|
||||
oldPath: null,
|
||||
newPath: null,
|
||||
mode,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
}
|
||||
files.push(currentFile)
|
||||
fileIndex += 1
|
||||
inferredOldCursorForNextHunk = 1
|
||||
inferredNewCursorForNextHunk = 1
|
||||
return currentFile
|
||||
}
|
||||
|
||||
const ensureFile = () => getActiveFile() ?? startFile(null)
|
||||
|
||||
const startHunk = (line: string) => {
|
||||
const file = ensureFile()
|
||||
flushHunk()
|
||||
|
||||
const parsed = parseHunkHeader(line)
|
||||
const resolvedOldStart = parsed?.oldStart ?? inferredOldCursorForNextHunk
|
||||
const resolvedNewStart = parsed?.newStart ?? inferredNewCursorForNextHunk
|
||||
oldLineCursor = resolvedOldStart
|
||||
newLineCursor = resolvedNewStart
|
||||
|
||||
currentHunk = {
|
||||
key: `${file.key}:hunk-${hunkIndex}`,
|
||||
oldStart: resolvedOldStart,
|
||||
oldCount: parsed?.oldCount ?? null,
|
||||
newStart: resolvedNewStart,
|
||||
newCount: parsed?.newCount ?? null,
|
||||
rows: [],
|
||||
}
|
||||
hunkIndex += 1
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
startFile(parsePathFromDiffGitLine(line))
|
||||
continue
|
||||
}
|
||||
|
||||
const applyPatchMarker = parseApplyPatchMarker(line)
|
||||
if (applyPatchMarker) {
|
||||
startFile(applyPatchMarker.path, applyPatchMarker.mode)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("*** Move to: ")) {
|
||||
const movedPath = normalizePath(line.slice("*** Move to: ".length))
|
||||
const file = getActiveFile()
|
||||
if (file && movedPath) {
|
||||
file.newPath = movedPath
|
||||
file.path = movedPath
|
||||
file.mode = "renamed"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("--- ")) {
|
||||
const file = ensureFile()
|
||||
const oldPath = normalizePath(line.slice(4))
|
||||
file.oldPath = oldPath
|
||||
if (!file.newPath && oldPath) file.path = oldPath
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("+++ ")) {
|
||||
const file = ensureFile()
|
||||
const newPath = normalizePath(line.slice(4))
|
||||
file.newPath = newPath
|
||||
if (newPath) file.path = newPath
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
startHunk(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const hunk = getActiveHunk()
|
||||
if (!hunk) continue
|
||||
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
hunk.rows.push({
|
||||
kind: "add",
|
||||
text: line.slice(1),
|
||||
oldLine: null,
|
||||
newLine: newLineCursor,
|
||||
})
|
||||
const cursor = getNewLineCursor()
|
||||
if (cursor !== null) newLineCursor = cursor + 1
|
||||
const file = getActiveFile()
|
||||
if (file) file.additions += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
hunk.rows.push({
|
||||
kind: "del",
|
||||
text: line.slice(1),
|
||||
oldLine: oldLineCursor,
|
||||
newLine: null,
|
||||
})
|
||||
const cursor = getOldLineCursor()
|
||||
if (cursor !== null) oldLineCursor = cursor + 1
|
||||
const file = getActiveFile()
|
||||
if (file) file.deletions += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith(" ")) {
|
||||
hunk.rows.push({
|
||||
kind: "context",
|
||||
text: line.slice(1),
|
||||
oldLine: oldLineCursor,
|
||||
newLine: newLineCursor,
|
||||
})
|
||||
const nextOldCursor = getOldLineCursor()
|
||||
if (nextOldCursor !== null) oldLineCursor = nextOldCursor + 1
|
||||
const nextNewCursor = getNewLineCursor()
|
||||
if (nextNewCursor !== null) newLineCursor = nextNewCursor + 1
|
||||
}
|
||||
}
|
||||
|
||||
flushHunk()
|
||||
|
||||
return files
|
||||
.map((file) => ({
|
||||
...file,
|
||||
mode: resolveFileMode(file),
|
||||
hunks: file.hunks
|
||||
.filter((hunk) => hunk.rows.length > 0)
|
||||
.map((hunk) => ({
|
||||
key: hunk.key,
|
||||
oldStart: hunk.oldStart,
|
||||
oldCount: hunk.oldCount,
|
||||
newStart: hunk.newStart,
|
||||
newCount: hunk.newCount,
|
||||
rows: classifyRows(hunk.rows),
|
||||
})),
|
||||
}))
|
||||
.filter((file) => file.hunks.length > 0)
|
||||
}
|
||||
|
||||
function modeLabel(mode: DiffFileMode): string {
|
||||
if (mode === "added") return "新增"
|
||||
if (mode === "deleted") return "删除"
|
||||
if (mode === "renamed") return "重命名"
|
||||
return "修改"
|
||||
}
|
||||
|
||||
function toDisplayPath(filePath: string, folderPath: string | null): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, "/")
|
||||
if (!folderPath) return normalizedPath
|
||||
|
||||
const normalizedFolder = folderPath.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
if (!normalizedFolder) return normalizedPath
|
||||
|
||||
const prefix = `${normalizedFolder}/`
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length)
|
||||
}
|
||||
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
function hunkLabel(hunk: ParsedDiffHunk, index: number): string {
|
||||
void hunk
|
||||
return `Hunk ${index + 1}`
|
||||
}
|
||||
|
||||
function countHunkChanges(hunk: ParsedDiffHunk): {
|
||||
additions: number
|
||||
deletions: number
|
||||
} {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const row of hunk.rows) {
|
||||
if (row.sign === "+") additions += 1
|
||||
if (row.sign === "-") deletions += 1
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
function buildHunkPreviewLines(rows: ParsedDiffRow[]): {
|
||||
lines: HunkPreviewLine[]
|
||||
} {
|
||||
const lines: HunkPreviewLine[] = rows.map((row) => {
|
||||
let marker: RowMarker = "none"
|
||||
if (row.type === "added") marker = "added"
|
||||
else if (row.type === "deleted") marker = "deleted"
|
||||
else if (row.type === "modified") marker = "modified"
|
||||
|
||||
return {
|
||||
text: `${row.sign}${row.text}`,
|
||||
marker,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
function HunkMonacoPreview({
|
||||
hunk,
|
||||
modelId,
|
||||
theme,
|
||||
}: {
|
||||
hunk: ParsedDiffHunk
|
||||
modelId: string
|
||||
theme: string
|
||||
}) {
|
||||
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
|
||||
const decorationsRef = useRef<string[]>([])
|
||||
|
||||
const { lines } = useMemo(() => buildHunkPreviewLines(hunk.rows), [hunk.rows])
|
||||
|
||||
const renderedContent = useMemo(
|
||||
() => lines.map((line) => line.text).join("\n"),
|
||||
[lines]
|
||||
)
|
||||
|
||||
const applyDecorations = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
const model = editor.getModel()
|
||||
if (!model) return
|
||||
|
||||
const maxLine = model.getLineCount()
|
||||
const decorations: MonacoEditorNs.IModelDeltaDecoration[] = []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1
|
||||
if (lineNumber > maxLine) continue
|
||||
|
||||
let cls: string | null = null
|
||||
if (line.marker === "added") {
|
||||
cls = "codeg-session-diff-line-added"
|
||||
} else if (line.marker === "modified") {
|
||||
cls = "codeg-session-diff-line-modified"
|
||||
} else if (line.marker === "deleted") {
|
||||
cls = "codeg-session-diff-line-deleted"
|
||||
}
|
||||
|
||||
if (!cls) continue
|
||||
|
||||
decorations.push({
|
||||
range: {
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: 1,
|
||||
},
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
className: cls,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
decorationsRef.current = editor.deltaDecorations(
|
||||
decorationsRef.current,
|
||||
decorations
|
||||
)
|
||||
}, [lines])
|
||||
|
||||
useEffect(() => {
|
||||
applyDecorations()
|
||||
}, [applyDecorations])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.deltaDecorations(decorationsRef.current, [])
|
||||
decorationsRef.current = []
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<MonacoEditor
|
||||
beforeMount={defineMonacoThemes}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
applyDecorations()
|
||||
}}
|
||||
path={`inmemory://session-hunk/${encodeURIComponent(modelId)}`}
|
||||
value={renderedContent}
|
||||
language="plaintext"
|
||||
theme={theme}
|
||||
loading={
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading hunk...
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
fontSize: 12,
|
||||
lineNumbers: "off",
|
||||
lineDecorationsWidth: 10,
|
||||
glyphMargin: false,
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: "none",
|
||||
contextmenu: false,
|
||||
folding: false,
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
padding: { top: 6, bottom: 6 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedDiffPreview({
|
||||
diffText,
|
||||
modelId,
|
||||
className,
|
||||
}: {
|
||||
diffText: string
|
||||
modelId?: string
|
||||
className?: string
|
||||
}) {
|
||||
const { folder } = useFolderContext()
|
||||
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
||||
const theme = useMonacoThemeSync()
|
||||
|
||||
if (!diffText.trim()) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center text-xs text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
No diff data
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground">
|
||||
{diffText}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("h-full overflow-auto p-3", className)}>
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<section
|
||||
key={file.key}
|
||||
className="overflow-hidden rounded-lg border border-border bg-background"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-[11px]">
|
||||
<span className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{modeLabel(file.mode)}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-foreground"
|
||||
title={file.path}
|
||||
>
|
||||
{toDisplayPath(file.path, folder?.path ?? null)}
|
||||
</span>
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-2 font-mono">
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
+{file.additions}
|
||||
</span>
|
||||
<span className="text-red-700 dark:text-red-400">
|
||||
-{file.deletions}
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
{file.hunks.map((hunk, index) => {
|
||||
const hunkStats = countHunkChanges(hunk)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hunk.key}
|
||||
className="rounded-md border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
<span>{hunkLabel(hunk, index)}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-2">
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
+{hunkStats.additions}
|
||||
</span>
|
||||
<span className="text-red-700 dark:text-red-400">
|
||||
-{hunkStats.deletions}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-[7rem]"
|
||||
style={{
|
||||
height: `${Math.max(120, hunk.rows.length * 20 + 18)}px`,
|
||||
}}
|
||||
>
|
||||
<HunkMonacoPreview
|
||||
hunk={hunk}
|
||||
modelId={`${modelId ?? "session"}:${file.key}:${hunk.key}`}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user