diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index 5f8ed376..2254eedd 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { useThemeValue } from "@/hooks/useTheme"; import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { normalizeCodeLanguage } from "@/lib/code-language"; import { cn } from "@/lib/utils"; interface CodeBlockProps { @@ -191,6 +192,7 @@ export function CodeBlock({ const isDark = useThemeValue() === "dark"; const hasChrome = chrome === "default"; const renderAnsi = shouldRenderAnsi(language, code); + const syntaxLanguage = normalizeCodeLanguage(language); const onCopy = useCallback(() => { void copyTextToClipboard(renderAnsi ? stripAnsi(code) : code).then((ok) => { @@ -261,7 +263,7 @@ export function CodeBlock({ } > >[0]; +type SyntaxNode = RendererArgs["rows"][number]; + +const CODE_FONT_STACK = [ + '"JetBrains Mono"', + '"SFMono-Regular"', + '"SF Mono"', + '"Fira Code"', + '"Cascadia Code"', + '"Source Code Pro"', + "Menlo", + "Consolas", + "monospace", +].join(", "); + +const LazyDiffSyntaxHighlight = lazy(async () => { + const [ + { default: SyntaxHighlighter }, + { default: createSyntaxElement }, + { default: oneDark }, + { default: oneLight }, + ] = await Promise.all([ + import("react-syntax-highlighter/dist/esm/prism-async-light"), + import("react-syntax-highlighter/dist/esm/create-element"), + import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"), + import("react-syntax-highlighter/dist/esm/styles/prism/one-light"), + ]); + return { + default: function LoadedDiffSyntaxHighlight({ + language, + lines, + isDark, + }: LoadedDiffSyntaxHighlightProps) { + const theme = isDark ? oneDark : oneLight; + const code = lines.map((line) => line.content || " ").join("\n"); + return ( + ( + { + const node = rows[index]; + if (!node) return line.content || " "; + return createSyntaxElement({ + node: trimTrailingLineBreak(node), + stylesheet, + useInlineStyles, + key: `diff-code-${index}`, + }); + }} + /> + )} + > + {code} + + ); + }, + }; +}); + +export function DiffSyntaxHighlight({ language, lines }: DiffSyntaxHighlightProps) { + const isDark = useThemeValue() === "dark"; + return ( + }> + + + ); +} + +function PlainDiffLines({ lines }: { lines: RenderableFileDiffLine[] }) { + return ( +
+ line.content || " "} /> +
+ ); +} + +function DiffLineTable({ + lines, + renderCode, +}: { + lines: RenderableFileDiffLine[]; + renderCode: (line: RenderableFileDiffLine, index: number) => ReactNode; +}) { + return ( + + + {lines.map((line, index) => ( + + {renderCode(line, index)} + + ))} + +
+ ); +} + +function DiffLineRow({ + line, + children, +}: { + line: RenderableFileDiffLine; + children: ReactNode; +}) { + const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context"; + const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " "; + return ( + + + {line.old_lineno ?? ""} + + + {line.new_lineno ?? ""} + + + {marker} + + + {children} + + + ); +} + +function trimTrailingLineBreak(node: SyntaxNode): SyntaxNode { + if (node.type === "text" && typeof node.value === "string") { + return { ...node, value: node.value.replace(/\n$/, "") }; + } + if (!node.children?.length) return node; + const children = [...node.children]; + children[children.length - 1] = trimTrailingLineBreak(children[children.length - 1]!); + return { ...node, children }; +} diff --git a/webui/src/components/thread/activity/FileEditRow.tsx b/webui/src/components/thread/activity/FileEditRow.tsx index f6157260..4c77d492 100644 --- a/webui/src/components/thread/activity/FileEditRow.tsx +++ b/webui/src/components/thread/activity/FileEditRow.tsx @@ -16,14 +16,15 @@ import { parseRenderableFileDiff, type RenderableFileDiff, type RenderableFileDiffHunk, - type RenderableFileDiffLine, } from "@/lib/file-diff"; +import { codeLanguageFromPath } from "@/lib/code-language"; import type { FileEditDisplayMode } from "@/lib/local-preferences"; import type { UIFileDiff, UIFileEdit } from "@/lib/types"; import { cn } from "@/lib/utils"; import { ActivityStep } from "./ActivityStep"; import { DiffPair } from "./DiffPair"; +import { DiffSyntaxHighlight } from "./DiffSyntaxHighlight"; const INITIAL_VISIBLE_DIFF_LINES = 160; const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES; @@ -266,6 +267,7 @@ function FileUnifiedDiff({ const [open, setOpen] = useState(false); const [expandedLines, setExpandedLines] = useState(false); const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]); + const language = useMemo(() => codeLanguageFromPath(previewPath), [previewPath]); const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]); const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated; const startsCollapsed = collapsed || shouldAutoCollapse; @@ -312,16 +314,7 @@ function FileUnifiedDiff({ > {skippedBefore > 0 ? : null}
- - - {hunk.lines.map((line, lineIndex) => ( - - ))} - -
+
))} @@ -485,37 +478,3 @@ function DiffHunkGap({ lineCount }: { lineCount: number }) { ); } - -function DiffLineRow({ line }: { line: RenderableFileDiffLine }) { - const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context"; - const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " "; - return ( - - - {line.old_lineno ?? ""} - - - {line.new_lineno ?? ""} - - - {marker} - - - {line.content || " "} - - - ); -} diff --git a/webui/src/lib/code-language.ts b/webui/src/lib/code-language.ts new file mode 100644 index 00000000..8fe1acda --- /dev/null +++ b/webui/src/lib/code-language.ts @@ -0,0 +1,100 @@ +const LANGUAGE_ALIASES: Record = { + cjs: "javascript", + dockerfile: "docker", + htm: "markup", + html: "markup", + js: "javascript", + md: "markdown", + mts: "typescript", + py: "python", + rb: "ruby", + sh: "bash", + shell: "bash", + svg: "markup", + ts: "typescript", + txt: "text", + xml: "markup", + yml: "yaml", + zsh: "bash", +}; + +const FILE_NAME_LANGUAGES: Record = { + "cmakelists.txt": "cmake", + dockerfile: "docker", + gemfile: "ruby", + makefile: "makefile", + procfile: "ruby", +}; + +const EXTENSION_LANGUAGES: Record = { + bash: "bash", + c: "c", + cc: "cpp", + cjs: "javascript", + conf: "text", + cpp: "cpp", + cs: "csharp", + css: "css", + cts: "typescript", + cxx: "cpp", + env: "bash", + go: "go", + h: "c", + hpp: "cpp", + htm: "markup", + html: "markup", + ini: "ini", + java: "java", + js: "javascript", + json: "json", + jsonl: "json", + jsx: "jsx", + kt: "kotlin", + kts: "kotlin", + md: "markdown", + mdx: "markdown", + mjs: "javascript", + mts: "typescript", + php: "php", + ps1: "powershell", + py: "python", + pyi: "python", + rb: "ruby", + rs: "rust", + scss: "scss", + sh: "bash", + sql: "sql", + svg: "markup", + svelte: "svelte", + toml: "toml", + ts: "typescript", + tsx: "tsx", + vue: "vue", + xml: "markup", + yaml: "yaml", + yml: "yaml", + zsh: "bash", +}; + +export function normalizeCodeLanguage(language?: string | null): string { + const normalized = language?.trim().toLowerCase(); + if (!normalized) return "text"; + return LANGUAGE_ALIASES[normalized] ?? normalized; +} + +export function codeLanguageFromPath(path?: string | null): string { + if (!path?.trim()) return "text"; + const normalizedPath = path + .split("?", 1)[0]! + .split("#", 1)[0]! + .replace(/:\d+(?::\d+)?$/, "") + .replace(/\\/g, "/"); + const name = normalizedPath.split("/").pop()?.toLowerCase() ?? ""; + if (!name) return "text"; + if (name.startsWith("dockerfile.")) return "docker"; + const namedLanguage = FILE_NAME_LANGUAGES[name]; + if (namedLanguage) return namedLanguage; + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + if (!extension) return "text"; + return normalizeCodeLanguage(EXTENSION_LANGUAGES[extension] ?? extension); +} diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx index 0b4b4b22..a48440a8 100644 --- a/webui/src/tests/code-block.test.tsx +++ b/webui/src/tests/code-block.test.tsx @@ -88,6 +88,21 @@ describe("CodeBlock", () => { expect(screen.getByText("const value = 1;")).toBeInTheDocument(); }); + it("normalizes file language aliases before loading Prism", async () => { + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByTestId("highlighted-code")).toHaveAttribute("data-language", "markup"); + }); + it("renders ANSI output without mounting the syntax highlighter", () => { render( diff --git a/webui/src/tests/code-language.test.ts b/webui/src/tests/code-language.test.ts new file mode 100644 index 00000000..9ea05998 --- /dev/null +++ b/webui/src/tests/code-language.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { codeLanguageFromPath, normalizeCodeLanguage } from "@/lib/code-language"; + +describe("code language helpers", () => { + it.each([ + ["src/App.tsx", "tsx"], + ["templates/index.html", "markup"], + ["Dockerfile", "docker"], + ["Dockerfile.dev", "docker"], + ["CMakeLists.txt", "cmake"], + ["scripts/setup.sh:12:4", "bash"], + ["config/settings.yaml?raw=1", "yaml"], + ["unknown.customlang", "customlang"], + ])("infers %s as %s", (path, language) => { + expect(codeLanguageFromPath(path)).toBe(language); + }); + + it("normalizes aliases used by the file preview API", () => { + expect(normalizeCodeLanguage("html")).toBe("markup"); + expect(normalizeCodeLanguage("dockerfile")).toBe("docker"); + expect(normalizeCodeLanguage(undefined)).toBe("text"); + }); +}); diff --git a/webui/src/tests/diff-syntax-highlight.integration.test.tsx b/webui/src/tests/diff-syntax-highlight.integration.test.tsx new file mode 100644 index 00000000..a5ee47de --- /dev/null +++ b/webui/src/tests/diff-syntax-highlight.integration.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DiffSyntaxHighlight } from "@/components/thread/activity/DiffSyntaxHighlight"; +import { ThemeProvider } from "@/hooks/useTheme"; + +describe("DiffSyntaxHighlight with Prism", () => { + it("loads the TSX grammar and renders styled syntax tokens", async () => { + render( + + ready;', + }, + ]} + /> + , + ); + + const highlighted = await screen.findByTestId("syntax-highlighted-diff-hunk"); + await waitFor( + () => { + expect(highlighted.querySelectorAll('td:last-child span[style*="color"]')).not.toHaveLength( + 0, + ); + }, + { timeout: 10_000 }, + ); + + expect(highlighted).toHaveAttribute("data-language", "tsx"); + expect(highlighted.querySelectorAll("tbody tr")).toHaveLength(4); + }); +}); diff --git a/webui/src/tests/diff-syntax-highlight.test.tsx b/webui/src/tests/diff-syntax-highlight.test.tsx new file mode 100644 index 00000000..84a52bf6 --- /dev/null +++ b/webui/src/tests/diff-syntax-highlight.test.tsx @@ -0,0 +1,92 @@ +import { act, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DiffSyntaxHighlight } from "@/components/thread/activity/DiffSyntaxHighlight"; +import { ThemeProvider } from "@/hooks/useTheme"; + +vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => { + const MockSyntaxHighlighter = ({ + children, + language, + renderer, + ...props + }: { + children: string; + language: string; + renderer: (args: { + rows: Array<{ + type: "element"; + tagName: "span"; + properties: { className: string[] }; + children: Array<{ type: "text"; value: string }>; + }>; + stylesheet: Record; + useInlineStyles: boolean; + }) => ReactNode; + [key: string]: unknown; + }) => ( +
+ {renderer({ + rows: children.split("\n").map((line) => ({ + type: "element" as const, + tagName: "span" as const, + properties: { className: ["token", "keyword"] }, + children: [{ type: "text" as const, value: `${line}\n` }], + })), + stylesheet: {}, + useInlineStyles: true, + })} +
+ ); + return { default: MockSyntaxHighlighter }; +}); + +vi.mock("react-syntax-highlighter/dist/esm/create-element", () => ({ + default: ({ node }: { node: { children?: Array<{ value?: string }> } }) => ( + {node.children?.[0]?.value} + ), +})); + +vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({ + default: { dark: { color: "#fff" } }, +})); + +vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({ + default: { light: { color: "#111" } }, +})); + +describe("DiffSyntaxHighlight", () => { + it("highlights a complete hunk while preserving diff rows and line numbers", async () => { + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const highlighted = await screen.findByTestId("syntax-highlighted-diff-hunk"); + expect(highlighted).toHaveAttribute("data-language", "typescript"); + expect(screen.getAllByTestId("syntax-token")).toHaveLength(3); + expect(screen.getByText("return oldValue;", { exact: false }).closest("tr")).toHaveClass( + "bg-rose-500/[0.09]", + ); + expect(screen.getByText("return newValue;", { exact: false }).closest("tr")).toHaveClass( + "bg-emerald-500/[0.09]", + ); + expect(screen.getAllByText("5")).toHaveLength(2); + expect(screen.getAllByTestId("syntax-token").some((node) => node.textContent?.endsWith("\n"))).toBe(false); + }); +}); diff --git a/webui/src/tests/file-preview-panel.test.tsx b/webui/src/tests/file-preview-panel.test.tsx index dcb6fcf1..18561eb1 100644 --- a/webui/src/tests/file-preview-panel.test.tsx +++ b/webui/src/tests/file-preview-panel.test.tsx @@ -6,7 +6,23 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { fetchFilePreview } from "@/lib/api"; vi.mock("@/components/CodeBlock", () => ({ - CodeBlock: ({ code }: { code: string }) =>
{code}
, + CodeBlock: ({ + code, + language, + highlight, + }: { + code: string; + language?: string; + highlight?: boolean; + }) => ( +
+      {code}
+    
+ ), })); vi.mock("@/lib/api", async (importOriginal) => { @@ -42,7 +58,10 @@ describe("FilePreviewPanel", () => { />, ); - expect(await screen.findByTestId("mock-code-block")).toHaveTextContent("print('ok')"); + const codeBlock = await screen.findByTestId("mock-code-block"); + expect(codeBlock).toHaveTextContent("print('ok')"); + expect(codeBlock).toHaveAttribute("data-language", "python"); + expect(codeBlock).toHaveAttribute("data-highlight", "true"); expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("..."); expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace"); expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");