feat(webui): highlight file previews and diffs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user