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 { useThemeValue } from "@/hooks/useTheme";
|
||||||
import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi";
|
import { hasAnsi, parseAnsiSegments, stripAnsi } from "@/lib/ansi";
|
||||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||||
|
import { normalizeCodeLanguage } from "@/lib/code-language";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
@@ -191,6 +192,7 @@ export function CodeBlock({
|
|||||||
const isDark = useThemeValue() === "dark";
|
const isDark = useThemeValue() === "dark";
|
||||||
const hasChrome = chrome === "default";
|
const hasChrome = chrome === "default";
|
||||||
const renderAnsi = shouldRenderAnsi(language, code);
|
const renderAnsi = shouldRenderAnsi(language, code);
|
||||||
|
const syntaxLanguage = normalizeCodeLanguage(language);
|
||||||
|
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
void copyTextToClipboard(renderAnsi ? stripAnsi(code) : code).then((ok) => {
|
void copyTextToClipboard(renderAnsi ? stripAnsi(code) : code).then((ok) => {
|
||||||
@@ -261,7 +263,7 @@ export function CodeBlock({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyHighlightedCode
|
<LazyHighlightedCode
|
||||||
language={language}
|
language={syntaxLanguage}
|
||||||
code={code}
|
code={code}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
chrome={chrome}
|
chrome={chrome}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function FilePreviewPanel({
|
|||||||
language={state.payload.language}
|
language={state.payload.language}
|
||||||
code={state.payload.content}
|
code={state.payload.content}
|
||||||
chrome="none"
|
chrome="none"
|
||||||
|
highlight
|
||||||
showLineNumbers
|
showLineNumbers
|
||||||
wrapLongLines={false}
|
wrapLongLines={false}
|
||||||
className="min-h-full"
|
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,
|
parseRenderableFileDiff,
|
||||||
type RenderableFileDiff,
|
type RenderableFileDiff,
|
||||||
type RenderableFileDiffHunk,
|
type RenderableFileDiffHunk,
|
||||||
type RenderableFileDiffLine,
|
|
||||||
} from "@/lib/file-diff";
|
} from "@/lib/file-diff";
|
||||||
|
import { codeLanguageFromPath } from "@/lib/code-language";
|
||||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||||
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
|
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ActivityStep } from "./ActivityStep";
|
import { ActivityStep } from "./ActivityStep";
|
||||||
import { DiffPair } from "./DiffPair";
|
import { DiffPair } from "./DiffPair";
|
||||||
|
import { DiffSyntaxHighlight } from "./DiffSyntaxHighlight";
|
||||||
|
|
||||||
const INITIAL_VISIBLE_DIFF_LINES = 160;
|
const INITIAL_VISIBLE_DIFF_LINES = 160;
|
||||||
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
|
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
|
||||||
@@ -266,6 +267,7 @@ function FileUnifiedDiff({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [expandedLines, setExpandedLines] = useState(false);
|
const [expandedLines, setExpandedLines] = useState(false);
|
||||||
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
|
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
|
||||||
|
const language = useMemo(() => codeLanguageFromPath(previewPath), [previewPath]);
|
||||||
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
|
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
|
||||||
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
|
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
|
||||||
const startsCollapsed = collapsed || shouldAutoCollapse;
|
const startsCollapsed = collapsed || shouldAutoCollapse;
|
||||||
@@ -312,16 +314,7 @@ function FileUnifiedDiff({
|
|||||||
>
|
>
|
||||||
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
|
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse font-mono text-[11px] leading-5">
|
<DiffSyntaxHighlight language={language} lines={hunk.lines} />
|
||||||
<tbody>
|
|
||||||
{hunk.lines.map((line, lineIndex) => (
|
|
||||||
<DiffLineRow
|
|
||||||
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
|
|
||||||
line={line}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -485,37 +478,3 @@ function DiffHunkGap({ lineCount }: { lineCount: number }) {
|
|||||||
</div>
|
</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();
|
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", () => {
|
it("renders ANSI output without mounting the syntax highlighter", () => {
|
||||||
render(
|
render(
|
||||||
<ThemeProvider theme="dark">
|
<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";
|
import { fetchFilePreview } from "@/lib/api";
|
||||||
|
|
||||||
vi.mock("@/components/CodeBlock", () => ({
|
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) => {
|
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("...");
|
||||||
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace");
|
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace");
|
||||||
expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");
|
expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");
|
||||||
|
|||||||
Reference in New Issue
Block a user