feat(webui): highlight file previews and diffs

This commit is contained in:
chengyongru
2026-07-11 00:57:37 +08:00
committed by Xubin Ren
parent 9a1d1e64c7
commit 1ee4349a08
10 changed files with 502 additions and 48 deletions
+3 -1
View File
@@ -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({
}
>
<LazyHighlightedCode
language={language}
language={syntaxLanguage}
code={code}
isDark={isDark}
chrome={chrome}
@@ -238,6 +238,7 @@ export function FilePreviewPanel({
language={state.payload.language}
code={state.payload.content}
chrome="none"
highlight
showLineNumbers
wrapLongLines={false}
className="min-h-full"
@@ -0,0 +1,186 @@
import { Suspense, lazy, type ReactNode } from "react";
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
import { useThemeValue } from "@/hooks/useTheme";
import type { RenderableFileDiffLine } from "@/lib/file-diff";
import { cn } from "@/lib/utils";
interface DiffSyntaxHighlightProps {
language: string;
lines: RenderableFileDiffLine[];
}
interface LoadedDiffSyntaxHighlightProps extends DiffSyntaxHighlightProps {
isDark: boolean;
}
type RendererArgs = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[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 (
<SyntaxHighlighter
language={language}
style={theme}
PreTag="div"
CodeTag="div"
customStyle={{
background: "transparent",
margin: 0,
padding: 0,
overflow: "visible",
fontFamily: CODE_FONT_STACK,
fontSize: "11px",
lineHeight: "1.25rem",
}}
codeTagProps={{
style: {
background: "transparent",
fontFamily: CODE_FONT_STACK,
},
}}
data-language={language}
data-testid="syntax-highlighted-diff-hunk"
renderer={({ rows, stylesheet, useInlineStyles }) => (
<DiffLineTable
lines={lines}
renderCode={(line, index) => {
const node = rows[index];
if (!node) return line.content || " ";
return createSyntaxElement({
node: trimTrailingLineBreak(node),
stylesheet,
useInlineStyles,
key: `diff-code-${index}`,
});
}}
/>
)}
>
{code}
</SyntaxHighlighter>
);
},
};
});
export function DiffSyntaxHighlight({ language, lines }: DiffSyntaxHighlightProps) {
const isDark = useThemeValue() === "dark";
return (
<Suspense fallback={<PlainDiffLines lines={lines} />}>
<LazyDiffSyntaxHighlight language={language} lines={lines} isDark={isDark} />
</Suspense>
);
}
function PlainDiffLines({ lines }: { lines: RenderableFileDiffLine[] }) {
return (
<div data-testid="plain-diff-hunk">
<DiffLineTable lines={lines} renderCode={(line) => line.content || " "} />
</div>
);
}
function DiffLineTable({
lines,
renderCode,
}: {
lines: RenderableFileDiffLine[];
renderCode: (line: RenderableFileDiffLine, index: number) => ReactNode;
}) {
return (
<table className="w-full border-collapse font-mono text-[11px] leading-5">
<tbody>
{lines.map((line, index) => (
<DiffLineRow
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${index}`}
line={line}
>
{renderCode(line, index)}
</DiffLineRow>
))}
</tbody>
</table>
);
}
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 (
<tr
className={cn(
"border-0",
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
)}
>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.old_lineno ?? ""}
</td>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.new_lineno ?? ""}
</td>
<td
className={cn(
"w-5 select-none px-1 text-center",
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
kind === "context" && "text-muted-foreground/45",
)}
>
{marker}
</td>
<td className="min-w-[16rem] px-1.5 text-foreground/86">
<span className="whitespace-pre">{children}</span>
</td>
</tr>
);
}
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 };
}
@@ -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 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-5">
<tbody>
{hunk.lines.map((line, lineIndex) => (
<DiffLineRow
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
line={line}
/>
))}
</tbody>
</table>
<DiffSyntaxHighlight language={language} lines={hunk.lines} />
</div>
</div>
))}
@@ -485,37 +478,3 @@ function DiffHunkGap({ lineCount }: { lineCount: number }) {
</div>
);
}
function DiffLineRow({ line }: { line: RenderableFileDiffLine }) {
const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context";
const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " ";
return (
<tr
className={cn(
"border-0",
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
)}
>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.old_lineno ?? ""}
</td>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.new_lineno ?? ""}
</td>
<td
className={cn(
"w-5 select-none px-1 text-center",
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
kind === "context" && "text-muted-foreground/45",
)}
>
{marker}
</td>
<td className="min-w-[16rem] px-1.5 text-foreground/86">
<span className="whitespace-pre">{line.content || " "}</span>
</td>
</tr>
);
}
+100
View File
@@ -0,0 +1,100 @@
const LANGUAGE_ALIASES: Record<string, string> = {
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<string, string> = {
"cmakelists.txt": "cmake",
dockerfile: "docker",
gemfile: "ruby",
makefile: "makefile",
procfile: "ruby",
};
const EXTENSION_LANGUAGES: Record<string, string> = {
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);
}
+15
View File
@@ -88,6 +88,21 @@ describe("CodeBlock", () => {
expect(screen.getByText("const value = 1;")).toBeInTheDocument();
});
it("normalizes file language aliases before loading Prism", async () => {
render(
<ThemeProvider theme="light">
<CodeBlock language="html" code="<main />" />
</ThemeProvider>,
);
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(
<ThemeProvider theme="dark">
+24
View File
@@ -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");
});
});
@@ -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(
<ThemeProvider theme="light">
<DiffSyntaxHighlight
language="tsx"
lines={[
{
kind: "context",
old_lineno: 1,
new_lineno: 1,
content: 'import { useMemo } from "react";',
},
{
kind: "delete",
old_lineno: 2,
new_lineno: null,
content: "export function StatusBadge() {",
},
{
kind: "add",
old_lineno: null,
new_lineno: 2,
content: "export function StatusBadge(): JSX.Element {",
},
{
kind: "context",
old_lineno: 3,
new_lineno: 3,
content: ' return <span className="badge">ready</span>;',
},
]}
/>
</ThemeProvider>,
);
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);
});
});
@@ -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<string, React.CSSProperties>;
useInlineStyles: boolean;
}) => ReactNode;
[key: string]: unknown;
}) => (
<div data-testid={String(props["data-testid"])} data-language={language}>
{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,
})}
</div>
);
return { default: MockSyntaxHighlighter };
});
vi.mock("react-syntax-highlighter/dist/esm/create-element", () => ({
default: ({ node }: { node: { children?: Array<{ value?: string }> } }) => (
<span data-testid="syntax-token">{node.children?.[0]?.value}</span>
),
}));
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(
<ThemeProvider theme="light">
<DiffSyntaxHighlight
language="typescript"
lines={[
{ kind: "context", old_lineno: 4, new_lineno: 4, content: "export function run() {" },
{ kind: "delete", old_lineno: 5, new_lineno: null, content: " return oldValue;" },
{ kind: "add", old_lineno: null, new_lineno: 5, content: " return newValue;" },
]}
/>
</ThemeProvider>,
);
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);
});
});
+21 -2
View File
@@ -6,7 +6,23 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { fetchFilePreview } from "@/lib/api";
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ code }: { code: string }) => <pre data-testid="mock-code-block">{code}</pre>,
CodeBlock: ({
code,
language,
highlight,
}: {
code: string;
language?: string;
highlight?: boolean;
}) => (
<pre
data-testid="mock-code-block"
data-language={language}
data-highlight={String(highlight)}
>
{code}
</pre>
),
}));
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");