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>
);
}