Merge branch 'main' into cline

This commit is contained in:
xintaofei
2026-03-28 20:15:21 +08:00
28 changed files with 1272 additions and 545 deletions
@@ -91,7 +91,7 @@ export const MessageThreadScrollButton = ({
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background/90 hover:bg-muted/90",
className
)}
onClick={handleScrollToBottom}
+4 -2
View File
@@ -22,6 +22,7 @@ import { useTranslations } from "next-intl"
import { isValidElement } from "react"
import { CodeBlock } from "./code-block"
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
import { MessageResponse } from "./message"
export type ToolProps = ComponentProps<typeof Collapsible>
@@ -380,9 +381,10 @@ export const ToolOutput = ({
<MessageResponse>{output}</MessageResponse>
</div>
)
} else if (lang === "diff") {
Output = <UnifiedDiffPreview diffText={output} />
} else {
const language = detectOutputLanguage(output)
Output = <CodeBlock code={output} language={language} />
Output = <CodeBlock code={output} language={lang} />
}
}
+3 -33
View File
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"
import {
ShieldAlert,
Terminal,
FilePenLine,
ListTodo,
Compass,
FileText,
@@ -15,6 +14,7 @@ import {
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { CodeBlock } from "@/components/ai-elements/code-block"
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
import { MessageResponse } from "@/components/ai-elements/message"
import type { PendingPermission } from "@/contexts/acp-connections-context"
import { parsePermissionToolCall } from "@/lib/permission-request"
@@ -86,38 +86,8 @@ export function PermissionDialog({
</div>
)}
{hasFileChanges && (
<div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 p-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<FilePenLine className="h-3.5 w-3.5" />
<span>
{t("filesSummary", { count: parsed.fileChanges.length })}
</span>
{(parsed.additions > 0 || parsed.deletions > 0) && (
<span>
+{parsed.additions} / -{parsed.deletions}
</span>
)}
</div>
<div className="space-y-1 rounded-md bg-muted/40 p-2">
{parsed.fileChanges.slice(0, 8).map((change, index) => (
<div
key={`${change.path}-${index}`}
className="break-all font-mono text-xs text-foreground/90"
>
{change.path}
</div>
))}
{parsed.fileChanges.length > 8 && (
<div className="text-xs text-muted-foreground">
{t("moreFiles", { count: parsed.fileChanges.length - 8 })}
</div>
)}
</div>
{parsed.diffPreview && (
<CodeBlock code={parsed.diffPreview} language="diff" />
)}
</div>
{hasFileChanges && parsed.diffPreview && (
<UnifiedDiffPreview diffText={parsed.diffPreview} />
)}
{hasPlan && (
+74 -206
View File
@@ -1,13 +1,9 @@
"use client"
import { useCallback, useEffect, useMemo, useRef } from "react"
import dynamic from "next/dynamic"
import type { editor as MonacoEditorNs } from "monaco-editor"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { cn } from "@/lib/utils"
import "@/lib/monaco-local"
type RowMarker = "none" | "added" | "deleted" | "modified"
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
@@ -67,14 +63,18 @@ interface WorkingFile {
hunks: WorkingHunk[]
}
interface HunkPreviewLine {
text: string
marker: RowMarker
const ROW_CLASS: Record<RowMarker, string> = {
none: "",
added: "bg-green-500/10 text-green-900 dark:text-green-300",
deleted: "bg-red-500/10 text-red-900 dark:text-red-300",
modified: "bg-blue-500/10 text-blue-900 dark:text-blue-300",
}
const MonacoEditor = dynamic(async () => import("@monaco-editor/react"), {
ssr: false,
})
const SIGN_CLASS: Record<string, string> = {
"+": "text-green-700 dark:text-green-400",
"-": "text-red-700 dark:text-red-400",
" ": "text-muted-foreground/50",
}
function normalizePath(raw: string): string | null {
const trimmed = raw.trim().replace(/^"|"$/g, "")
@@ -190,13 +190,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
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()) {
for (let d = index; d < delEnd; d++) {
const row = rows[d]
if (!row) continue
parsed.push({
type: delta < modifiedPairs ? "modified" : "deleted",
type: "deleted",
text: row.text,
sign: "-",
oldLine: row.oldLine,
@@ -204,9 +202,11 @@ function classifyRows(rows: RawDiffRow[]): ParsedDiffRow[] {
})
}
for (const [delta, row] of addRows.entries()) {
for (let a = delEnd; a < addEnd; a++) {
const row = rows[a]
if (!row) continue
parsed.push({
type: delta < modifiedPairs ? "modified" : "added",
type: "added",
text: row.text,
sign: "+",
oldLine: row.oldLine,
@@ -440,171 +440,65 @@ function toDisplayPath(filePath: string, folderPath: string | null): string {
return normalizedPath
}
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 rowMarker(row: ParsedDiffRow): RowMarker {
if (row.type === "added") return "added"
if (row.type === "deleted") return "deleted"
return "none"
}
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 t = useTranslations("Folder.diffPreview")
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 = []
},
[]
)
function HunkSeparator({ hunk }: { hunk: ParsedDiffHunk }) {
const label =
hunk.oldStart != null && hunk.oldCount != null
? `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart ?? hunk.oldStart},${hunk.newCount ?? hunk.oldCount} @@`
: "···"
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">
{t("loadingHunk")}
</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 },
}}
/>
<div className="flex items-center gap-2 border-y border-border/50 bg-muted/30 px-3 py-0.5 font-mono text-[11px] text-muted-foreground/60">
<span className="select-none">{label}</span>
</div>
)
}
function HunkLines({ rows }: { rows: ParsedDiffRow[] }) {
return (
<div className="font-mono text-[12px] leading-[20px]">
{rows.map((row, i) => {
const marker = rowMarker(row)
return (
<div key={i} className={cn("flex", ROW_CLASS[marker])}>
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
{row.oldLine ?? ""}
</span>
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
{row.newLine ?? ""}
</span>
<span
className={cn(
"w-4 shrink-0 select-none text-center",
SIGN_CLASS[row.sign] ?? ""
)}
>
{row.sign === " " ? "" : row.sign}
</span>
<span className="flex-1 whitespace-pre pr-3">{row.text}</span>
</div>
)
})}
</div>
)
}
export function UnifiedDiffPreview({
diffText,
modelId,
className,
}: {
diffText: string
/** @deprecated No longer used — kept for API compat */
modelId?: string
className?: string
}) {
const t = useTranslations("Folder.diffPreview")
const { folder } = useFolderContext()
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
const theme = useMonacoThemeSync()
if (!diffText.trim()) {
return (
@@ -621,8 +515,8 @@ export function UnifiedDiffPreview({
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">
<div className={cn("h-full overflow-auto", className)}>
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
{diffText}
</pre>
</div>
@@ -630,14 +524,14 @@ export function UnifiedDiffPreview({
}
return (
<div className={cn("h-full overflow-auto p-3", className)}>
<div className={cn("h-full overflow-auto", className)}>
<div className="space-y-3">
{files.map((file) => (
<section
key={file.key}
className="overflow-hidden rounded-lg border border-border bg-background"
className="flex max-h-[420px] flex-col 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]">
<header className="flex shrink-0 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">
{t(modeKey(file.mode))}
</span>
@@ -657,41 +551,15 @@ export function UnifiedDiffPreview({
</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>{t("hunkLabel", { index: index + 1 })}</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 className="overflow-auto">
<div className="inline-block min-w-full">
{file.hunks.map((hunk, hunkIdx) => (
<div key={hunk.key}>
{hunkIdx > 0 && <HunkSeparator hunk={hunk} />}
<HunkLines rows={hunk.rows} />
</div>
)
})}
))}
</div>
</div>
</section>
))}
@@ -1270,8 +1270,7 @@ export function FileWorkspacePanel() {
)}
<UnifiedDiffPreview
diffText={activeFileTab.content}
modelId={activeFileTab.id}
className="h-full"
className="h-full p-3"
/>
</div>
)
+16 -16
View File
@@ -11,11 +11,11 @@ import {
Columns2,
FileCode2,
MessageSquare,
PanelBottom,
PanelLeft,
PanelRight,
Search,
Settings,
SquareTerminal,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
@@ -340,21 +340,6 @@ export function FolderTitleBar() {
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
onClick={() => toggleTerminal()}
title={tTitleBar("withShortcut", {
label: tTitleBar("toggleTerminal"),
shortcut: formatShortcutLabel(
shortcuts.toggle_terminal,
isMac
),
})}
>
<PanelBottom className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -370,6 +355,21 @@ export function FolderTitleBar() {
>
<PanelRight className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
onClick={() => toggleTerminal()}
title={tTitleBar("withShortcut", {
label: tTitleBar("toggleTerminal"),
shortcut: formatShortcutLabel(
shortcuts.toggle_terminal,
isMac
),
})}
>
<SquareTerminal className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
+175 -228
View File
@@ -1,5 +1,4 @@
import { memo, useMemo, useState, type ReactNode } from "react"
import type { BundledLanguage } from "shiki"
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
import type { MessageRole } from "@/lib/types"
import { normalizeToolName } from "@/lib/tool-call-normalization"
@@ -17,6 +16,8 @@ import {
} from "@/components/ai-elements/tool"
import { Terminal } from "@/components/ai-elements/terminal"
import { CodeBlock } from "@/components/ai-elements/code-block"
import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview"
import { generateUnifiedDiff } from "@/lib/unified-diff-generator"
import {
Reasoning,
ReasoningTrigger,
@@ -374,62 +375,6 @@ function num(obj: Record<string, unknown>, key: string): number | undefined {
return typeof v === "number" ? v : undefined
}
/** Guess shiki language from file path extension. */
const EXT_LANG_MAP: Record<string, BundledLanguage> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
py: "python",
rs: "rust",
go: "go",
java: "java",
css: "css",
scss: "scss",
less: "less",
html: "html",
json: "json",
jsonl: "json",
yaml: "yaml",
yml: "yaml",
md: "markdown",
mdx: "mdx",
sql: "sql",
sh: "bash",
bash: "bash",
zsh: "bash",
toml: "toml",
xml: "xml",
svg: "xml",
vue: "vue",
svelte: "svelte",
rb: "ruby",
php: "php",
swift: "swift",
kt: "kotlin",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
dart: "dart",
lua: "lua",
r: "r",
dockerfile: "dockerfile",
graphql: "graphql",
prisma: "prisma",
}
function guessLangFromPath(filePath: string): BundledLanguage {
const ext = filePath.split(".").pop()?.toLowerCase() ?? ""
// Handle dotfiles like "Dockerfile"
const basename = filePath.split("/").pop()?.toLowerCase() ?? ""
if (basename === "dockerfile") return "dockerfile"
return EXT_LANG_MAP[ext] ?? ("log" as BundledLanguage)
}
type ApplyPatchOp = "add" | "update" | "delete" | "move"
type ApplyPatchFile = {
@@ -1227,115 +1172,55 @@ function localizeDerivedToolTitle(
/** Edit tool: file path + unified diff view */
function EditToolInput({ input }: { input: Record<string, unknown> }) {
const t = useTranslations("Folder.chat.contentParts")
const filePath = str(input, "file_path")
const oldString = str(input, "old_string") ?? ""
const newString = str(input, "new_string") ?? ""
const replaceAll = input.replace_all === true
const startLine = num(input, "_start_line")
const diffCode = useMemo(() => {
const parts: string[] = []
if (oldString) {
for (const line of oldString.split("\n")) {
parts.push(`- ${line}`)
}
}
if (newString) {
for (const line of newString.split("\n")) {
parts.push(`+ ${line}`)
}
}
return parts.join("\n")
}, [oldString, newString])
const diff = generateUnifiedDiff(
oldString,
newString,
filePath ?? undefined
)
if (!diff || !startLine || startLine <= 1) return diff ?? ""
// Replace line numbers in hunk headers with real start line
return diff.replace(
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
)
}, [oldString, newString, filePath, startLine])
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<FilePenLineIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="break-all font-mono text-foreground">
{filePath ?? t("unknown")}
</span>
{replaceAll && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t("replaceAll")}
</span>
)}
</div>
{diffCode && <CodeBlock code={diffCode} language="diff" />}
</div>
)
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
}
/** Edit tool (changes payload): file list + summary + combined diff view */
/** Edit tool (changes payload): combined diff view */
function EditChangesToolInput({ changes }: { changes: EditChangePreview[] }) {
const t = useTranslations("Folder.chat.contentParts")
const { additions, deletions, diffCode } = useMemo(() => {
let additions = 0
let deletions = 0
const diffCode = useMemo(() => {
const diffParts: string[] = []
for (const change of changes) {
if (change.unifiedDiff && change.unifiedDiff.trim().length > 0) {
diffParts.push(change.unifiedDiff.trim())
diffParts.push("")
for (const line of change.unifiedDiff.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1
}
continue
}
const oldLines = change.oldText ? change.oldText.split("\n") : []
const newLines = change.newText ? change.newText.split("\n") : []
deletions += oldLines.length
additions += newLines.length
diffParts.push(`--- ${change.path}`)
diffParts.push(`+++ ${change.path}`)
for (const line of oldLines) {
diffParts.push(`-${line}`)
const generated = generateUnifiedDiff(
change.oldText,
change.newText,
change.path
)
if (generated) {
diffParts.push(generated)
diffParts.push("")
}
for (const line of newLines) {
diffParts.push(`+${line}`)
}
diffParts.push("")
}
return {
additions,
deletions,
diffCode: diffParts.join("\n").trim(),
}
return diffParts.join("\n").trim()
}, [changes])
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>{t("filesCount", { count: changes.length })}</span>
{additions > 0 && <span>+{additions}</span>}
{deletions > 0 && <span>-{deletions}</span>}
</div>
<div className="space-y-1 rounded-md bg-muted/40 p-2">
{changes.slice(0, 8).map((change, index) => (
<div key={`${change.path}-${index}`} className="flex gap-2 text-xs">
<span className="shrink-0 rounded bg-blue-500/15 px-1.5 py-0.5 font-medium uppercase text-blue-600">
{t("update")}
</span>
<span className="break-all font-mono text-foreground">
{change.path}
</span>
</div>
))}
{changes.length > 8 && (
<div className="text-xs text-muted-foreground">
{t("moreFiles", { count: changes.length - 8 })}
</div>
)}
</div>
{diffCode && <CodeBlock code={diffCode} language="diff" />}
</div>
)
return diffCode ? <UnifiedDiffPreview diffText={diffCode} /> : null
}
/** Bash / exec_command: terminal-style command display */
@@ -1370,13 +1255,60 @@ function BashToolInput({ input }: { input: Record<string, unknown> }) {
)
}
/**
* Parse structured read output from backend: `{"start_line":N,"content":"..."}`.
* Falls back to raw text with startLine=1 if not structured.
*/
function parseReadOutput(raw: string): { startLine: number; content: string } {
try {
const parsed = JSON.parse(raw)
if (
typeof parsed === "object" &&
parsed !== null &&
typeof parsed.start_line === "number" &&
typeof parsed.content === "string"
) {
return { startLine: parsed.start_line, content: parsed.content }
}
} catch {
// not JSON
}
return { startLine: 1, content: raw }
}
/** Lightweight file content viewer with line numbers */
function FileContentLines({
content,
startLine = 1,
}: {
content: string
startLine?: number
}) {
const lines = useMemo(() => content.split("\n"), [content])
return (
<div className="inline-block min-w-full font-mono text-[12px] leading-[20px]">
{lines.map((line, i) => (
<div key={i} className="flex">
<span className="w-[3.5rem] shrink-0 select-none pr-1 text-right text-muted-foreground/40">
{startLine + i}
</span>
<span className="flex-1 whitespace-pre pr-3">{line}</span>
</div>
))}
</div>
)
}
/** Read / Write / NotebookEdit: file-focused display */
function FileToolInput({
toolName,
input,
output,
}: {
toolName: string
input: Record<string, unknown>
output?: string | null
}) {
const t = useTranslations("Folder.chat.contentParts")
const name = toolName.toLowerCase()
@@ -1389,48 +1321,52 @@ function FileToolInput({
const pages = str(input, "pages")
const cellType = str(input, "cell_type")
const editMode = str(input, "edit_mode")
const isRead = name === "read" || name === "read file"
const lang = filePath
? guessLangFromPath(filePath)
: ("log" as BundledLanguage)
const badges: string[] = []
if (offset != null) badges.push(t("offset", { offset }))
if (limit != null) badges.push(t("limit", { limit }))
if (pages) badges.push(t("pages", { pages }))
if (editMode) badges.push(t("mode", { mode: editMode }))
if (cellType) badges.push(t("cell", { cell: cellType }))
const { displayContent, startLine } = useMemo(() => {
if (isRead && output) {
const parsed = parseReadOutput(output)
return { displayContent: parsed.content, startLine: parsed.startLine }
}
return {
displayContent: content ?? newSource ?? null,
startLine: 1,
}
}, [isRead, output, content, newSource])
return (
<div className="space-y-2">
{filePath && (
<div className="flex items-center gap-2 text-xs">
{name === "read" || name === "read file" ? (
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<FilePlusIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="break-all font-mono text-foreground">
{filePath}
<section className="flex max-h-[420px] flex-col rounded-lg border border-border bg-background">
<header className="flex shrink-0 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">
{isRead ? "READ" : "WRITE"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-foreground"
title={filePath ?? undefined}
>
{filePath ?? t("unknown")}
</span>
{badges.length > 0 && (
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
{badges.map((b) => (
<span key={b}>{b}</span>
))}
</span>
)}
</header>
{displayContent && (
<div className="overflow-auto">
<FileContentLines content={displayContent} startLine={startLine} />
</div>
)}
{(offset != null || limit != null || pages) && (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{offset != null && <span>{t("offset", { offset })}</span>}
{limit != null && <span>{t("limit", { limit })}</span>}
{pages && <span>{t("pages", { pages })}</span>}
</div>
)}
{(cellType || editMode) && (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{editMode && <span>{t("mode", { mode: editMode })}</span>}
{cellType && <span>{t("cell", { cell: cellType })}</span>}
</div>
)}
{(name === "write" || name === "notebookedit") &&
(content || newSource) &&
(lang === "markdown" || lang === "mdx" ? (
<div className="rounded-md border p-3 text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
<MessageResponse>{content ?? newSource ?? ""}</MessageResponse>
</div>
) : (
<CodeBlock code={content ?? newSource ?? ""} language={lang} />
))}
</div>
</section>
)
}
@@ -1641,49 +1577,7 @@ function TodoWriteToolInput({ input }: { input: Record<string, unknown> }) {
}
function ApplyPatchToolInput({ input }: { input: string }) {
const t = useTranslations("Folder.chat.contentParts")
const { files, additions, deletions } = useMemo(
() => parseApplyPatchInput(input),
[input]
)
const opClass: Record<ApplyPatchOp, string> = {
add: "bg-green-500/15 text-green-600",
update: "bg-blue-500/15 text-blue-600",
delete: "bg-red-500/15 text-red-600",
move: "bg-purple-500/15 text-purple-600",
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span>{t("filesCount", { count: files.length })}</span>
{additions > 0 && <span>+{additions}</span>}
{deletions > 0 && <span>-{deletions}</span>}
</div>
{files.length > 0 && (
<div className="space-y-1 rounded-md bg-muted/40 p-2">
{files.slice(0, 8).map((file, index) => (
<div key={`${file.path}-${index}`} className="flex gap-2 text-xs">
<span
className={`shrink-0 rounded px-1.5 py-0.5 font-medium uppercase ${opClass[file.op]}`}
>
{file.op}
</span>
<span className="break-all font-mono text-foreground">
{file.path}
</span>
</div>
))}
{files.length > 8 && (
<div className="text-xs text-muted-foreground">
{t("moreFiles", { count: files.length - 8 })}
</div>
)}
</div>
)}
<CodeBlock code={input} language="diff" />
</div>
)
return <UnifiedDiffPreview diffText={input} />
}
// ── Switch mode (plan) input ──────────────────────────────────────────
@@ -1806,20 +1700,41 @@ function GenericToolInput({ input }: { input: string }) {
// ── Dispatcher ───────────────────────────────────────────────────────
function isTruncatedInput(input: string): boolean {
return input.endsWith('..."') || input.endsWith("...")
}
function StructuredToolInput({
toolName,
input,
output,
}: {
toolName: string
input: string
output?: string | null
}) {
const t = useTranslations("Folder.chat.contentParts")
const name = toolName.toLowerCase()
const parsed = tryParseJson(input)
const truncated =
(name === "edit" || name === "write" || name === "apply_patch") &&
isTruncatedInput(input)
const truncationBanner = truncated ? (
<div className="rounded-md bg-yellow-500/10 px-2.5 py-1.5 text-[11px] text-yellow-700 dark:text-yellow-400">
{t("inputTruncated")}
</div>
) : null
if (name === "apply_patch") {
const patchInput =
extractApplyPatchTextFromUnknownInput(input, parsed) ?? input
return <ApplyPatchToolInput input={patchInput} />
return (
<>
{truncationBanner}
<ApplyPatchToolInput input={patchInput} />
</>
)
}
if (name === "bash" || name === "exec_command") {
@@ -1843,16 +1758,41 @@ function StructuredToolInput({
if (name === "edit") {
const patchInput = extractApplyPatchTextFromUnknownInput(input, parsed)
if (patchInput) {
return <ApplyPatchToolInput input={patchInput} />
return (
<>
{truncationBanner}
<ApplyPatchToolInput input={patchInput} />
</>
)
}
if (parsed) {
const changesPayload = extractEditChangesPayload(parsed)
if (changesPayload.length > 0) {
return <EditChangesToolInput changes={changesPayload} />
return (
<>
{truncationBanner}
<EditChangesToolInput changes={changesPayload} />
</>
)
}
}
// Prefer tool output if it contains a structured diff with real line numbers
// (injected by backend from toolUseResult.structuredPatch)
if (output && typeof output === "string" && /^@@ /m.test(output)) {
return (
<>
{truncationBanner}
<UnifiedDiffPreview diffText={output} />
</>
)
}
if (isCanonicalEditPayload(parsed)) {
return <EditToolInput input={parsed} />
return (
<>
{truncationBanner}
<EditToolInput input={parsed} />
</>
)
}
return <GenericToolInput input={input} />
}
@@ -1864,7 +1804,7 @@ function StructuredToolInput({
name === "write" ||
name === "notebookedit"
)
return <FileToolInput toolName={toolName} input={parsed} />
return <FileToolInput toolName={toolName} input={parsed} output={output} />
if (name === "glob" || name === "grep")
return <SearchToolInput toolName={toolName} input={parsed} />
if (name === "webfetch" || name === "websearch")
@@ -2287,12 +2227,18 @@ const ToolCallPart = memo(function ToolCallPart({
displayCommand,
isRunning,
])
const isFileTool =
toolNameLower === "read" ||
toolNameLower === "read file" ||
toolNameLower === "write" ||
toolNameLower === "notebookedit"
const shouldHideDuplicateResult =
(toolNameLower === "edit" ||
toolNameLower === "apply_patch" ||
toolNameLower === "switch_mode" ||
toolNameLower === "enterplanmode" ||
toolNameLower === "exitplanmode") &&
toolNameLower === "exitplanmode" ||
isFileTool) &&
!part.errorText
// Cline: attempt_completion — render as an expanded card with result + progress
if (toolNameLower === "attempt_completion") {
@@ -2348,6 +2294,7 @@ const ToolCallPart = memo(function ToolCallPart({
<StructuredToolInput
toolName={normalizedToolName}
input={part.input}
output={part.output}
/>
)}
{(toolNameLower === "task" || toolNameLower === "agent") &&
+25 -9
View File
@@ -5,6 +5,8 @@ import { Settings } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import type { FolderHistoryEntry } from "@/lib/types"
import { FolderList } from "@/components/welcome/folder-list"
import { FolderActions } from "@/components/welcome/folder-actions"
@@ -18,6 +20,28 @@ export function WelcomeScreen() {
const t = useTranslations("WelcomePage")
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const { shortcuts } = useShortcutSettings()
const handleOpenSettings = useCallback(() => {
openSettingsWindow().catch((err) => {
console.error("[WelcomeScreen] failed to open settings:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openSettingsFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
})
}, [t])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (matchShortcutEvent(e, shortcuts.open_settings)) {
e.preventDefault()
handleOpenSettings()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [shortcuts, handleOpenSettings])
const refreshHistory = useCallback(async () => {
try {
@@ -49,15 +73,7 @@ export function WelcomeScreen() {
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={() => {
openSettingsWindow().catch((err) => {
console.error("[WelcomeScreen] failed to open settings:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openSettingsFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
})
}}
onClick={handleOpenSettings}
title={t("openSettings")}
aria-label={t("openSettings")}
type="button"
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.",
"result": "النتيجة",
"unknown": "غير معروف",
"inputTruncated": "تم اقتطاع الإدخال — قد يكون الفرق غير مكتمل.",
"replaceAll": "استبدال الكل",
"filesCount": "الملفات: {count}",
"update": "تحديث",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.",
"result": "Ergebnis",
"unknown": "unbekannt",
"inputTruncated": "Eingabe wurde gekürzt — Diff ist möglicherweise unvollständig.",
"replaceAll": "ALLES ERSETZEN",
"filesCount": "Dateien: {count}",
"update": "aktualisieren",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "Showing tail output while streaming for performance.",
"result": "Result",
"unknown": "unknown",
"inputTruncated": "Input was truncated — diff may be incomplete.",
"replaceAll": "REPLACE ALL",
"filesCount": "Files: {count}",
"update": "update",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.",
"result": "Resultado",
"unknown": "desconocido",
"inputTruncated": "La entrada fue truncada — el diff puede estar incompleto.",
"replaceAll": "REEMPLAZAR TODO",
"filesCount": "Archivos: {count}",
"update": "actualizar",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.",
"result": "Résultat",
"unknown": "inconnu",
"inputTruncated": "L'entrée a été tronquée — le diff peut être incomplet.",
"replaceAll": "TOUT REMPLACER",
"filesCount": "Fichiers : {count}",
"update": "mettre à jour",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。",
"result": "結果",
"unknown": "不明",
"inputTruncated": "入力が切り詰められました — diff が不完全な可能性があります。",
"replaceAll": "すべて置換",
"filesCount": "ファイル: {count}",
"update": "更新",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.",
"result": "결과",
"unknown": "알 수 없음",
"inputTruncated": "입력이 잘렸습니다 — diff가 불완전할 수 있습니다.",
"replaceAll": "모두 바꾸기",
"filesCount": "파일: {count}",
"update": "업데이트",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.",
"result": "Resultado",
"unknown": "desconhecido",
"inputTruncated": "A entrada foi truncada — o diff pode estar incompleto.",
"replaceAll": "SUBSTITUIR TUDO",
"filesCount": "Arquivos: {count}",
"update": "atualizar",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "为保证性能,流式输出时仅显示尾部内容。",
"result": "结果",
"unknown": "未知",
"inputTruncated": "输入已截断,diff 可能不完整。",
"replaceAll": "全部替换",
"filesCount": "文件:{count}",
"update": "更新",
+1
View File
@@ -1491,6 +1491,7 @@
"showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。",
"result": "結果",
"unknown": "未知",
"inputTruncated": "輸入已截斷,diff 可能不完整。",
"replaceAll": "全部替換",
"filesCount": "檔案:{count}",
"update": "更新",
+30 -4
View File
@@ -12,6 +12,7 @@ export interface PermissionFileChange {
oldText: string
newText: string
unifiedDiff?: string
startLine?: number
}
export interface PermissionPlanEntry {
@@ -111,7 +112,8 @@ function buildCompactDiffFromTexts(
path: string,
oldText: string,
newText: string,
contextLines: number = 2
contextLines: number = 2,
startLine: number = 1
): string | null {
const oldLines = splitNormalizedLines(oldText)
const newLines = splitNormalizedLines(newText)
@@ -145,7 +147,15 @@ function buildCompactDiffFromTexts(
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
)
const parts: string[] = [`--- ${path}`, `+++ ${path}`]
const oldStart = Math.max(1, startLine + prefix - before.length)
const oldCount = before.length + removed.length + after.length
const newCount = before.length + added.length + after.length
const parts: string[] = [
`--- ${path}`,
`+++ ${path}`,
`@@ -${oldStart},${oldCount} +${oldStart},${newCount} @@`,
]
for (const line of before) parts.push(` ${line}`)
for (const line of removed) parts.push(`-${line}`)
for (const line of added) parts.push(`+${line}`)
@@ -189,7 +199,13 @@ function buildDiffPreviewFromChanges(
typeof change.unifiedDiff === "string" &&
change.unifiedDiff.trim().length > 0
? change.unifiedDiff.trim()
: buildCompactDiffFromTexts(change.path, change.oldText, change.newText)
: buildCompactDiffFromTexts(
change.path,
change.oldText,
change.newText,
2,
change.startLine ?? 1
)
if (!block) continue
for (const line of block.split("\n")) {
@@ -350,11 +366,18 @@ function parseChangeRecord(
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
undefined
const rawStartLine = record._start_line ?? record.start_line
const startLine =
typeof rawStartLine === "number" && rawStartLine > 0
? rawStartLine
: undefined
return {
path: normalizedPath,
oldText,
newText,
unifiedDiff,
startLine,
}
}
@@ -402,11 +425,13 @@ function extractRawInputFileChanges(
]) ?? ""
if (oldText || newText || changes.length === 0) {
const rawSl = rawInputObj._start_line ?? rawInputObj.start_line
changes.push({
path: directPath,
oldText,
newText,
unifiedDiff: undefined,
startLine: typeof rawSl === "number" && rawSl > 0 ? rawSl : undefined,
})
}
}
@@ -495,7 +520,8 @@ function mergeFileChanges(
const oldText = prev.oldText || change.oldText
const newText = prev.newText || change.newText
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
merged.set(path, { path, oldText, newText, unifiedDiff })
const startLine = prev.startLine ?? change.startLine
merged.set(path, { path, oldText, newText, unifiedDiff, startLine })
}
return Array.from(merged.values())
}
+52 -32
View File
@@ -1,5 +1,7 @@
import type { MessageTurn } from "./types"
import type { ContentBlock, MessageTurn } from "./types"
import { normalizeToolName } from "./tool-call-normalization"
import { estimateChangedLineStats } from "./line-change-stats"
import { generateUnifiedDiff } from "./unified-diff-generator"
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
@@ -266,32 +268,12 @@ function countDiffLines(text: string): DiffStat {
return { additions, deletions }
}
function createHunkHeader(oldLineCount: number, newLineCount: number): string {
const oldStart = oldLineCount === 0 ? 0 : 1
const newStart = newLineCount === 0 ? 0 : 1
return `@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
}
function buildUnifiedDiff(
filePath: string,
oldText: string,
newText: string
): string | null {
if (!oldText && !newText) return null
const oldLines = oldText ? oldText.split("\n") : []
const newLines = newText ? newText.split("\n") : []
const lines: string[] = [
`--- a/${filePath}`,
`+++ b/${filePath}`,
createHunkHeader(oldLines.length, newLines.length),
]
for (const line of oldLines) lines.push(`-${line}`)
for (const line of newLines) lines.push(`+${line}`)
return lines.join("\n")
return generateUnifiedDiff(oldText, newText, filePath)
}
function parseEditChangeValue(value: unknown): EditChangePreview | null {
@@ -645,8 +627,12 @@ function computeLineDiff(
continue
}
additions += countLines(change.newText)
deletions += countLines(change.oldText)
const estimated = estimateChangedLineStats(
change.oldText,
change.newText
)
additions += estimated.additions
deletions += estimated.deletions
}
return { additions, deletions }
@@ -662,10 +648,7 @@ function computeLineDiff(
if (!oldStr && !newStr) return null
return {
additions: countLines(newStr),
deletions: countLines(oldStr),
}
return estimateChangedLineStats(oldStr, newStr)
}
if (op === "write") {
@@ -774,10 +757,12 @@ export function extractSessionFilesGrouped(
block.input_preview,
normalizedPath
)
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
const diffChunk = buildDiffChunk(
normalized,
block.input_preview,
normalizedPath
normalizedPath,
toolOutput
)
currentFiles.push({
@@ -836,10 +821,12 @@ export function buildSessionFileDiff(
)
if (!blockPaths.includes(normalizedTargetPath)) continue
const toolOutput = findToolResultOutput(turn.blocks, block.tool_use_id)
const chunk = buildDiffChunk(
normalized,
block.input_preview,
normalizedTargetPath
normalizedTargetPath,
toolOutput
)
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
}
@@ -852,10 +839,30 @@ export function buildSessionFileDiff(
return chunks.join("\n\n")
}
/** Find the tool_result output matching a tool_use_id within the same turn. */
function findToolResultOutput(
blocks: ContentBlock[],
toolUseId: string | null
): string | null {
if (!toolUseId) return null
for (const block of blocks) {
if (
block.type === "tool_result" &&
block.tool_use_id === toolUseId &&
block.output_preview &&
!block.is_error
) {
return block.output_preview
}
}
return null
}
function buildDiffChunk(
op: string,
inputPreview: string | null,
filePath: string
filePath: string,
toolOutput?: string | null
): string | null {
if (!inputPreview) return null
@@ -877,6 +884,11 @@ function buildDiffChunk(
}
}
// Prefer tool output if backend injected a real diff with line numbers
if (toolOutput && /^@@ /m.test(toolOutput)) {
return toolOutput.trim()
}
if (!parsed) return null
const oldStr =
@@ -884,7 +896,15 @@ function buildDiffChunk(
const newStr =
typeof parsed.new_string === "string" ? parsed.new_string : ""
return buildUnifiedDiff(filePath, oldStr, newStr)
const diff = buildUnifiedDiff(filePath, oldStr, newStr)
if (!diff) return null
const startLine =
typeof parsed._start_line === "number" ? parsed._start_line : 0
if (startLine <= 1) return diff
return diff.replace(
/^@@ -(\d+),(\d+) \+(\d+),(\d+) @@/gm,
(_, _o, oc, _n, nc) => `@@ -${startLine},${oc} +${startLine},${nc} @@`
)
}
if (op === "write") {
+180
View File
@@ -0,0 +1,180 @@
import { computeLineDiff, type DiffHunk } from "@/components/merge/merge-diff"
/**
* Maximum product of line counts before falling back to naive diff.
* Avoids O(n*m) LCS blowup for very large inputs.
*/
const LCS_PAIR_BUDGET = 200_000
/**
* Generate a unified diff string from old and new text.
*
* Uses LCS-based line diff when within budget, falls back to
* simple "all deletions then all additions" for very large inputs.
*/
export function generateUnifiedDiff(
oldText: string,
newText: string,
filePath?: string,
contextLines: number = 3
): string | null {
if (!oldText && !newText) return null
if (oldText === newText) return null
const oldLines = oldText ? splitLines(oldText) : []
const newLines = newText ? splitLines(newText) : []
const path = filePath ?? "file"
const header = `--- a/${path}\n+++ b/${path}`
// Performance gate: fall back to naive diff for large inputs
if (oldLines.length * newLines.length > LCS_PAIR_BUDGET) {
return buildNaiveDiff(header, oldLines, newLines)
}
const hunks = computeLineDiff(oldLines, newLines)
if (hunks.length === 0) return null
const unifiedHunks = buildUnifiedHunks(oldLines, hunks, contextLines)
return `${header}\n${unifiedHunks}`
}
function splitLines(text: string): string[] {
const lines = text.split("\n")
// Remove trailing empty line from trailing newline
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop()
}
return lines
}
/**
* Naive diff: all deletions first, then all additions, with a single hunk header.
* Used as fallback when inputs are too large for LCS.
*/
function buildNaiveDiff(
header: string,
oldLines: string[],
newLines: string[]
): string {
const oldStart = oldLines.length === 0 ? 0 : 1
const newStart = newLines.length === 0 ? 0 : 1
const hunkHeader = `@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`
const parts = [header, hunkHeader]
for (const line of oldLines) parts.push(`-${line}`)
for (const line of newLines) parts.push(`+${line}`)
return parts.join("\n")
}
/**
* Convert DiffHunk[] into unified diff text with context lines and hunk headers.
*
* Groups nearby hunks that overlap in their context windows into a single
* unified hunk, producing output similar to `diff -u`.
*/
function buildUnifiedHunks(
oldLines: string[],
hunks: DiffHunk[],
contextLines: number
): string {
// Build "change regions" with context, then merge overlapping ones
const regions = hunks.map((hunk) => ({
// Context-expanded range in old lines
ctxOldStart: Math.max(0, hunk.baseStart - contextLines),
ctxOldEnd: Math.min(
oldLines.length,
hunk.baseStart + hunk.baseCount + contextLines
),
hunk,
}))
// Merge overlapping regions
const merged: {
ctxOldStart: number
ctxOldEnd: number
hunks: DiffHunk[]
}[] = []
for (const region of regions) {
const last = merged[merged.length - 1]
if (last && region.ctxOldStart <= last.ctxOldEnd) {
// Overlapping — extend and add hunk
last.ctxOldEnd = Math.max(last.ctxOldEnd, region.ctxOldEnd)
last.hunks.push(region.hunk)
} else {
merged.push({
ctxOldStart: region.ctxOldStart,
ctxOldEnd: region.ctxOldEnd,
hunks: [region.hunk],
})
}
}
// Render each merged region as a unified hunk
const output: string[] = []
for (const group of merged) {
const lines: string[] = []
let oldCursor = group.ctxOldStart
let newLineCount = 0
const oldLineCount = group.ctxOldEnd - group.ctxOldStart
for (const hunk of group.hunks) {
// Context lines before this change
while (oldCursor < hunk.baseStart) {
lines.push(` ${oldLines[oldCursor]}`)
newLineCount++
oldCursor++
}
// Deleted lines
for (let i = 0; i < hunk.baseCount; i++) {
lines.push(`-${oldLines[hunk.baseStart + i]}`)
oldCursor++
}
// Added lines
for (const newLine of hunk.newLines) {
lines.push(`+${newLine}`)
newLineCount++
}
}
// Trailing context
while (oldCursor < group.ctxOldEnd) {
lines.push(` ${oldLines[oldCursor]}`)
newLineCount++
oldCursor++
}
// Compute hunk header
const oldStart = oldLineCount === 0 ? 0 : group.ctxOldStart + 1
const newStart =
newLineCount === 0
? 0
: group.ctxOldStart +
1 +
computeNewOffset(group.hunks, group.ctxOldStart)
output.push(
`@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
)
output.push(...lines)
}
return output.join("\n")
}
/**
* Compute the offset applied to new-line numbering by hunks before a given position.
*/
function computeNewOffset(hunks: DiffHunk[], beforeOldLine: number): number {
let offset = 0
for (const hunk of hunks) {
if (hunk.baseStart >= beforeOldLine) break
offset += hunk.newLines.length - hunk.baseCount
}
return offset
}