Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View 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>
)
}

View 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>
)
}