Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
"@stripe/stripe-js": "^4.2.0",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "3.14.21",
"axios": "1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cronstrue": "^2.48.0",
"date-fns": "^4.1.0",
"dayjs": "1.11.19",
"diff": "9.0.0",
"emoji-picker-react": "^4.8.0",
"emoji-regex": "^10.3.0",
"file-saver": "^2.0.5",
Expand Down Expand Up @@ -56,6 +58,7 @@
"react-router-dom": "^6.11.2",
"react-social-login-buttons": "^4.1.1",
"recharts": "^3.5.1",
"refractor": "5.0.0",
"remark-gfm": "^3.0.1",
"socket.io-client": "^4.7.2",
"sonner": "^2.0.7",
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ is convention.
`Spinner` sits only in buttons and running text. Errors on a screen are an
`InlineAlert` above the content, with `onRetry`.

## Code and data

- Code, JSON, diffs, Markdown, copy controls and code editors come from
`components/ui/code/` (`CodeBlock`, `JsonView`, `DiffView`, `Markdown`,
`CopyButton`, `CodeEditor`, `InlineCode`); its README lists every prop.
Do not build another highlighter, JSON tree, diff or Markdown renderer.
- Mono text (IDs, token counts) is `<InlineCode variant="plain">`; the font
comes only from the `font-mono` token. Syntax and diff colours are the
`code-*` and `diff-*` tokens.
- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call
`loader.config()` in v1: the loader is shared with v0.

## Folders and what each may import

Every folder may import from itself. Test files (`*.test.ts(x)`,
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/v1/components/ui/code/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { screen, waitFor, within } from "@testing-library/react";
import { renderV1 } from "@v1/test";
import { expect, it, vi } from "vitest";
import { CodeBlock } from "./CodeBlock";
import { InlineCode } from "./InlineCode";

it("renders each line, highlighted with the code tokens", () => {
const { container } = renderV1(
<CodeBlock code={'{"a": 1}\n\nnull'} language="json" />,
);
const lines = container.querySelectorAll("[data-slot=code-line]");
expect([...lines].map((l) => l.textContent)).toEqual([
'{"a": 1}',
"",
"null",
]);
expect(container.querySelector(".ui\\:text-code-key")?.textContent).toBe(
'"a"',
);
expect(container.querySelector(".ui\\:text-code-number")?.textContent).toBe(
"1",
);
// Never Prism's own class names, which v0's global Prism theme styles.
expect(container.innerHTML).not.toMatch(/\btoken\b|language-/);
});

it("names its scrollable region and makes it focusable", async () => {
const { user, rerender } = renderV1(
<CodeBlock code="x = 1" language="python" />,
);
const region = screen.getByRole("region", { name: "Python code" });
await user.tab();
await user.tab();
expect(document.activeElement).toBe(region);
rerender(<CodeBlock code="x" title="Request" />);
expect(screen.getByRole("region", { name: "Request" })).toBeTruthy();
rerender(<CodeBlock code="x" aria-label="Body" title={<b>Req</b>} />);
expect(screen.getByRole("region", { name: "Body" })).toBeTruthy();
});

it("shows line numbers only when asked, outside the copied text", () => {
const { container, rerender } = renderV1(<CodeBlock code={"a\nb"} />);
expect(container.querySelector("[aria-hidden].ui\\:select-none")).toBeNull();
rerender(<CodeBlock code={"a\nb"} showLineNumbers />);
const numbers = container.querySelectorAll(
"[data-slot=code-line] > [aria-hidden]",
);
expect([...numbers].map((n) => n.textContent)).toEqual(["1", "2"]);
});

it("copies the code, and drops the header when there is nothing for it", async () => {
const { user, container, rerender } = renderV1(
<CodeBlock code="print(1)" actions={<button type="button">Run</button>} />,
);
const writeText = vi.spyOn(navigator.clipboard, "writeText");
const header = container.querySelector("[data-slot=code-block-header]");
expect(
within(header as HTMLElement).getByRole("button", { name: "Run" }),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "Copy code" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("print(1)"));
rerender(<CodeBlock code="x" copyable={false} />);
expect(container.querySelector("[data-slot=code-block-header]")).toBeNull();
});

it("wraps long lines only when asked", () => {
const { container, rerender } = renderV1(<CodeBlock code="x" />);
const content = () =>
container.querySelector("[data-slot=code-line] > span:last-child");
expect(content()?.className).toContain("ui:whitespace-pre");
rerender(<CodeBlock code="x" wrap />);
expect(content()?.className).toContain("ui:whitespace-pre-wrap");
});

it("passes axe in every mode", async () => {
for (const theme of ["light", "dark", "unstract"] as const) {
const { container, unmount } = renderV1(
<CodeBlock code="a" title="T" showLineNumbers />,
{ theme },
);
await expect(container).toHaveNoA11yViolations();
unmount();
}
});

it("InlineCode sets mono text as a chip or plain", () => {
renderV1(
<p>
<InlineCode>a</InlineCode>
<InlineCode variant="plain">2</InlineCode>
</p>,
);
const [chip, plain] = document.querySelectorAll("code");
expect(chip.className).toContain("ui:font-mono");
expect(chip.className).toContain("ui:bg-muted");
expect(plain.className).toContain("ui:font-mono");
expect(plain.className).not.toContain("ui:bg-muted");
});
107 changes: 107 additions & 0 deletions frontend/src/v1/components/ui/code/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { cn } from "@v1/lib/cn";
import { type CodeLanguage, highlightLines } from "@v1/lib/code/highlight";
import { type ComponentProps, type JSX, type ReactNode, useMemo } from "react";
import { gutterWidth, LineRow, MONO, Tokens } from "./CodeLines";
import { CopyButton } from "./CopyButton";

/** Display names, used for the default accessible label ("Python code"). */
export const LANGUAGE_NAME: Readonly<Record<CodeLanguage, string>> = {
json: "JSON",
bash: "Shell",
python: "Python",
javascript: "JavaScript",
typescript: "TypeScript",
yaml: "YAML",
markdown: "Markdown",
text: "Plain text",
};

export type CodeBlockProps = Omit<ComponentProps<"div">, "children" | "title"> &
Readonly<{
code: string;
/** Highlighting grammar; `text` (the default) is not highlighted. */
language?: CodeLanguage;
/** Shown in the header, left. */
title?: ReactNode;
/** Extra controls in the header, before the copy button. */
actions?: ReactNode;
/** Shows a copy button in the header (default on). */
copyable?: boolean;
showLineNumbers?: boolean;
/** Wraps long lines instead of scrolling sideways. */
wrap?: boolean;
/** Names the scrollable code region; defaults to the title or the language. */
"aria-label"?: string;
}>;

/**
* Read-only, highlighted code. The block grows with its content; constrain it
* with a class (`className="ui:max-h-80"`) and the code area scrolls.
*/
export function CodeBlock({
code,
language = "text",
title,
actions,
copyable = true,
showLineNumbers = false,
wrap = false,
"aria-label": ariaLabel,
className,
...props
}: CodeBlockProps): JSX.Element {
const lines = useMemo(() => highlightLines(code, language), [code, language]);
const label =
ariaLabel ??
(typeof title === "string" ? title : `${LANGUAGE_NAME[language]} code`);
const header = title != null || actions != null || copyable;
return (
<div
data-slot="code-block"
data-language={language}
className={cn(
"ui:flex ui:min-h-0 ui:flex-col ui:overflow-hidden ui:rounded-lg ui:border ui:border-border ui:bg-muted ui:text-foreground",
className,
)}
{...props}
>
{header ? (
<div
data-slot="code-block-header"
className="ui:flex ui:items-center ui:gap-2 ui:border-b ui:border-border ui:py-1 ui:pr-1 ui:pl-3"
>
<div className="ui:min-w-0 ui:flex-1 ui:truncate ui:text-caption ui:font-medium ui:text-muted-foreground">
{title}
</div>
{actions}
{copyable ? <CopyButton text={code} label="Copy code" /> : null}
</div>
) : null}
<section
aria-label={label}
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region must be focusable (WCAG 2.1.1)
tabIndex={0} // NOSONAR: a scrollable region must be focusable (WCAG 2.1.1)
className="ui:min-h-0 ui:flex-1 ui:overflow-auto ui:focus-visible:focus-ring"
>
<pre
className={cn("ui:m-0 ui:p-3", MONO)}
style={{ "--code-gutter": gutterWidth(lines.length) }}
>
<code>
{lines.map((line, i) => (
<LineRow
// biome-ignore lint/suspicious/noArrayIndexKey: a line is identified by its position.
key={i}

Check warning on line 94 in frontend/src/v1/components/ui/code/CodeBlock.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a stable ID as key, because the index changes when the list changes.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaGrAKrFiS5hhdbsVHtD&open=AaGrAKrFiS5hhdbsVHtD&pullRequest=2447
number={i + 1}
showLineNumbers={showLineNumbers}
wrap={wrap}
>
<Tokens line={line} />
</LineRow>
))}
</code>
</pre>
</section>
</div>
);
}
30 changes: 30 additions & 0 deletions frontend/src/v1/components/ui/code/CodeEditor.chunk.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { screen } from "@testing-library/react";
import { renderV1 } from "@v1/test";
import { expect, it, vi } from "vitest";
import { CodeEditor } from "./CodeEditor";

// The lazy chunk itself fails to load (a stale hash after a deploy, a network
// error): the editor shows its own error instead of unmounting the page.
// Never the real loader, which would inject the CDN script.
vi.mock("@monaco-editor/loader", () => ({
default: { init: () => new Promise(() => undefined) },
}));
vi.mock("@monaco-editor/react", () => {
throw new Error("Failed to fetch dynamically imported module");
});

it("shows its own error when the editor chunk fails to load", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
renderV1(
<div>
<p>Rest of the page</p>
<CodeEditor aria-label="Broken" />
</div>,
);
expect((await screen.findByRole("alert")).textContent).toContain(
"The editor could not load",
);
expect(screen.getByText("Rest of the page")).toBeTruthy();
// One alert, though both the boundary and the loader watch see the failure.
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
Loading
Loading