From c6ddd3833c8641583a1257459b6ffaac366b844e Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Sat, 28 Mar 2026 00:47:57 +0200 Subject: [PATCH] Add syntax highlighting and context-aware trimming to inline diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @pierre/diffs integration for language-aware syntax highlighting - Support dark/light theme switching for highlighted code - Implement smart context trimming: shows changed lines + 3-line radius context - Insert separators (···) between omitted context gaps - Calculate diff stats from full diff before line truncation - Fix timestamp formatting to use 2-digit hours for consistency --- .../src/components/chat/InlineDiffPreview.tsx | 274 +++++++++++++++--- apps/web/src/lib/inlineDiff.test.ts | 15 + apps/web/src/lib/inlineDiff.ts | 52 +++- apps/web/src/timestampFormat.test.ts | 6 +- apps/web/src/timestampFormat.ts | 2 +- 5 files changed, 301 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/chat/InlineDiffPreview.tsx b/apps/web/src/components/chat/InlineDiffPreview.tsx index 84a008a689e..dfd8b63e44f 100644 --- a/apps/web/src/components/chat/InlineDiffPreview.tsx +++ b/apps/web/src/components/chat/InlineDiffPreview.tsx @@ -1,15 +1,116 @@ -import { memo, useMemo, useState } from "react"; +import { + type DiffsHighlighter, + getSharedHighlighter, + type SupportedLanguages, +} from "@pierre/diffs"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; -import { type DiffLine, type InlineDiffHunk, diffStats } from "~/lib/inlineDiff"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useTheme } from "~/hooks/useTheme"; +import { type DiffLine, type InlineDiffHunk } from "~/lib/inlineDiff"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; -const MAX_VISIBLE_HEIGHT_PX = 260; +const highlighterPromiseCache = new Map>(); -function formatSummary(additions: number, deletions: number): string { - const parts: string[] = []; - if (additions > 0) parts.push(`+${additions}`); - if (deletions > 0) parts.push(`-${deletions}`); - return parts.join(", "); +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") throw err; + return getHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} + +function resolveLanguageFromPath(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return "text"; + const MAP: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + mts: "typescript", + cts: "typescript", + py: "python", + rs: "rust", + go: "go", + css: "css", + scss: "scss", + html: "html", + json: "json", + jsonc: "jsonc", + yaml: "yaml", + yml: "yaml", + toml: "toml", + md: "markdown", + mdx: "mdx", + sh: "bash", + bash: "bash", + zsh: "bash", + sql: "sql", + rb: "ruby", + java: "java", + swift: "swift", + kt: "kotlin", + c: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + h: "c", + hpp: "cpp", + vue: "vue", + svelte: "svelte", + xml: "xml", + svg: "xml", + lua: "lua", + php: "php", + dart: "dart", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + r: "r", + tf: "hcl", + dockerfile: "dockerfile", + graphql: "graphql", + gql: "graphql", + prisma: "prisma", + proto: "protobuf", + }; + return MAP[ext] ?? "text"; +} + +function extractLineHtmls(fullHtml: string): string[] { + const codeStart = fullHtml.indexOf(""); + if (codeStart === -1 || codeEnd === -1) return []; + + const codeTagClose = fullHtml.indexOf(">", codeStart); + if (codeTagClose === -1) return []; + + const inner = fullHtml.slice(codeTagClose + 1, codeEnd); + + return inner.split("\n").map((raw) => { + let line = raw; + const openIdx = line.indexOf(">"); + if (line.startsWith("")) { + line = line.slice(0, -7); + } + return line; + }); } function shortenPath(filePath: string): string { @@ -33,15 +134,101 @@ const OPERATION_LABELS: Record = { write: "Write", }; +function DiffStatSummary(props: { additions: number; deletions: number }) { + const { additions, deletions } = props; + if (additions === 0 && deletions === 0) return null; + + return ( + + {additions > 0 && ( + + +{additions} + + )} + {deletions > 0 && ( + + -{deletions} + + )} + + ); +} + +const LINE_BG: Record = { + deletion: "bg-[color-mix(in_srgb,var(--background)_88%,var(--destructive))]", + addition: "bg-[color-mix(in_srgb,var(--background)_88%,var(--success))]", + context: "", + separator: "", +}; + +const LINE_TEXT_PLAIN: Record = { + deletion: "text-[color-mix(in_srgb,var(--foreground)_70%,var(--destructive))]", + addition: "text-[color-mix(in_srgb,var(--foreground)_70%,var(--success))]", + context: "text-muted-foreground/60", + separator: "text-muted-foreground/30", +}; + +const MARKER_CHAR: Record = { + deletion: "-", + addition: "+", + context: " ", + separator: " ", +}; + export const InlineDiffPreview = memo(function InlineDiffPreview(props: { hunk: InlineDiffHunk }) { const { hunk } = props; const [collapsed, setCollapsed] = useState(false); - const stats = diffStats(hunk.lines); - const summary = formatSummary(stats.additions, stats.deletions); + const { resolvedTheme } = useTheme(); + const keyedLines = useMemo(() => { const keys = buildLineKeys(hunk.lines); return hunk.lines.map((line, i) => ({ ...line, key: keys[i]! })); }, [hunk.lines]); + + const [lineHtmls, setLineHtmls] = useState(null); + const highlightVersionRef = useRef(0); + + useEffect(() => { + const version = ++highlightVersionRef.current; + const language = resolveLanguageFromPath(hunk.filePath); + if (language === "text") return; + + const codeLineIndices: number[] = []; + const codeFragments: string[] = []; + for (let i = 0; i < hunk.lines.length; i++) { + if (hunk.lines[i]!.type !== "separator") { + codeLineIndices.push(i); + codeFragments.push(hunk.lines[i]!.content); + } + } + if (codeFragments.length === 0) return; + + const code = codeFragments.join("\n"); + const themeName = resolveDiffThemeName(resolvedTheme); + + getHighlighterPromise(language) + .then((highlighter) => { + if (highlightVersionRef.current !== version) return; + try { + const html = highlighter.codeToHtml(code, { lang: language, theme: themeName }); + const extracted = extractLineHtmls(html); + if (extracted.length === codeFragments.length) { + const mapped: (string | null)[] = Array(hunk.lines.length).fill(null) as ( + | string + | null + )[]; + for (let i = 0; i < codeLineIndices.length; i++) { + mapped[codeLineIndices[i]!] = extracted[i]!; + } + setLineHtmls(mapped as string[]); + } + } catch { + // noop + } + }) + .catch(() => {}); + }, [hunk.filePath, hunk.lines, resolvedTheme]); + const CollapseIcon = collapsed ? ChevronRightIcon : ChevronDownIcon; return ( @@ -55,38 +242,45 @@ export const InlineDiffPreview = memo(function InlineDiffPreview(props: { hunk: {OPERATION_LABELS[hunk.operation]}({shortenPath(hunk.filePath)}) - {summary && ( - - {summary} - - )} + {!collapsed && ( -
-
-
-              {keyedLines.map((line) => (
-                
- - {line.type === "deletion" ? "-" : line.type === "addition" ? "+" : " "} - - {line.content} -
- ))} +
+
+
+              {keyedLines.map((line, idx) => {
+                if (line.type === "separator") {
+                  return (
+                    
+ ··· +
+ ); + } + const highlighted = lineHtmls?.[idx]; + return ( +
+ + {MARKER_CHAR[line.type]} + + {highlighted ? ( + + ) : ( + line.content + )} +
+ ); + })}
@@ -96,7 +290,7 @@ export const InlineDiffPreview = memo(function InlineDiffPreview(props: { hunk:
)} - {!hunk.truncated && hunk.lines.length * 18 > MAX_VISIBLE_HEIGHT_PX && ( + {!hunk.truncated && hunk.lines.length > 14 && (
)}
diff --git a/apps/web/src/lib/inlineDiff.test.ts b/apps/web/src/lib/inlineDiff.test.ts index c51b401d8f4..b8f27f5f1fd 100644 --- a/apps/web/src/lib/inlineDiff.test.ts +++ b/apps/web/src/lib/inlineDiff.test.ts @@ -176,6 +176,18 @@ describe("extractDiffPreviews", () => { expect(result[0]!.lines.length).toBeLessThanOrEqual(40); expect(result[0]!.truncated).toBe(true); }); + + it("computes stats from full diff before truncation", () => { + const bigOld = Array.from({ length: 50 }, (_, i) => `old-${i}`).join("\n"); + const bigNew = Array.from({ length: 50 }, (_, i) => `new-${i}`).join("\n"); + const result = extractDiffPreviews({ + data: { + toolName: "Edit", + input: { file_path: "big.ts", old_string: bigOld, new_string: bigNew }, + }, + }); + expect(result[0]!.stats).toEqual({ additions: 50, deletions: 50 }); + }); }); describe("mergeDiffPreviews", () => { @@ -184,18 +196,21 @@ describe("mergeDiffPreviews", () => { operation: "edit", lines: [{ type: "context", content: "a" }], truncated: false, + stats: { additions: 0, deletions: 0 }, }; const hunkB: InlineDiffHunk = { filePath: "b.ts", operation: "write", lines: [{ type: "addition", content: "b" }], truncated: false, + stats: { additions: 1, deletions: 0 }, }; const hunkAUpdated: InlineDiffHunk = { filePath: "a.ts", operation: "edit", lines: [{ type: "addition", content: "updated" }], truncated: false, + stats: { additions: 1, deletions: 0 }, }; it("returns b when a is empty", () => { diff --git a/apps/web/src/lib/inlineDiff.ts b/apps/web/src/lib/inlineDiff.ts index 34eaafb4cfc..e9cbed776d1 100644 --- a/apps/web/src/lib/inlineDiff.ts +++ b/apps/web/src/lib/inlineDiff.ts @@ -1,17 +1,24 @@ export interface DiffLine { - type: "context" | "addition" | "deletion"; + type: "context" | "addition" | "deletion" | "separator"; content: string; } +export interface DiffStats { + additions: number; + deletions: number; +} + export interface InlineDiffHunk { filePath: string; operation: "edit" | "write"; lines: ReadonlyArray; truncated: boolean; + stats: DiffStats; } const MAX_DIFF_LINES = 40; const MAX_LCS_INPUT_LINES = 200; +const CONTEXT_RADIUS = 3; const EDIT_TOOL_NAMES = new Set([ "edit", @@ -136,6 +143,40 @@ function computeLCS(oldLines: string[], newLines: string[]): LCSMatch[] { return matches; } +function trimContext(lines: DiffLine[]): DiffLine[] { + const changeIndices: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i]!.type !== "context") { + changeIndices.push(i); + } + } + + if (changeIndices.length === 0) return lines; + + const keep = new Set(); + for (const idx of changeIndices) { + keep.add(idx); + for (let offset = 1; offset <= CONTEXT_RADIUS; offset++) { + if (idx - offset >= 0) keep.add(idx - offset); + if (idx + offset < lines.length) keep.add(idx + offset); + } + } + + const result: DiffLine[] = []; + let lastKept = -1; + for (let i = 0; i < lines.length; i++) { + if (keep.has(i)) { + if (lastKept !== -1 && i - lastKept > 1) { + result.push({ type: "separator", content: "" }); + } + result.push(lines[i]!); + lastKept = i; + } + } + + return result; +} + function truncateDiffLines(lines: DiffLine[]): { lines: ReadonlyArray; truncated: boolean; @@ -165,9 +206,11 @@ function extractEditHunk(input: Record): InlineDiffHunk | null if (!filePath || oldString == null || newString == null) return null; const rawLines = computeLineDiff(oldString, newString); - const { lines, truncated } = truncateDiffLines(rawLines); + const stats = diffStats(rawLines); + const trimmed = trimContext(rawLines); + const { lines, truncated } = truncateDiffLines(trimmed); - return { filePath, operation: "edit", lines, truncated }; + return { filePath, operation: "edit", lines, truncated, stats }; } function extractWriteHunk(input: Record): InlineDiffHunk | null { @@ -181,9 +224,10 @@ function extractWriteHunk(input: Record): InlineDiffHunk | null type: "addition" as const, content: line, })); + const stats: DiffStats = { additions: rawLines.length, deletions: 0 }; const { lines, truncated } = truncateDiffLines(rawLines); - return { filePath, operation: "write", lines, truncated }; + return { filePath, operation: "write", lines, truncated, stats }; } export function extractDiffPreviews(payload: Record | null): InlineDiffHunk[] { diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index f45ada7341c..3570b3aa2fb 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -5,7 +5,7 @@ import { getTimestampFormatOptions } from "./timestampFormat"; describe("getTimestampFormatOptions", () => { it("omits hour12 when locale formatting is requested", () => { expect(getTimestampFormatOptions("locale", true)).toEqual({ - hour: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", }); @@ -13,7 +13,7 @@ describe("getTimestampFormatOptions", () => { it("builds a 12-hour formatter with seconds when requested", () => { expect(getTimestampFormatOptions("12-hour", true)).toEqual({ - hour: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true, @@ -22,7 +22,7 @@ describe("getTimestampFormatOptions", () => { it("builds a 24-hour formatter without seconds when requested", () => { expect(getTimestampFormatOptions("24-hour", false)).toEqual({ - hour: "numeric", + hour: "2-digit", minute: "2-digit", hour12: false, }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index dc164445046..048b422d8de 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -5,7 +5,7 @@ export function getTimestampFormatOptions( includeSeconds: boolean, ): Intl.DateTimeFormatOptions { const baseOptions: Intl.DateTimeFormatOptions = { - hour: "numeric", + hour: "2-digit", minute: "2-digit", ...(includeSeconds ? { second: "2-digit" } : {}), };