Merge pull request #2170 from modelcontextprotocol/v2/feat/2151-ace-json-editor
feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
This commit is contained in:
commit
6bc3251962
52 changed files with 4038 additions and 486 deletions
|
|
@ -49,8 +49,8 @@ Components live under `src/components/` in four layers, smallest to largest:
|
|||
|
||||
| Layer | Count | What it is |
|
||||
| ----------- | ----- | ------------------------------------------------------------------------------ |
|
||||
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `elements/` | ~48 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~64 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
|
||||
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
|
||||
|
||||
|
|
@ -283,17 +283,34 @@ Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Tex
|
|||
|
||||
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
|
||||
|
||||
## Code editing (`JsonObjectInput`)
|
||||
## JSON editing and display (`JsonEditor`)
|
||||
|
||||
Payloads whose _values_ may be arbitrary JSON — `_meta` is the case that forced it ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)) — are edited with **Ace** (`react-ace` + `ace-builds`, declared in this client because they render React) rather than the key/value rows used for headers and env, which cannot express an object value. Ace brings code folding, brace auto-closing, and per-line error annotation from its JSON worker.
|
||||
Every surface in this client where JSON is **typed**, plus `ContentViewer`'s read-only JSON branch, renders one element: **`elements/JsonEditor`**, an **Ace** editor (`react-ace` + `ace-builds`, declared in this client because they render React). Ace brings code folding, line numbers, brace auto-closing, and per-line error annotation from its JSON worker — the last three are why hand-writing a nested payload in a bare textarea was the actual pain ([#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)).
|
||||
|
||||
Three integration details are load-bearing:
|
||||
`JsonEditor` is deliberately **text in, text out**: it never parses. The editing contracts above it disagree about what an unparseable draft means, and neither can be expressed by a component that decides for them:
|
||||
|
||||
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations.
|
||||
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden.
|
||||
| Consumer | While the draft is invalid |
|
||||
| --- | --- |
|
||||
| `elements/JsonObjectInput` (Server Settings → Request Metadata) | Parent is **not** told; the last valid object stands. There is no Save button to gate — `onChange` writes straight through — so emitting `{}` would discard configured metadata on a stray keystroke ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). |
|
||||
| `SchemaJsonField` (the object/array/union fallback in `groups/SchemaForm`) | Parent is told `undefined`, **and** invalidity is reported up through `onValidityChange` so Execute / Open App / Submit are disabled ([#2020](https://github.com/modelcontextprotocol/inspector/issues/2020)). |
|
||||
| `SchemaForm`'s **Edit as JSON** switch | Same as above, for the whole arguments object — the v1 escape hatch, restored. Seeded from what the form holds, so a root-union switch's pruning ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)) is not undone by a round trip. |
|
||||
| `groups/EditReplayModal` (Protocol → Edit and replay) | Send is disabled. Unlike the metadata editor this modal *has* a commit gesture to gate. |
|
||||
| `groups/ImportServerJsonPanel`, `groups/ExperimentalFeaturesPanel` | The panel owns the text and validates it itself. |
|
||||
| `elements/ContentViewer`'s JSON branch | Read-only — see below. |
|
||||
|
||||
**Read-only mode is what `ContentViewer` renders JSON as**, and through it every payload that reaches the app that way: Protocol and Network entries, tool results, structured output, resource previews, server cards. Highlighting is *not* what that buys — JSON already highlighted, via the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is **folding**, line numbers and a gutter on a large payload. The Prism `json` grammar was dropped in the same change rather than kept beside it: two highlighters for one language drift, and nothing else asks for `json`. Two cases stay on the plain renderer — a `wrap={false}` caller (the server card's fixed-height, single-line box) and untyped text that only *looks* like JSON but does not parse, which in an editor would frame a server's prose as a malformed document.
|
||||
|
||||
Two read-only JSON displays deliberately do **not** go through `ContentViewer`, and so are not on this editor — worth knowing before assuming it is the route for all of them. `ExperimentalFeaturesPanel` renders its JSON-RPC **response** in a read-only `Textarea` (its *request* box is on the editor), and `ConnectionInfoContent/OAuthTokenField` renders a decoded JWT in a `Code` block beside the raw token, where the decoded/raw toggle and the copy affordance belong to the field rather than to a viewer. Both are candidates for the same treatment; neither is in [#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)'s scope.
|
||||
|
||||
Five integration details are load-bearing:
|
||||
|
||||
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations. It is registered at **module scope** — the registration is global to Ace and idempotent, so it must not move into a per-mount effect.
|
||||
- **Ace fires two change events for a replace** (a remove, then an insert), so a select-all-and-retype passes through a momentarily *empty* document. `JsonEditor` coalesces the pair in a microtask and reports only the settled text; acting on the first event reports the empty document as the user's answer.
|
||||
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden. The read-only caret is hidden the same way (`.json-editor-readonly`), since `readOnly` has no option that removes it.
|
||||
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
|
||||
- **`ariaLabel` is required.** Ace names its hidden textarea "Cursor at row N", which is a position readout rather than a name — and it only recomputes that label when the cursor moves, so the option has to be applied *and* recomputed in `onLoad` to reach the DOM before the user clicks in.
|
||||
|
||||
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts`; real keyboard behaviour lives in the Storybook play functions, which run in Chromium.
|
||||
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts` (`setAceText` / `getAceText`, plus the `*ByLabel` variants for a screen holding more than one editor); real keyboard behaviour lives in the Storybook play functions, which run in Chromium. The same split applies to *reading* a payload back: Ace virtualizes its lines, so a read-only editor's text is not fully in the DOM and assertions go through the editor rather than `getByText`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
|
|||
|
|
@ -615,3 +615,10 @@ body.resizing-col {
|
|||
.ace_editor.ace-github-dark .ace_gutter {
|
||||
color: var(--inspector-code-gutter-text);
|
||||
}
|
||||
|
||||
/* A read-only Ace editor still paints a caret, which reads as an editable field
|
||||
whose keystrokes are being swallowed. `readOnly` has no option that removes
|
||||
it, so — like the gutter above — it can only be reached by selector. */
|
||||
.ace_editor.json-editor-readonly .ace_cursor {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,11 @@ const meta: Meta<typeof CodeHighlight> = {
|
|||
export default meta;
|
||||
type Story = StoryObj<typeof CodeHighlight>;
|
||||
|
||||
export const Json: Story = {
|
||||
// No JSON story: JSON is rendered by `JsonEditor` (read-only), not here (#2151).
|
||||
export const Yaml: Story = {
|
||||
args: {
|
||||
language: "json",
|
||||
code: JSON.stringify(
|
||||
{ name: "my-app", version: "1.0.0", tags: ["a", "b"] },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
language: "yaml",
|
||||
code: "name: my-app\nversion: 1.0.0\ntags:\n - a\n - b",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({
|
|||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({
|
||||
default: {},
|
||||
}));
|
||||
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({
|
||||
default: { __grammar: "json" },
|
||||
}));
|
||||
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({
|
||||
default: { __grammar: "markup" },
|
||||
}));
|
||||
|
|
@ -53,15 +50,15 @@ beforeEach(() => {
|
|||
describe("CodeHighlight", () => {
|
||||
it("renders plain code initially, then upgrades to the highlighter", async () => {
|
||||
const CodeHighlight = await loadComponent();
|
||||
renderWithMantine(<CodeHighlight language="json" code='{"a":1}' />);
|
||||
renderWithMantine(<CodeHighlight language="yaml" code="a: 1" />);
|
||||
// Plain Mantine Code before the grammar resolves.
|
||||
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
|
||||
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
|
||||
expect(screen.getByText("a: 1")).toBeInTheDocument();
|
||||
// After the lazy grammar loads, the prism runtime takes over.
|
||||
const prism = await screen.findByTestId("prism");
|
||||
expect(prism).toHaveAttribute("data-language", "json");
|
||||
expect(registerLanguage).toHaveBeenCalledWith("json", {
|
||||
__grammar: "json",
|
||||
expect(prism).toHaveAttribute("data-language", "yaml");
|
||||
expect(registerLanguage).toHaveBeenCalledWith("yaml", {
|
||||
__grammar: "yaml",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -113,14 +110,14 @@ describe("CodeHighlight", () => {
|
|||
it("reuses an already-registered grammar without re-importing", async () => {
|
||||
const CodeHighlight = await loadComponent();
|
||||
const { unmount } = renderWithMantine(
|
||||
<CodeHighlight language="json" code='{"a":1}' />,
|
||||
<CodeHighlight language="yaml" code="a: 1" />,
|
||||
);
|
||||
await screen.findByTestId("prism");
|
||||
expect(registerLanguage).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
// A second mount finds json already registered: ready synchronously, no
|
||||
// A second mount finds yaml already registered: ready synchronously, no
|
||||
// second registerLanguage call.
|
||||
renderWithMantine(<CodeHighlight language="json" code='{"b":2}' />);
|
||||
renderWithMantine(<CodeHighlight language="yaml" code="b: 2" />);
|
||||
expect(await screen.findByTestId("prism")).toBeInTheDocument();
|
||||
expect(registerLanguage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -128,7 +125,7 @@ describe("CodeHighlight", () => {
|
|||
it("loads a second language reusing the already-loaded runtime", async () => {
|
||||
const CodeHighlight = await loadComponent();
|
||||
const { unmount } = renderWithMantine(
|
||||
<CodeHighlight language="json" code='{"a":1}' />,
|
||||
<CodeHighlight language="yaml" code="a: 1" />,
|
||||
);
|
||||
await screen.findByTestId("prism");
|
||||
unmount();
|
||||
|
|
@ -146,7 +143,7 @@ describe("CodeHighlight", () => {
|
|||
const CodeHighlight = await loadComponent();
|
||||
renderWithMantine(
|
||||
<>
|
||||
<CodeHighlight language="json" code='{"a":1}' />
|
||||
<CodeHighlight language="yaml" code="a: 1" />
|
||||
<CodeHighlight language="css" code=".a{}" />
|
||||
</>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ type PrismRuntime = ComponentType<{
|
|||
* below if the canonical Prism name differs) as the type matrix grows.
|
||||
*/
|
||||
const LANGUAGE_LOADERS: Record<string, () => Promise<{ default: Grammar }>> = {
|
||||
json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"),
|
||||
// No `json` entry, deliberately (#2151). JSON is rendered by the Ace-backed
|
||||
// `JsonEditor` in read-only mode, which folds and numbers a large payload —
|
||||
// things a Prism block cannot do. Registering the grammar here as well would
|
||||
// leave two highlighters for one language, differing in appearance and
|
||||
// drifting apart; there is no caller left that asks for it.
|
||||
markup: () =>
|
||||
import("react-syntax-highlighter/dist/esm/languages/prism/markup"),
|
||||
css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
TextResourceContents,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { getAceText } from "../../../test/aceEditor";
|
||||
import { ContentViewer } from "./ContentViewer";
|
||||
|
||||
// Stub the lazy highlighter so JSON/XML/CSS branches are assertable
|
||||
|
|
@ -28,16 +29,19 @@ describe("ContentViewer", () => {
|
|||
expect(screen.getByText("hello world")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pretty-prints JSON text", () => {
|
||||
it("pretty-prints JSON text into the JSON editor", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
// Text that merely *starts* like JSON stays plain: presenting it in a JSON
|
||||
// gutter would frame the server's prose as a malformed document (#2151).
|
||||
it("falls back to raw text when JSON is malformed", () => {
|
||||
const block: ContentBlock = { type: "text", text: "{ broken" };
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(screen.getByText("{ broken")).toBeInTheDocument();
|
||||
expect(document.querySelector(".ace_editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a copy overlay when copyable", () => {
|
||||
|
|
@ -206,14 +210,90 @@ describe("ContentViewer", () => {
|
|||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights a JSON text block when mimeType is application/json", () => {
|
||||
it("renders a declared JSON text block in the JSON editor", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="application/json" />,
|
||||
);
|
||||
const probe = screen.getByTestId("code-highlight");
|
||||
expect(probe).toHaveAttribute("data-language", "json");
|
||||
expect(probe.textContent).toContain('"a": 1');
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
// The Prism path is no longer taken for JSON — one highlighter per
|
||||
// language, and Ace is the one that folds (#2151).
|
||||
expect(screen.queryByTestId("code-highlight")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The server card's fixed-height, single-line box (`wrap={false}`) must not
|
||||
// become a multi-line editor, whatever MIME it declares.
|
||||
it("keeps a non-wrapping JSON block on the plain renderer", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="application/json" wrap={false} />,
|
||||
);
|
||||
expect(document.querySelector(".ace_editor")).toBeNull();
|
||||
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// An expanded Protocol entry holds two of these and a list holds many pairs,
|
||||
// so an unnamed editor leaves a screen reader tabbing through textboxes that
|
||||
// all announce the same thing.
|
||||
it("names the JSON editor from jsonLabel", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
block={block}
|
||||
mimeType="application/json"
|
||||
jsonLabel="tools/call response JSON"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText(/tools\/call response JSON/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a generic name when no label is given", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(screen.getByLabelText(/JSON content/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the copy overlay above the JSON editor", () => {
|
||||
const block: ContentBlock = { type: "text", text: '{"a":1}' };
|
||||
renderWithMantine(
|
||||
<ContentViewer block={block} mimeType="application/json" copyable />,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The `resource` block branch used to render every embedded resource in a
|
||||
// plain code block, so a prompt message carrying JSON did not inherit this
|
||||
// migration even though the text-block branch did.
|
||||
it("renders an embedded JSON resource in the JSON editor", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///a.json",
|
||||
mimeType: "application/json",
|
||||
text: '{"a":1}',
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
// Narrowed to JSON on purpose: routing every declared type through would put
|
||||
// an embedded `text/html` resource into the sandboxed frame, which is a
|
||||
// change to what a tool result is.
|
||||
it("leaves a non-JSON embedded resource on the plain renderer", () => {
|
||||
const block: ContentBlock = {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///a.html",
|
||||
mimeType: "text/html",
|
||||
text: "<p>hi</p>",
|
||||
},
|
||||
};
|
||||
renderWithMantine(<ContentViewer block={block} />);
|
||||
expect(screen.getByText("<p>hi</p>")).toBeInTheDocument();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when neither block nor contents is provided", () => {
|
||||
|
|
@ -274,15 +354,13 @@ describe("ContentViewer (resource contents)", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights JSON contents", () => {
|
||||
it("renders JSON contents in the JSON editor", () => {
|
||||
renderWithMantine(
|
||||
<ContentViewer
|
||||
contents={text({ text: '{"a":1}', mimeType: "application/json" })}
|
||||
/>,
|
||||
);
|
||||
const probe = screen.getByTestId("code-highlight");
|
||||
expect(probe).toHaveAttribute("data-language", "json");
|
||||
expect(probe.textContent).toContain('"a": 1');
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
it("indents and highlights XML contents", () => {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import type { Components } from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { CodeHighlight } from "../CodeHighlight/CodeHighlight";
|
||||
import { CopyButton } from "../CopyButton/CopyButton";
|
||||
import { JsonEditor } from "../JsonEditor/JsonEditor";
|
||||
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
|
||||
import {
|
||||
formatJson,
|
||||
formatXml,
|
||||
formatJsonDocument,
|
||||
getMimeKind,
|
||||
isSafeHref,
|
||||
isTextualKind,
|
||||
|
|
@ -58,8 +60,27 @@ export interface ContentViewerProps {
|
|||
* one line — don't pass it for multi-line content.
|
||||
*/
|
||||
wrap?: boolean;
|
||||
/**
|
||||
* Accessible name for the read-only JSON editor a JSON payload renders in.
|
||||
*
|
||||
* Say what the payload *is* whenever a screen can hold more than one: an
|
||||
* expanded Protocol entry carries both its parameters and its response, and
|
||||
* a list of them carries many such pairs — so the default leaves a screen
|
||||
* reader tabbing through textboxes that all announce the same thing, with
|
||||
* only the visible section headings to tell them apart.
|
||||
*
|
||||
* Reaches the JSON branch only; every other renderer here is not a control
|
||||
* and takes no name.
|
||||
*/
|
||||
jsonLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows a read-only JSON payload renders before the editor scrolls internally
|
||||
* instead of growing. See the note at the `JsonEditor` that uses it.
|
||||
*/
|
||||
const JSON_DISPLAY_MAX_LINES = 200;
|
||||
|
||||
function buildDataUri(mimeType: string, data: string): string {
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
|
|
@ -161,15 +182,95 @@ function HighlightedContent({
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only JSON payload, rendered in the same Ace editor the JSON *input*
|
||||
* surfaces use (#2151).
|
||||
*
|
||||
* Highlighting is not what this buys — JSON already highlighted here, through
|
||||
* the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is
|
||||
* **folding**, line numbers and a gutter, which is the difference between
|
||||
* reading a large `tools/call` result and scrolling past it. Prism's `json`
|
||||
* grammar was dropped in the same change rather than kept beside this: two
|
||||
* highlighters for one language drift, and nothing else asks for `json`.
|
||||
*
|
||||
* The copy affordance is unchanged — `CopyableWrapper` overlays it above
|
||||
* whichever renderer was picked, so `copyable` keeps working across the swap.
|
||||
* It copies the *original* text, not the pretty-printed form, matching every
|
||||
* other branch here.
|
||||
*/
|
||||
function JsonContent({
|
||||
text,
|
||||
formatted,
|
||||
copyable,
|
||||
label,
|
||||
}: {
|
||||
/** The original payload — what the copy button copies. */
|
||||
text: string;
|
||||
/** Its pretty-printed form, already parsed once by whoever decided to use it. */
|
||||
formatted: string;
|
||||
copyable: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<CopyableWrapper copyable={copyable} copyValue={text}>
|
||||
<JsonEditor
|
||||
ariaLabel={label}
|
||||
value={formatted}
|
||||
readOnly
|
||||
// Sized to the payload, and capped.
|
||||
//
|
||||
// `minLines={1}` so a two-line result does not open six rows of blank
|
||||
// editor. The cap is the more interesting number, and it is a trade:
|
||||
//
|
||||
// - Below it the editor grows to fit, so the *host's* scroll container
|
||||
// is the only one — every consumer already provides the one it wants
|
||||
// (the structured-output section caps at `mah` and scrolls; a result
|
||||
// panel scrolls whole), and nesting a second scrollbar inside it is
|
||||
// worse to use.
|
||||
// - Above it Ace scrolls internally and renders only the rows its own
|
||||
// viewport covers. That is the point of having a cap at all: the
|
||||
// Protocol and Network lists are not virtualized and keep every
|
||||
// entry's payload mounted inside its `Collapse`, so an uncapped
|
||||
// editor would put a whole 10k-line response in the DOM per entry.
|
||||
//
|
||||
// 200 is chosen so essentially no real payload nests a scrollbar while
|
||||
// the pathological one stays bounded.
|
||||
minLines={1}
|
||||
maxLines={JSON_DISPLAY_MAX_LINES}
|
||||
/>
|
||||
</CopyableWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function PlainTextContent({
|
||||
text,
|
||||
copyable,
|
||||
wrap,
|
||||
jsonLabel,
|
||||
}: {
|
||||
text: string;
|
||||
copyable: boolean;
|
||||
wrap: boolean;
|
||||
jsonLabel: string;
|
||||
}) {
|
||||
// Untyped text that really parses as JSON gets the JSON renderer too — the
|
||||
// heuristic is how a server that sent no MIME type still reads well. Not when
|
||||
// `wrap` is false, though: that caller wants one clipped line at a fixed
|
||||
// height, which a multi-line editor is not.
|
||||
//
|
||||
// The formatted text comes back from the same call that decided, so the
|
||||
// payload is parsed once rather than once to test and again to render.
|
||||
const asJson = wrap ? formatJsonDocument(text) : null;
|
||||
if (asJson !== null) {
|
||||
return (
|
||||
<JsonContent
|
||||
text={text}
|
||||
formatted={asJson}
|
||||
copyable={copyable}
|
||||
label={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const displayText = looksLikeJson(text) ? formatJson(text) : text;
|
||||
return (
|
||||
<CopyableWrapper copyable={copyable} copyValue={text}>
|
||||
|
|
@ -199,23 +300,40 @@ function TextualContent({
|
|||
mimeType,
|
||||
copyable,
|
||||
wrap,
|
||||
jsonLabel,
|
||||
}: {
|
||||
text: string;
|
||||
mimeType: string | undefined;
|
||||
copyable: boolean;
|
||||
wrap: boolean;
|
||||
jsonLabel: string;
|
||||
}) {
|
||||
const kind = mimeType ? getMimeKind(mimeType) : "text";
|
||||
switch (kind) {
|
||||
case "markdown":
|
||||
return <MarkdownContent text={text} copyable={copyable} />;
|
||||
case "json":
|
||||
return (
|
||||
<HighlightedContent
|
||||
code={formatJson(text)}
|
||||
language="json"
|
||||
copyValue={text}
|
||||
// `wrap={false}` is a fixed-height, single-line, ellipsis-clipped box
|
||||
// (the server card's command/URL). Pretty-printed JSON collapsed onto one
|
||||
// line is exactly what that prop is documented not to be used for, so
|
||||
// that caller keeps the plain renderer.
|
||||
return wrap ? (
|
||||
<JsonContent
|
||||
text={text}
|
||||
// A *declared* `application/json` renders as JSON even when it does
|
||||
// not parse — the server said what it sent — so this cannot go
|
||||
// through `formatJsonDocument`, which reports unparseable text as
|
||||
// not-JSON. `formatJson` returns the original on failure.
|
||||
formatted={formatJson(text)}
|
||||
copyable={copyable}
|
||||
label={jsonLabel}
|
||||
/>
|
||||
) : (
|
||||
<PlainTextContent
|
||||
text={text}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
case "xml":
|
||||
|
|
@ -249,7 +367,14 @@ function TextualContent({
|
|||
</Stack>
|
||||
);
|
||||
default:
|
||||
return <PlainTextContent text={text} copyable={copyable} wrap={wrap} />;
|
||||
return (
|
||||
<PlainTextContent
|
||||
text={text}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,11 +402,13 @@ function ResourceContent({
|
|||
mimeType,
|
||||
copyable,
|
||||
wrap,
|
||||
jsonLabel,
|
||||
}: {
|
||||
contents: TextResourceContents | BlobResourceContents;
|
||||
mimeType: string;
|
||||
copyable: boolean;
|
||||
wrap: boolean;
|
||||
jsonLabel: string;
|
||||
}) {
|
||||
if ("text" in contents) {
|
||||
return (
|
||||
|
|
@ -290,6 +417,7 @@ function ResourceContent({
|
|||
mimeType={mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -318,6 +446,7 @@ function ResourceContent({
|
|||
mimeType={mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -330,11 +459,13 @@ function BlockContent({
|
|||
mimeType,
|
||||
copyable,
|
||||
wrap,
|
||||
jsonLabel,
|
||||
}: {
|
||||
block: ContentBlock;
|
||||
mimeType: string | undefined;
|
||||
copyable: boolean;
|
||||
wrap: boolean;
|
||||
jsonLabel: string;
|
||||
}) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
|
|
@ -344,24 +475,47 @@ function BlockContent({
|
|||
mimeType={mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return <ImageContent data={block.data} mimeType={block.mimeType} />;
|
||||
case "audio":
|
||||
return <AudioContent data={block.data} mimeType={block.mimeType} />;
|
||||
case "resource":
|
||||
case "resource": {
|
||||
// An embedded *text* resource that declares JSON goes through the same
|
||||
// dispatch a text block does, so this branch inherits the JSON renderer
|
||||
// rather than staying on a plain code block (#2151).
|
||||
//
|
||||
// Narrowed to JSON on purpose, the same way the Network tab's bodies are:
|
||||
// routing every declared type through would also put an embedded
|
||||
// `text/html` resource into the sandboxed frame and a `text/csv` one into
|
||||
// a table. The Resources screen already renders resource contents that
|
||||
// way, so doing it here too is defensible — but it is a change to what a
|
||||
// tool result *is*, and belongs to whoever decides that rather than to
|
||||
// this swap.
|
||||
const embedded = "text" in block.resource ? block.resource : null;
|
||||
if (embedded && getMimeKind(embedded.mimeType ?? "") === "json") {
|
||||
return (
|
||||
<TextualContent
|
||||
text={embedded.text}
|
||||
mimeType={embedded.mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ContentWrapper>
|
||||
<CodeBlock>
|
||||
{"text" in block.resource
|
||||
? block.resource.text
|
||||
: `[blob: ${block.resource.uri}]`}
|
||||
{embedded ? embedded.text : `[blob: ${block.resource.uri}]`}
|
||||
</CodeBlock>
|
||||
</ContentWrapper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
case "resource_link":
|
||||
// Static metadata only. The interactive, read-on-demand presentation
|
||||
// lives in the `groups/ResourceLink` group, rendered by content-block
|
||||
|
|
@ -384,6 +538,7 @@ export function ContentViewer({
|
|||
copyable = false,
|
||||
mimeType,
|
||||
wrap = true,
|
||||
jsonLabel = "JSON content",
|
||||
}: ContentViewerProps) {
|
||||
if (contents) {
|
||||
const effective =
|
||||
|
|
@ -394,6 +549,7 @@ export function ContentViewer({
|
|||
mimeType={effective}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -404,6 +560,7 @@ export function ContentViewer({
|
|||
mimeType={mimeType}
|
||||
copyable={copyable}
|
||||
wrap={wrap}
|
||||
jsonLabel={jsonLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getMimeKind,
|
||||
isSafeHref,
|
||||
isTextualKind,
|
||||
formatJsonDocument,
|
||||
looksLikeJson,
|
||||
PREVIEW_HTML_CSP,
|
||||
tryDecodeBase64ToBytes,
|
||||
|
|
@ -102,6 +103,35 @@ describe("formatJson", () => {
|
|||
it("returns the input unchanged when invalid", () => {
|
||||
expect(formatJson("{ broken")).toBe("{ broken");
|
||||
});
|
||||
|
||||
// Pretty-printing is a round trip through JS's number type, which is not
|
||||
// lossless. Reformatting these would show the reader something other than
|
||||
// what the sender put on the wire — the worst kind of bug in an inspector.
|
||||
it("leaves a payload alone when reindenting would change a value", () => {
|
||||
// Would render as …992.
|
||||
expect(formatJson('{"id":9007199254740993}')).toBe(
|
||||
'{"id":9007199254740993}',
|
||||
);
|
||||
// `JSON.parse` yields Infinity, which `JSON.stringify` writes as null.
|
||||
expect(formatJson('{"limit":1e400}')).toBe('{"limit":1e400}');
|
||||
});
|
||||
|
||||
// The starkest instance of the same class: only the *second* `role` survives
|
||||
// `JSON.parse`, so reindenting would show the reader less than the server
|
||||
// sent.
|
||||
it("leaves a payload alone when it names the same member twice", () => {
|
||||
expect(formatJson('{"role":"user","role":"admin"}')).toBe(
|
||||
'{"role":"user","role":"admin"}',
|
||||
);
|
||||
});
|
||||
|
||||
// The spelling of a number changes on a round trip and its *value* does not,
|
||||
// so these still get indented: `0.10000000000000001` and `0.1` are the same
|
||||
// double, and `1e2` and `100` are the same number.
|
||||
it("still formats a payload whose numbers only change spelling", () => {
|
||||
expect(formatJson('{"n":0.10000000000000001}')).toBe('{\n "n": 0.1\n}');
|
||||
expect(formatJson('{"n":1e2}')).toBe('{\n "n": 100\n}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeJson", () => {
|
||||
|
|
@ -112,6 +142,38 @@ describe("looksLikeJson", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("formatJsonDocument", () => {
|
||||
it("pretty-prints text that really parses as JSON", () => {
|
||||
expect(formatJsonDocument(' {"a":1}')).toBe('{\n "a": 1\n}');
|
||||
expect(formatJsonDocument("[1,2]")).toBe("[\n 1,\n 2\n]");
|
||||
});
|
||||
|
||||
// The stricter half of the heuristic (#2151). Text that merely *starts* like
|
||||
// JSON must not be handed to the JSON editor: a note beginning `{` would then
|
||||
// be presented as a document, in a JSON gutter, framed as malformed.
|
||||
// Still a JSON document — it just does not get reindented when reindenting
|
||||
// would change what it says.
|
||||
it("returns the source unindented when reindenting would change a value", () => {
|
||||
expect(formatJsonDocument('{"id":9007199254740993}')).toBe(
|
||||
'{"id":9007199254740993}',
|
||||
);
|
||||
expect(formatJsonDocument('{"limit":1e400}')).toBe('{"limit":1e400}');
|
||||
});
|
||||
|
||||
it("reports text that only looks like JSON as not JSON", () => {
|
||||
expect(formatJsonDocument("{ broken")).toBeNull();
|
||||
expect(formatJsonDocument("[unclosed")).toBeNull();
|
||||
expect(formatJsonDocument("plain")).toBeNull();
|
||||
});
|
||||
|
||||
// A bare scalar is valid JSON but never looked like a document, so it stays
|
||||
// on the plain renderer — otherwise ordinary prose that happens to be a
|
||||
// number would open an editor.
|
||||
it("rejects a scalar that never looked like a document", () => {
|
||||
expect(formatJsonDocument("42")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatXml", () => {
|
||||
it("indents nested elements", () => {
|
||||
const out = formatXml("<a><b>x</b></a>");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
/**
|
||||
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept in
|
||||
* a dependency-free module so they can be unit-tested in isolation and reused by
|
||||
* the blob renderers (PDF / CSV / HTML) without dragging in React.
|
||||
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept
|
||||
* free of component dependencies so they can be unit-tested in isolation and
|
||||
* reused by the blob renderers (PDF / CSV / HTML) without dragging in React.
|
||||
* The predicates imported below are the exception that proves it: plain
|
||||
* functions over a string, shared so the viewer and the JSON editors cannot
|
||||
* disagree about which payloads survive a round trip.
|
||||
*/
|
||||
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
||||
import {
|
||||
findDuplicateObjectKey,
|
||||
findUnsendableNumberLiteral,
|
||||
} from "../../../utils/jsonObjectDraft";
|
||||
|
||||
/**
|
||||
* The renderer family a MIME type maps to. `ContentViewer` switches on this to
|
||||
|
|
@ -130,18 +138,82 @@ export function tryDecodeBase64ToBytes(
|
|||
/** Pretty-print JSON text; returns the input unchanged when it doesn't parse. */
|
||||
export function formatJson(content: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 2);
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
return reformatsFaithfully(content, parsed)
|
||||
? JSON.stringify(parsed, null, 2)
|
||||
: content;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether pretty-printing this payload would show the reader the same values
|
||||
* the sender put on the wire.
|
||||
*
|
||||
* Pretty-printing here is `JSON.parse` then `JSON.stringify`, which is a
|
||||
* *round trip through JavaScript's number type* — and that is not lossless.
|
||||
* A server sending `{"id":9007199254740993}` would be shown `…992`, and one
|
||||
* sending `{"limit":1e400}` would be shown `null`, because `JSON.parse` yields
|
||||
* `Infinity` and `JSON.stringify` writes that as null. Neither is a formatting
|
||||
* difference; both are the viewer telling the reader something untrue about
|
||||
* the payload it received, which for an inspector is the worst kind of bug.
|
||||
*
|
||||
* So when the round trip would alter a value, the payload is displayed exactly
|
||||
* as it arrived — unindented, but honest. The copy button already copied the
|
||||
* original text either way; this makes what is on screen agree with it.
|
||||
*
|
||||
* The same checks the draft parsers use, which is why they live in
|
||||
* `utils/jsonObjectDraft` rather than beside either caller.
|
||||
*/
|
||||
function reformatsFaithfully(text: string, parsed: unknown): boolean {
|
||||
return (
|
||||
isSerializableJson(parsed) &&
|
||||
findUnsendableNumberLiteral(text) === null &&
|
||||
// A duplicate member name is the same class and the starkest instance:
|
||||
// `{"role":"user","role":"admin"}` would be *shown* as only the second,
|
||||
// which is the viewer hiding what the server sent.
|
||||
findDuplicateObjectKey(text) === null
|
||||
);
|
||||
}
|
||||
|
||||
/** Heuristic: does this plain text (no MIME) look like a JSON document? */
|
||||
export function looksLikeJson(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print untyped text if it really is a JSON document, or report that it
|
||||
* is not.
|
||||
*
|
||||
* {@link looksLikeJson} only tests the first character, which is enough to
|
||||
* decide whether to *try* pretty-printing — a failed parse falls back to the
|
||||
* original text and nothing is lost. It is not enough to decide to hand the
|
||||
* text to the JSON editor: a note beginning `{` would then be presented as a
|
||||
* JSON document, in a JSON gutter, with the server's plain text framed as
|
||||
* malformed. A declared `application/json` is different — there the server said
|
||||
* what it sent, so its own broken payload is shown as JSON on purpose.
|
||||
*
|
||||
* Returns the formatted text rather than a boolean so the caller does not parse
|
||||
* a second time to render what this already parsed to decide. Protocol and
|
||||
* Network entries hand their serialized bodies over as untyped text and are not
|
||||
* virtualized, so a long history pays for every parse on every render.
|
||||
*/
|
||||
export function formatJsonDocument(text: string): string | null {
|
||||
if (!looksLikeJson(text)) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
// Still a JSON document either way — it just does not get reindented when
|
||||
// reindenting would change what it says. See `reformatsFaithfully`.
|
||||
return reformatsFaithfully(text, parsed)
|
||||
? JSON.stringify(parsed, null, 2)
|
||||
: text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent a single-line or minified XML/HTML-ish document for readability before
|
||||
* syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { EditReplayButton } from "./EditReplayButton";
|
||||
|
||||
const meta: Meta<typeof EditReplayButton> = {
|
||||
title: "Elements/EditReplayButton",
|
||||
component: EditReplayButton,
|
||||
args: { onClick: fn() },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EditReplayButton>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Activated: Story = {
|
||||
play: async ({ args, canvasElement, userEvent }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Edit and replay" }),
|
||||
);
|
||||
await expect(args.onClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { EditReplayButton } from "./EditReplayButton";
|
||||
|
||||
describe("EditReplayButton", () => {
|
||||
it("is named for assistive tech", () => {
|
||||
renderWithMantine(<EditReplayButton onClick={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Edit and replay" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClick when activated", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderWithMantine(<EditReplayButton onClick={onClick} />);
|
||||
await user.click(screen.getByRole("button", { name: "Edit and replay" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { MdEditNote } from "react-icons/md";
|
||||
|
||||
export interface EditReplayButtonProps {
|
||||
/** Open the editor seeded with the owning entry's request params. */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Edit and replay" — the sibling of {@link ReplayButton} that stops for the
|
||||
* params instead of re-sending them verbatim (#2151).
|
||||
*
|
||||
* Same subtle gray icon-button styling as Replay and the pin toggle, so the
|
||||
* three read as one control cluster on a Protocol entry.
|
||||
*/
|
||||
const EditReplayActionIcon = ActionIcon.withProps({
|
||||
variant: "subtle",
|
||||
color: "gray",
|
||||
size: "md",
|
||||
"aria-label": "Edit and replay",
|
||||
});
|
||||
|
||||
export function EditReplayButton({ onClick }: EditReplayButtonProps) {
|
||||
return (
|
||||
<Tooltip label="Edit and replay">
|
||||
<EditReplayActionIcon onClick={onClick}>
|
||||
<MdEditNote size={20} />
|
||||
</EditReplayActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent } from "storybook/test";
|
||||
import { JsonEditor } from "./JsonEditor";
|
||||
|
||||
const meta: Meta<typeof JsonEditor> = {
|
||||
title: "Elements/JsonEditor",
|
||||
component: JsonEditor,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof JsonEditor>;
|
||||
|
||||
const SAMPLE = JSON.stringify(
|
||||
{
|
||||
trace: { id: "abc-123", sampled: true },
|
||||
tags: ["alpha", "beta"],
|
||||
retries: 3,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
|
||||
function aceEditor(canvasElement: HTMLElement): {
|
||||
focus(): void;
|
||||
getValue(): string;
|
||||
} {
|
||||
const container = canvasElement.querySelector(".ace_editor");
|
||||
const editor = (
|
||||
container as
|
||||
| (HTMLElement & {
|
||||
env?: { editor?: { focus(): void; getValue(): string } };
|
||||
})
|
||||
| null
|
||||
)?.env?.editor;
|
||||
if (!editor) throw new Error("Ace editor not mounted");
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful wrapper: the editor is controlled on text by every real consumer, so
|
||||
* a story without one would not accept a keystroke.
|
||||
*
|
||||
* Ace's *keyboard* behavior — auto-closing a brace, keeping an invalid draft
|
||||
* raw, the worker's gutter annotation — is covered by the `JsonObjectInput`
|
||||
* play functions, which drive this same editor through its object-shaped
|
||||
* wrapper. What is left to pin here is the read-only mode those cannot reach.
|
||||
*/
|
||||
function Editable({ initial }: { initial: string }) {
|
||||
const [text, setText] = useState(initial);
|
||||
return (
|
||||
<JsonEditor
|
||||
ariaLabel="Payload JSON"
|
||||
label="Payload"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Editing: Story = {
|
||||
render: () => <Editable initial={SAMPLE} />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => <Editable initial="" />,
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
ariaLabel: "Payload JSON",
|
||||
label: "Payload",
|
||||
value: '{"a":',
|
||||
description: "Sent with every request.",
|
||||
error: "Not valid JSON — changes are not applied",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Switched off by the form around it — what a tool panel does to every field
|
||||
* while a call is in flight. Ace has no disabled state, so this is read-only
|
||||
* plus a dimmed wrapper, `aria-disabled`, and removal from the tab order.
|
||||
*/
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
ariaLabel: "Payload JSON",
|
||||
label: "Payload",
|
||||
value: SAMPLE,
|
||||
disabled: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
// Re-stated because a story's `a11y.config.rules` replaces the
|
||||
// preview's rather than merging with it — dropping this would make
|
||||
// `region` fire here and nowhere else.
|
||||
{ id: "region", enabled: false },
|
||||
// WCAG 1.4.3 exempts "inactive user interface components" from the
|
||||
// contrast minimum, and this whole editor is one: the dimming is the
|
||||
// affordance that says so. axe cannot see that, because the disabled
|
||||
// state lives on Ace's hidden textarea while the text it measures is
|
||||
// in the separately-rendered gutter — so it reports the dimmed line
|
||||
// numbers as a violation on every row.
|
||||
//
|
||||
// Scoped to this story on purpose. Every other story here, and the
|
||||
// whole read-only path, is still contrast-checked; a preview-level
|
||||
// disable would retire the rule for the enabled editor too, which is
|
||||
// where it has something to catch.
|
||||
{ id: "color-contrast", enabled: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Display mode — what every read-only JSON payload in the app renders as
|
||||
* (#2151). It keeps the folding and the line numbers a large `tools/call`
|
||||
* result gains from, and takes no input.
|
||||
*
|
||||
* The keystroke assertion is the point: a read-only Ace editor is still
|
||||
* focusable and still owns a hidden textarea, so "does not edit" is a real
|
||||
* behavior to pin rather than a property of the markup.
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
ariaLabel: "JSON content",
|
||||
value: SAMPLE,
|
||||
readOnly: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const editor = aceEditor(canvasElement);
|
||||
editor.focus();
|
||||
await userEvent.keyboard("x");
|
||||
await expect(editor.getValue()).toBe(SAMPLE);
|
||||
// The caret is hidden too — a blinking cursor in a box that swallows every
|
||||
// keystroke reads as a broken field.
|
||||
await expect(
|
||||
canvasElement.querySelector(".json-editor-readonly"),
|
||||
).not.toBeNull();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import { JsonEditor } from "./JsonEditor";
|
||||
|
||||
const LABEL = "Payload JSON";
|
||||
|
||||
/** Controlled on text, which is the contract every consumer uses. */
|
||||
function Harness({
|
||||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: string;
|
||||
onChange?: (text: string) => void;
|
||||
}) {
|
||||
const [text, setText] = useState(initial);
|
||||
return (
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value={text}
|
||||
onChange={(next) => {
|
||||
setText(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("JsonEditor", () => {
|
||||
it("names the editor for assistive tech", () => {
|
||||
renderWithMantine(<Harness initial="" />);
|
||||
// Ace composes "<label>, Cursor at row N", so the match is by substring —
|
||||
// the position readout is Ace's, the name is ours.
|
||||
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Ace recomputes the textarea's `aria-label` from its option only when the
|
||||
// cursor moves, so a name that changes while the editor stays mounted — a
|
||||
// tool refreshed in place under a new field title — would leave the visible
|
||||
// label and the accessible name disagreeing until the user clicked into it.
|
||||
it("re-applies the accessible name when it changes in place", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<JsonEditor ariaLabel="Series JSON" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(aceLabel("Series JSON"))).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<JsonEditor ariaLabel="Data points JSON" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText(aceLabel("Data points JSON")),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(aceLabel("Series JSON"))).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the text it is given, verbatim", () => {
|
||||
// Braces, not a bare JSX string: a JSX string attribute does not process
|
||||
// escapes, so `'…\n…'` written inline would be a literal backslash-n.
|
||||
renderWithMantine(<Harness initial={'{\n "a": 1\n}'} />);
|
||||
expect(getAceText()).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
it("reports the settled text of an edit", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial="" onChange={onChange} />);
|
||||
await setAceText('{"a":1}');
|
||||
expect(onChange).toHaveBeenLastCalledWith('{"a":1}');
|
||||
});
|
||||
|
||||
// Ace fires a *remove* then an *insert* for a replace. Reporting the first
|
||||
// would tell the consumer the document is empty, which for `JsonObjectInput`
|
||||
// means `{}` — the user's saved metadata, wiped a keystroke before the
|
||||
// replacement lands.
|
||||
it("reports a replace once, not as an empty document first", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial='{"keep":1}' onChange={onChange} />);
|
||||
await setAceText('{"next":2}');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('{"next":2}');
|
||||
});
|
||||
|
||||
it("uses Ace's dark theme under a dark color scheme", () => {
|
||||
renderWithMantine(<Harness initial="" />, { colorScheme: "dark" });
|
||||
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
|
||||
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
|
||||
// same class, so getting it wrong silently drops the fix in dark mode.
|
||||
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Ace's light theme under a light color scheme", () => {
|
||||
renderWithMantine(<Harness initial="" />, { colorScheme: "light" });
|
||||
expect(document.querySelector(".ace-github")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github_dark")).toBeNull();
|
||||
});
|
||||
|
||||
describe("wrapper wiring", () => {
|
||||
it("associates the label, description and error with the editor", () => {
|
||||
renderWithMantine(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
label="Metadata"
|
||||
description="Sent with every request."
|
||||
error="Not valid JSON"
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(aceLabel(LABEL));
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
const describedBy = input.getAttribute("aria-describedby")?.split(" ");
|
||||
expect(describedBy).toHaveLength(2);
|
||||
// Both nodes exist and are the ones named — a description that is never
|
||||
// announced, or an error pointing at nothing, is the failure mode here.
|
||||
for (const id of describedBy ?? []) {
|
||||
expect(document.getElementById(id)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText("Not valid JSON")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the error wiring once the text is valid again", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
error="Not valid JSON"
|
||||
/>,
|
||||
);
|
||||
rerender(<JsonEditor ariaLabel={LABEL} value="" onChange={vi.fn()} />);
|
||||
const input = screen.getByLabelText(aceLabel(LABEL));
|
||||
expect(input).not.toHaveAttribute("aria-invalid");
|
||||
expect(input).not.toHaveAttribute("aria-describedby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only", () => {
|
||||
it("is read-only when no change handler is supplied", () => {
|
||||
renderWithMantine(<JsonEditor ariaLabel={LABEL} value='{"a":1}' />);
|
||||
expect(getAceText()).toBe('{"a":1}');
|
||||
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is read-only when asked, even with a handler", () => {
|
||||
renderWithMantine(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value='{"a":1}'
|
||||
onChange={vi.fn()}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Ace has no disabled state, so its hidden textarea keeps `tabindex="0"`
|
||||
// unless something takes it out — leaving a keyboard user able to tab into
|
||||
// a field the rest of the form has switched off, with nothing announced.
|
||||
it("leaves the tab order and says so when disabled", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
|
||||
);
|
||||
const input = screen.getByLabelText(aceLabel(LABEL));
|
||||
expect(input).toHaveAttribute("tabindex", "0");
|
||||
expect(input).not.toHaveAttribute("aria-disabled");
|
||||
|
||||
rerender(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value='{"a":1}'
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
expect(input).toHaveAttribute("tabindex", "-1");
|
||||
expect(input).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// …and both are restored when the form re-enables.
|
||||
rerender(
|
||||
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
|
||||
);
|
||||
expect(input).toHaveAttribute("tabindex", "0");
|
||||
expect(input).not.toHaveAttribute("aria-disabled");
|
||||
});
|
||||
|
||||
// Read-only is not disabled: a payload the user may need to scroll past the
|
||||
// bottom of has to be reachable by keyboard (WCAG SC 2.1.1), which is why
|
||||
// the preformatted block this replaced carried `tabIndex={0}`.
|
||||
it("stays focusable when merely read-only", () => {
|
||||
renderWithMantine(<JsonEditor ariaLabel={LABEL} value='{"a":1}' />);
|
||||
const input = screen.getByLabelText(aceLabel(LABEL));
|
||||
expect(input).toHaveAttribute("tabindex", "0");
|
||||
expect(input).not.toHaveAttribute("aria-disabled");
|
||||
});
|
||||
|
||||
it("is read-only and dimmed when disabled", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value='{"a":1}'
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
|
||||
// The only affordance a pointer user gets, since Ace has no disabled
|
||||
// state of its own.
|
||||
expect(container.querySelector('[style*="opacity: 0.6"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Read-only is derived state, so both transitions happen to an editor that
|
||||
// is already mounted — and react-ace's `componentDidUpdate` reads
|
||||
// `prevProps.className.trim()` with no guard when the class changes. An
|
||||
// `undefined` on either side of that swap throws, or writes the literal
|
||||
// class name "undefined" onto the element.
|
||||
it("becomes editable when a handler arrives", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<JsonEditor ariaLabel={LABEL} value='{"a":1}' />,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
|
||||
rerender(
|
||||
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).toBeNull();
|
||||
expect(document.querySelector(".undefined")).toBeNull();
|
||||
});
|
||||
|
||||
// The direction a tool form takes when a call goes in flight: the panel
|
||||
// disables the whole form, so an editable editor becomes read-only in
|
||||
// place. This threw before the class was made unconditionally a string.
|
||||
it("becomes read-only when the form is disabled mid-edit", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).toBeNull();
|
||||
rerender(
|
||||
<JsonEditor
|
||||
ariaLabel={LABEL}
|
||||
value='{"a":1}'
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
300
clients/web/src/components/elements/JsonEditor/JsonEditor.tsx
Normal file
300
clients/web/src/components/elements/JsonEditor/JsonEditor.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { useEffect, useId, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Input, useComputedColorScheme } from "@mantine/core";
|
||||
import type { Ace } from "ace-builds";
|
||||
import AceEditor from "react-ace";
|
||||
import ace from "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-github_dark";
|
||||
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
|
||||
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
|
||||
// bundled app, silently loses its annotations, and logs a fetch error.
|
||||
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
|
||||
|
||||
// Module scope: registering the worker URL is global to Ace and idempotent, so
|
||||
// it must not be repeated per mount.
|
||||
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
|
||||
|
||||
export interface JsonEditorProps {
|
||||
/**
|
||||
* The text the editor displays. This is a **text** contract, not a JSON one:
|
||||
* the editor never parses, so a caller holding a value keeps its own draft
|
||||
* text and decides for itself what an unparseable draft means.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Settled text after an edit. Omitted for a read-only editor.
|
||||
*
|
||||
* Called once per edit, not once per Ace change event — see the coalescing
|
||||
* note on `handleChange`.
|
||||
*/
|
||||
onChange?: (text: string) => void;
|
||||
/**
|
||||
* Display only: no caret, no editing, and no JSON worker (its annotations
|
||||
* describe a document the user cannot fix). Implied when `onChange` is
|
||||
* omitted.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Accessible name for the editor's text input. Ace labels its hidden
|
||||
* textarea "Cursor at row N" by default, which is a position readout rather
|
||||
* than a name — so this is what makes the control identifiable to a screen
|
||||
* reader, and what `getByLabelText` finds in a test.
|
||||
*/
|
||||
ariaLabel: string;
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Rendered by the wrapper and referenced from the editor's `aria-describedby`. */
|
||||
error?: ReactNode;
|
||||
withAsterisk?: boolean;
|
||||
/**
|
||||
* Non-editable *and* visibly inert, for a form whose whole surface is
|
||||
* disabled. Ace has no disabled state of its own, so this is read-only plus
|
||||
* a dimmed wrapper.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Rows shown before the editor grows; it autosizes between these bounds. */
|
||||
minLines?: number;
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ace-backed JSON editor every JSON-typing and JSON-displaying surface in
|
||||
* the web client renders (#2151).
|
||||
*
|
||||
* Ace rather than a plain textarea because hand-writing JSON in a bare box is
|
||||
* the actual pain: Ace closes a brace when you open one, folds a nested
|
||||
* payload, numbers its lines, and — via its JSON worker — marks the offending
|
||||
* line in the gutter instead of reporting one message for the whole document.
|
||||
* Read-only it keeps the folding and the line numbers, which is what a large
|
||||
* response payload gains over a preformatted block.
|
||||
*
|
||||
* Deliberately **text in, text out**. The two editing contracts above it want
|
||||
* different things from an unparseable draft — `JsonObjectInput` keeps the last
|
||||
* valid object and says nothing to its parent, `SchemaJsonField` reports
|
||||
* `undefined` and blocks submission — and neither can be expressed by a
|
||||
* component that decides what a draft means. So parsing, and the draft/value
|
||||
* split that goes with it, belongs to each caller; only the editing surface is
|
||||
* shared.
|
||||
*/
|
||||
export function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
ariaLabel,
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
withAsterisk,
|
||||
disabled = false,
|
||||
minLines = 6,
|
||||
maxLines = 24,
|
||||
}: JsonEditorProps) {
|
||||
// `getInitialValueInEffect: false` so the first render already has the real
|
||||
// scheme; otherwise the editor mounts light and repaints, which is visible.
|
||||
const colorScheme = useComputedColorScheme("light", {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
|
||||
// Text from the most recent change event in the current tick, and whether a
|
||||
// flush is already queued to consume it. Two refs rather than one nullable
|
||||
// one so the flush reads a plain `string` — folding them together would add
|
||||
// a null check that nothing can ever satisfy. See `handleChange`.
|
||||
const pendingTextRef = useRef("");
|
||||
const flushScheduledRef = useRef(false);
|
||||
// Read through a ref so the coalescing flush always calls the current
|
||||
// handler, without the handler's identity being part of any dependency list.
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
/**
|
||||
* Coalesce the change events Ace fires within one edit, and act on the last.
|
||||
*
|
||||
* A replace — select-all-and-retype, or a paste over a selection — is a
|
||||
* *remove* followed by an *insert*, and Ace fires a change event for each.
|
||||
* Acting on the first would report the momentarily-empty document as the
|
||||
* user's answer: for `JsonObjectInput` that is `{}`, which overwrites the
|
||||
* metadata they had just selected; for a raw-JSON form it is a cleared
|
||||
* arguments object. If the replacement text then failed to parse, that empty
|
||||
* value is what they would be left with.
|
||||
*
|
||||
* A microtask is enough because both events are dispatched synchronously
|
||||
* inside the same edit, so the flush sees only the settled text.
|
||||
*/
|
||||
const handleChange = (text: string) => {
|
||||
pendingTextRef.current = text;
|
||||
if (flushScheduledRef.current) return;
|
||||
flushScheduledRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
flushScheduledRef.current = false;
|
||||
onChangeRef.current?.(pendingTextRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
const wrapperId = useId();
|
||||
const editorRef = useRef<Ace.Editor | null>(null);
|
||||
const isReadOnly = readOnly === true || disabled || onChange === undefined;
|
||||
const hasError = error !== undefined && error !== null && error !== false;
|
||||
|
||||
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
|
||||
// is not one — it renders its own DOM — so the wrapper's label and error node
|
||||
// exist but point at nothing, leaving the control unassociated with the error
|
||||
// a screen reader needs and its visible label inert on click.
|
||||
//
|
||||
// A genuine external-system sync, so an effect rather than `useValueChange`:
|
||||
// it writes to a DOM node React does not own. Giving the textarea the
|
||||
// wrapper's own id is what makes the `<label for>` focus the editor; the
|
||||
// error wiring follows Mantine's `${id}-error` naming.
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const element = editor?.textInput.getElement();
|
||||
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
|
||||
synchronously on mount, so this effect always sees an element. */
|
||||
if (!editor || !element) return;
|
||||
element.id = wrapperId;
|
||||
|
||||
// Re-apply the accessible name, not just set it once at mount.
|
||||
//
|
||||
// `setOptions` does update Ace's *option* when `ariaLabel` changes, but Ace
|
||||
// only recomputes the textarea's `aria-label` from it inside
|
||||
// `TextInput.setAriaLabel()`, which it calls when the cursor moves. So a
|
||||
// name that changes while the editor stays mounted — a tool refreshed in
|
||||
// place under a new field title, which `SchemaJsonField` passes straight
|
||||
// through — leaves the visible label and the accessible name disagreeing
|
||||
// until the user happens to click into the box.
|
||||
//
|
||||
// `onLoad` still does this too: an effect runs after paint, and this is the
|
||||
// control's only name, so mount should not present a frame without it.
|
||||
editor.setOption("textInputAriaLabel", ariaLabel);
|
||||
editor.textInput.setAriaLabel();
|
||||
|
||||
// Both the description and the error are rendered by the wrapper and both
|
||||
// describe this control, so both ids belong here — a supplied description
|
||||
// is otherwise never announced, and it must not be dropped just because the
|
||||
// draft went invalid. Mantine renders each only when its prop is set, so
|
||||
// referencing an absent one would point at nothing.
|
||||
const describedBy = [
|
||||
description === undefined ? null : `${wrapperId}-description`,
|
||||
hasError ? `${wrapperId}-error` : null,
|
||||
].filter((id) => id !== null);
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
element.setAttribute("aria-describedby", describedBy.join(" "));
|
||||
} else {
|
||||
element.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
element.setAttribute("aria-invalid", "true");
|
||||
} else {
|
||||
element.removeAttribute("aria-invalid");
|
||||
}
|
||||
|
||||
// `disabled` and `readOnly` are different states here, and only one of them
|
||||
// leaves the tab order.
|
||||
//
|
||||
// A **read-only** editor is a rendering of a payload the user may need to
|
||||
// read past the bottom of, so it stays focusable — the same reason the
|
||||
// preformatted block it replaced carries `tabIndex={0}` (WCAG SC 2.1.1).
|
||||
//
|
||||
// A **disabled** one is a control the rest of the form has switched off. Ace
|
||||
// has no disabled state, so without this its hidden textarea keeps
|
||||
// `tabindex="0"` and a keyboard user tabs into a field every other control
|
||||
// beside it has dropped out of the order — landing somewhere that looks
|
||||
// editable and silently swallows what they type. Assistive tech is told
|
||||
// nothing either, since dimming is a purely visual signal.
|
||||
if (disabled) {
|
||||
element.setAttribute("aria-disabled", "true");
|
||||
element.tabIndex = -1;
|
||||
} else {
|
||||
element.removeAttribute("aria-disabled");
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
}, [wrapperId, hasError, description, disabled, ariaLabel]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
id={wrapperId}
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
withAsterisk={withAsterisk}
|
||||
// Ace has no disabled state, so the only signal a pointer user gets is
|
||||
// that the surface is dimmed — the same affordance a disabled Mantine
|
||||
// input carries.
|
||||
opacity={disabled ? 0.6 : undefined}
|
||||
w="100%"
|
||||
>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={colorScheme === "dark" ? "github_dark" : "github"}
|
||||
// react-ace uses `name` as the editor container's DOM id, so a fixed
|
||||
// value would collide the moment two of these render — and it must also
|
||||
// differ from the id given to the textarea below, which is the one the
|
||||
// wrapper's `<label for>` points at.
|
||||
name={`${wrapperId}-editor`}
|
||||
// A class, not a style: Ace renders its own DOM and paints a caret even
|
||||
// when read-only, which reads as an editable field whose keystrokes are
|
||||
// being swallowed. Hiding it needs a selector into that DOM, which is
|
||||
// the same reason the gutter override in App.css exists.
|
||||
//
|
||||
// `""` rather than `undefined` for the off case: react-ace's
|
||||
// `componentDidUpdate` reads `prevProps.className.trim()` whenever the
|
||||
// class changes, with no guard — so going *to* a class from `undefined`
|
||||
// throws, and going *from* one to `undefined` writes the literal class
|
||||
// name "undefined" onto the element. Both are reachable here, because
|
||||
// read-only is derived state: a tool form disables itself while a call
|
||||
// is in flight, which flips this on an editor already mounted.
|
||||
className={isReadOnly ? "json-editor-readonly" : ""}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
readOnly={isReadOnly}
|
||||
width="100%"
|
||||
minLines={minLines}
|
||||
maxLines={maxLines}
|
||||
tabSize={2}
|
||||
showPrintMargin={false}
|
||||
// A read-only editor is a rendering of someone else's payload, so it
|
||||
// carries none of the caret furniture an editable one does.
|
||||
highlightActiveLine={!isReadOnly}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
onLoad={(editor) => {
|
||||
editorRef.current = editor;
|
||||
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
|
||||
// textarea's label as "<label>, Cursor at row N" inside
|
||||
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
|
||||
// moves — so an option applied at mount does not reach the DOM until
|
||||
// the user clicks into the editor. Until then the control's only
|
||||
// accessible name is the position readout, which names nothing.
|
||||
// Setting it here and recomputing once is what makes the editor
|
||||
// identifiable from first paint.
|
||||
editor.setOption("textInputAriaLabel", ariaLabel);
|
||||
editor.textInput.setAriaLabel();
|
||||
}}
|
||||
setOptions={{
|
||||
// The JSON worker is what puts a marker on the offending line rather
|
||||
// than one message for the whole document. Pointless on a read-only
|
||||
// document — the reader cannot act on what it flags — and actively
|
||||
// misleading where the text is a *fragment* rendered for display.
|
||||
useWorker: !isReadOnly,
|
||||
// Closes a brace/bracket/quote when you open one, and skips over the
|
||||
// closing one you then type. On by default; named because it is half
|
||||
// of why this component exists.
|
||||
behavioursEnabled: true,
|
||||
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
|
||||
// the editor does not swallow Tab for keyboard users.
|
||||
enableKeyboardAccessibility: true,
|
||||
textInputAriaLabel: ariaLabel,
|
||||
showFoldWidgets: true,
|
||||
displayIndentGuides: true,
|
||||
useSoftTabs: true,
|
||||
scrollPastEnd: false,
|
||||
highlightGutterLine: !isReadOnly,
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,8 @@
|
|||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Input, useComputedColorScheme } from "@mantine/core";
|
||||
import type { Ace } from "ace-builds";
|
||||
import AceEditor from "react-ace";
|
||||
import ace from "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-github_dark";
|
||||
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
|
||||
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
|
||||
// bundled app, silently loses its annotations, and logs a fetch error.
|
||||
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
|
||||
import { useState } from "react";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { useValueChange } from "../../../hooks/useValueChange";
|
||||
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
|
||||
|
||||
// Module scope: registering the worker URL is global to Ace and idempotent, so
|
||||
// it must not be repeated per mount.
|
||||
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
|
||||
import { JsonEditor } from "../JsonEditor/JsonEditor";
|
||||
|
||||
/** Two-space canonical form — the same shape the catalog file is written in. */
|
||||
function serialize(value: StrictJsonObject): string {
|
||||
|
|
@ -28,12 +14,7 @@ export interface JsonObjectInputProps {
|
|||
onChange: (value: StrictJsonObject) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Accessible name for the editor's text input. Ace labels its hidden
|
||||
* textarea "Cursor at row N" by default, which is a position readout rather
|
||||
* than a name — so this is what makes the control identifiable to a screen
|
||||
* reader, and what `getByLabelText` finds in a test.
|
||||
*/
|
||||
/** See {@link JsonEditor}'s prop of the same name — Ace names its own input. */
|
||||
ariaLabel: string;
|
||||
/** Rows shown before the editor grows; it autosizes between these bounds. */
|
||||
minLines?: number;
|
||||
|
|
@ -50,10 +31,9 @@ export interface JsonObjectInputProps {
|
|||
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
|
||||
* what the protocol allows.
|
||||
*
|
||||
* Built on Ace rather than a plain textarea because hand-writing JSON in a bare
|
||||
* box is the actual pain: Ace closes a brace when you open one, folds nested
|
||||
* objects, and — via its JSON worker — marks the offending line in the gutter
|
||||
* instead of reporting one message for the whole document.
|
||||
* The editing surface is {@link JsonEditor}, shared with every other JSON
|
||||
* surface in the client (#2151). What lives here is the *contract*: an object
|
||||
* in, an object out, and a decision about what an unparseable draft means.
|
||||
*
|
||||
* The text the user is typing is the source of truth for what is *displayed*;
|
||||
* the parent only ever sees a parsed object. That split is what makes the box
|
||||
|
|
@ -75,8 +55,8 @@ export function JsonObjectInput({
|
|||
label,
|
||||
description,
|
||||
ariaLabel,
|
||||
minLines = 6,
|
||||
maxLines = 24,
|
||||
minLines,
|
||||
maxLines,
|
||||
}: JsonObjectInputProps) {
|
||||
const [draft, setDraft] = useState(() => serialize(value));
|
||||
// The canonical JSON of the last value this component emitted.
|
||||
|
|
@ -85,63 +65,22 @@ export function JsonObjectInput({
|
|||
// echoing back what we just sent it, and the draft alone cannot: while the
|
||||
// text is invalid it parses to nothing, so every parent change would look
|
||||
// external and overwrite what is being typed.
|
||||
//
|
||||
// That is not hypothetical. Ace's `setValue` — which is what a select-all
|
||||
// paste and any programmatic replace go through — fires *two* change events,
|
||||
// a remove then an insert. The remove leaves an empty document, which is a
|
||||
// valid `{}` and gets emitted; the insert then leaves invalid text. Comparing
|
||||
// against the draft, the `{}` echo reads as an external reset and wipes the
|
||||
// pasted text a keystroke after it lands.
|
||||
const [echoed, setEchoed] = useState(() => serialize(value));
|
||||
// `getInitialValueInEffect: false` so the first render already has the real
|
||||
// scheme; otherwise the editor mounts light and repaints, which is visible.
|
||||
const colorScheme = useComputedColorScheme("light", {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setDraft(text);
|
||||
const result = parseJsonObjectDraft(text);
|
||||
if (!result.ok) return;
|
||||
setEchoed(serialize(result.value));
|
||||
onChange(result.value);
|
||||
};
|
||||
|
||||
const parsed = parseJsonObjectDraft(draft);
|
||||
|
||||
// Re-sync only on a value this component did not produce, so an external
|
||||
// reset (switching servers, a reloaded catalog) reaches the editor while an
|
||||
// in-progress edit is left alone. Compared through canonical JSON because the
|
||||
// parent hands back a fresh object each time.
|
||||
// Text from the most recent change event in the current tick, and whether a
|
||||
// flush is already queued to consume it. Two refs rather than one nullable
|
||||
// one so the flush reads a plain `string` — folding them together would add
|
||||
// a null check that nothing can ever satisfy. See `handleChange`.
|
||||
const pendingTextRef = useRef("");
|
||||
const flushScheduledRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Coalesce the change events Ace fires within one edit, and act on the last.
|
||||
*
|
||||
* A replace — select-all-and-retype, or a paste over a selection — is a
|
||||
* *remove* followed by an *insert*, and Ace fires a change event for each.
|
||||
* Acting on the first would read the momentarily-empty document as the user
|
||||
* clearing the field, emit `{}` to the parent, and so wipe their saved
|
||||
* metadata the instant they selected it. If the replacement text then failed
|
||||
* to parse, `{}` is what they would be left with.
|
||||
*
|
||||
* A microtask is enough because both events are dispatched synchronously
|
||||
* inside the same edit, so the flush sees only the settled text.
|
||||
*/
|
||||
const handleChange = (text: string) => {
|
||||
pendingTextRef.current = text;
|
||||
if (flushScheduledRef.current) return;
|
||||
flushScheduledRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
flushScheduledRef.current = false;
|
||||
const settled = pendingTextRef.current;
|
||||
setDraft(settled);
|
||||
const result = parseJsonObjectDraft(settled);
|
||||
if (!result.ok) return;
|
||||
setEchoed(serialize(result.value));
|
||||
onChange(result.value);
|
||||
});
|
||||
};
|
||||
|
||||
const wrapperId = useId();
|
||||
const editorRef = useRef<Ace.Editor | null>(null);
|
||||
|
||||
const parsed = parseJsonObjectDraft(draft);
|
||||
useValueChange(serialize(value), (next) => {
|
||||
if (next === echoed) return;
|
||||
setDraft(next);
|
||||
|
|
@ -152,100 +91,16 @@ export function JsonObjectInput({
|
|||
setEchoed(next);
|
||||
});
|
||||
|
||||
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
|
||||
// is not one — it renders its own DOM — so the wrapper's label and error node
|
||||
// exist but point at nothing, leaving the control unassociated with the error
|
||||
// a screen reader needs and its visible label inert on click.
|
||||
//
|
||||
// A genuine external-system sync, so an effect rather than `useValueChange`:
|
||||
// it writes to a DOM node React does not own. Giving the textarea the
|
||||
// wrapper's own id is what makes the `<label for>` focus the editor; the
|
||||
// error wiring follows Mantine's `${id}-error` naming.
|
||||
useEffect(() => {
|
||||
const element = editorRef.current?.textInput.getElement();
|
||||
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
|
||||
synchronously on mount, so this effect always sees an element. */
|
||||
if (!element) return;
|
||||
element.id = wrapperId;
|
||||
|
||||
// Both the description and the error are rendered by the wrapper and both
|
||||
// describe this control, so both ids belong here — a supplied description
|
||||
// is otherwise never announced, and it must not be dropped just because the
|
||||
// draft went invalid. Mantine renders each only when its prop is set, so
|
||||
// referencing an absent one would point at nothing.
|
||||
const describedBy = [
|
||||
description === undefined ? null : `${wrapperId}-description`,
|
||||
parsed.ok ? null : `${wrapperId}-error`,
|
||||
].filter((id) => id !== null);
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
element.setAttribute("aria-describedby", describedBy.join(" "));
|
||||
} else {
|
||||
element.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
if (parsed.ok) {
|
||||
element.removeAttribute("aria-invalid");
|
||||
} else {
|
||||
element.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
}, [wrapperId, parsed.ok, description]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
id={wrapperId}
|
||||
<JsonEditor
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
label={label}
|
||||
description={description}
|
||||
ariaLabel={ariaLabel}
|
||||
error={parsed.ok ? undefined : parsed.error}
|
||||
w="100%"
|
||||
>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={colorScheme === "dark" ? "github_dark" : "github"}
|
||||
// react-ace uses `name` as the editor container's DOM id, so a fixed
|
||||
// value would collide the moment two of these render — and it must also
|
||||
// differ from the id given to the textarea below, which is the one the
|
||||
// wrapper's `<label for>` points at.
|
||||
name={`${wrapperId}-editor`}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
width="100%"
|
||||
minLines={minLines}
|
||||
maxLines={maxLines}
|
||||
tabSize={2}
|
||||
showPrintMargin={false}
|
||||
editorProps={{ $blockScrolling: Infinity }}
|
||||
onLoad={(editor) => {
|
||||
editorRef.current = editor;
|
||||
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
|
||||
// textarea's label as "<label>, Cursor at row N" inside
|
||||
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
|
||||
// moves — so an option applied at mount does not reach the DOM until
|
||||
// the user clicks into the editor. Until then the control's only
|
||||
// accessible name is the position readout, which names nothing.
|
||||
// Setting it here and recomputing once is what makes the editor
|
||||
// identifiable from first paint.
|
||||
editor.setOption("textInputAriaLabel", ariaLabel);
|
||||
editor.textInput.setAriaLabel();
|
||||
}}
|
||||
setOptions={{
|
||||
// The JSON worker is what puts a marker on the offending line rather
|
||||
// than one message for the whole document.
|
||||
useWorker: true,
|
||||
// Closes a brace/bracket/quote when you open one, and skips over the
|
||||
// closing one you then type. On by default; named because it is half
|
||||
// of why this component exists.
|
||||
behavioursEnabled: true,
|
||||
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
|
||||
// the editor does not swallow Tab for keyboard users.
|
||||
enableKeyboardAccessibility: true,
|
||||
textInputAriaLabel: ariaLabel,
|
||||
showFoldWidgets: true,
|
||||
displayIndentGuides: true,
|
||||
useSoftTabs: true,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
minLines={minLines}
|
||||
maxLines={maxLines}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { setAceTextByLabel } from "../../../test/aceEditor";
|
||||
import { AppDetailPanel } from "./AppDetailPanel";
|
||||
|
||||
const noFieldsTool: Tool = {
|
||||
|
|
@ -179,7 +180,6 @@ describe("AppDetailPanel", () => {
|
|||
// `undefined` — so an optional argument was dropped from the call with no
|
||||
// gate. The form reports draft validity for this reason.
|
||||
it("disables Open App while a field holds text it cannot send", async () => {
|
||||
const user = userEvent.setup();
|
||||
const jsonFieldTool: Tool = {
|
||||
name: "chart_app",
|
||||
title: "Chart App",
|
||||
|
|
@ -192,11 +192,10 @@ describe("AppDetailPanel", () => {
|
|||
const openApp = screen.getByRole("button", { name: /open app/i });
|
||||
expect(openApp).not.toBeDisabled();
|
||||
|
||||
const jsonInput = screen.getByLabelText(/Series/);
|
||||
await user.type(jsonInput, "x");
|
||||
await setAceTextByLabel(/Series/, "x");
|
||||
expect(openApp).toBeDisabled();
|
||||
|
||||
await user.clear(jsonInput);
|
||||
await setAceTextByLabel(/Series/, "");
|
||||
expect(openApp).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { EditReplayModal } from "./EditReplayModal";
|
||||
|
||||
const meta: Meta<typeof EditReplayModal> = {
|
||||
title: "Groups/EditReplayModal",
|
||||
component: EditReplayModal,
|
||||
args: {
|
||||
opened: true,
|
||||
method: "tools/call",
|
||||
params: { name: "get_weather", arguments: { city: "Boston" } },
|
||||
onSend: fn(),
|
||||
onClose: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EditReplayModal>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A request that carried no params — `tools/list` and friends. */
|
||||
export const NoParams: Story = {
|
||||
args: { method: "tools/list", params: undefined },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ace's own editing behavior is covered by the `JsonObjectInput` play
|
||||
* functions; what this pins is the gate on top of it — invalid text must not be
|
||||
* sendable, because unlike the metadata editor this modal commits.
|
||||
*/
|
||||
export const InvalidDraftBlocksSend: Story = {
|
||||
play: async ({ canvasElement, userEvent }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Modals render in a portal, so the body is the canvas here.
|
||||
const body = within(document.body);
|
||||
const editor = document.querySelector(".ace_editor") as HTMLElement & {
|
||||
env: { editor: { focus(): void; selectAll(): void } };
|
||||
};
|
||||
editor.env.editor.focus();
|
||||
editor.env.editor.selectAll();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
// A bare number: valid JSON, wrong shape for JSON-RPC `params`.
|
||||
await userEvent.keyboard("42");
|
||||
|
||||
await expect(await body.findByText(/must be a JSON object/i)).toBeVisible();
|
||||
await expect(body.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
// The canvas itself holds no controls — everything is portalled.
|
||||
await expect(canvas.queryByRole("button", { name: "Send" })).toBeNull();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { EditReplayModal } from "./EditReplayModal";
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "echo",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
inputSchema: { type: "object", properties: { count: { type: "number" } } },
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
opened: true,
|
||||
method: "tools/call",
|
||||
onSend: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
describe("EditReplayModal", () => {
|
||||
it("renders nothing while closed", () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} opened={false} />);
|
||||
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the editor with the entry's params and names the method", () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
|
||||
);
|
||||
expect(getAceText()).toBe('{\n "name": "echo"\n}');
|
||||
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens empty for a request that carried no params", () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
|
||||
expect(getAceText()).toBe("");
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends the edited params and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
params={{ name: "echo" }}
|
||||
onSend={onSend}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await setAceText('{"name":"add","arguments":{"a":1}}');
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onSend).toHaveBeenCalledWith({
|
||||
name: "add",
|
||||
arguments: { a: 1 },
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// `null`, not `undefined`: the caller has to be able to tell "the user
|
||||
// cleared the editor" from "the user did not edit anything" (#2151).
|
||||
it("reports an emptied editor as no params at all", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
params={{ name: "echo" }}
|
||||
onSend={onSend}
|
||||
/>,
|
||||
);
|
||||
await setAceText("");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onSend).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
// Unlike the metadata editor, this modal has a commit gesture to gate — so
|
||||
// invalid text must disable it rather than silently sending the last value
|
||||
// that parsed.
|
||||
it("disables Send while the draft does not parse", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
|
||||
);
|
||||
await setAceText('{"name":');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Send for JSON that is not an object", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} />);
|
||||
await setAceText("42");
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/must be a JSON object/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `JSON.parse("1e400")` yields `Infinity`, which `JSON.stringify` writes back
|
||||
// as `null` — so Send would dispatch a number the user never typed while the
|
||||
// editor still showed what they wrote.
|
||||
it("disables Send for a number that cannot survive being sent", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} />);
|
||||
await setAceText('{"limit":1e400}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Replay dispatches through the typed client methods, so a param they have no
|
||||
// room for is dropped. Saying which, rather than seeding it into the editor,
|
||||
// is what keeps the modal from inviting an edit that Send discards.
|
||||
it("names the params replay cannot re-send", () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
params={{ name: "echo" }}
|
||||
droppedParamKeys={["_meta"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says nothing when every param survives", () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
params={{ name: "echo" }}
|
||||
droppedParamKeys={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/not re-sent/)).toBeNull();
|
||||
});
|
||||
|
||||
it("agrees with its verb for several dropped params", () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
params={{ cursor: "abc" }}
|
||||
droppedParamKeys={["_meta", "extra"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/`_meta`, `extra` are not re-sent/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Seeding from the projection is only half of it: a key the dispatcher does
|
||||
// not read can be *added* to the draft, and Send would then discard an edit
|
||||
// the user was looking at.
|
||||
it("disables Send for a key this method's dispatch would not read", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
|
||||
);
|
||||
await setAceText('{"cursor":"abc","_meta":{"progressToken":1}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/`_meta` is not carried by tools\/list/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-enables Send once the unsupported key is removed", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
|
||||
);
|
||||
await setAceText('{"cursor":"abc","_meta":{}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
|
||||
await setAceText('{"cursor":"abc"}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
expect(screen.queryByText(/not carried by/)).toBeNull();
|
||||
});
|
||||
|
||||
// The check is per method, not a fixed key list: `arguments` is read by a
|
||||
// tool call and by nothing on a list request.
|
||||
it("judges a key against the method it would be sent to", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<EditReplayModal {...baseProps} method="tools/call" />,
|
||||
);
|
||||
await setAceText('{"name":"echo","arguments":{"a":1}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
|
||||
rerender(<EditReplayModal {...baseProps} method="tools/list" />);
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/`name`, `arguments` are not carried by tools\/list/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Surviving the key check is not the same as being sent as written:
|
||||
// `replayProtocolRequest` applies `arguments ?? {}`, so a null shown in the
|
||||
// editor reaches the wire as `{}`.
|
||||
it("disables Send for a null arguments, which would be sent as {}", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
|
||||
await setAceText('{"name":"echo","arguments":null}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/sent as `\{\}` when null/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Send for a non-object arguments", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
|
||||
await setAceText('{"name":"echo","arguments":[1,2]}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/`arguments` must be a JSON object/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so
|
||||
// the draft shows digits the wire will not carry.
|
||||
it("disables Send for a whole number that loses digits when parsed", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
|
||||
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/would not arrive as written/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Send for a non-string prompt argument", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="prompts/get" />);
|
||||
await setAceText('{"name":"greet","arguments":{"count":2}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/`count` would be sent as JSON text/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `callTool` coerces string arguments to the type the schema declares, so
|
||||
// `"2"` against a numeric field is sent as `2`.
|
||||
it("disables Send for a tool argument the schema would coerce", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} method="tools/call" tools={TOOLS} />,
|
||||
);
|
||||
await setAceText('{"name":"add","arguments":{"count":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/`count` would be converted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `name` is editable, so the schema to validate against is whichever tool the
|
||||
// draft *now* names — not the one the entry happened to call.
|
||||
it("re-validates against the tool the draft retargets to", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
method="tools/call"
|
||||
params={{ name: "echo", arguments: { message: "hi" } }}
|
||||
tools={TOOLS}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
|
||||
// Retargeted at `add`, whose `count` is a number — the string would coerce.
|
||||
await setAceText('{"name":"add","arguments":{"count":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
|
||||
// And back: `echo`'s `message` is a string, so nothing is converted.
|
||||
await setAceText('{"name":"echo","arguments":{"message":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// `JSON.parse` keeps only the last occurrence, so the draft would show two
|
||||
// cursors and Send would dispatch one.
|
||||
it("disables Send for a document that names the same member twice", async () => {
|
||||
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
|
||||
await setAceText('{"cursor":"first","cursor":"second"}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/`cursor` appears twice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes without sending when cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} onSend={onSend} onClose={onClose} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The draft belongs to the one opening of the modal it was typed in.
|
||||
// Reopening must show the entry's params again, not whatever was typed and
|
||||
// abandoned last time.
|
||||
it("reseeds from the entry when reopened", async () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
|
||||
);
|
||||
await setAceText('{"name":"abandoned"}');
|
||||
|
||||
rerender(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
opened={false}
|
||||
params={{ name: "echo" }}
|
||||
/>,
|
||||
);
|
||||
rerender(<EditReplayModal {...baseProps} params={{ name: "echo" }} />);
|
||||
expect(getAceText()).toBe('{\n "name": "echo"\n}');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import { useState } from "react";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||
import {
|
||||
duplicateKeyError,
|
||||
findDuplicateObjectKey,
|
||||
findUnsendableNumberLiteral,
|
||||
isJsonObject,
|
||||
unsendableNumberError,
|
||||
} from "../../../utils/jsonObjectDraft";
|
||||
import {
|
||||
replayableParams,
|
||||
reshapedReplayParam,
|
||||
} from "../../../lib/protocolReplay";
|
||||
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
||||
|
||||
export interface EditReplayModalProps {
|
||||
opened: boolean;
|
||||
/** The method being replayed, shown so the editor is unambiguous. */
|
||||
method: string;
|
||||
/**
|
||||
* The originating entry's params, seeded into the editor — already reduced to
|
||||
* the ones replay will actually read. See {@link droppedParamKeys}.
|
||||
*/
|
||||
params?: Record<string, unknown>;
|
||||
/**
|
||||
* Param names the entry carried that replay cannot re-send, so the modal can
|
||||
* say so rather than leave the difference to be discovered.
|
||||
*
|
||||
* Replay dispatches through the typed `InspectorClient` methods rather than
|
||||
* re-sending the captured frame, and those signatures have no room for
|
||||
* `_meta` (or for anything but `cursor` on a list request). Seeding the
|
||||
* editor with them would invite an edit that Send silently discards, which is
|
||||
* the one thing an inspector must not do.
|
||||
*/
|
||||
droppedParamKeys?: string[];
|
||||
/**
|
||||
* The connected server's tools, used only to detect an argument the client
|
||||
* would coerce to the type the schema declares — see
|
||||
* {@link reshapedReplayParam}.
|
||||
*
|
||||
* The whole list rather than the one the entry called, because `name` is
|
||||
* itself editable: retargeting an `echo` replay at `add` has to be validated
|
||||
* against `add`'s schema, not the one the entry happened to use.
|
||||
*/
|
||||
tools?: Tool[];
|
||||
/**
|
||||
* Re-issue the request with these params. `null` means "send no params",
|
||||
* which is a request an empty editor can legitimately express — see
|
||||
* {@link ReplayParamsOverride} for why it is not `undefined`.
|
||||
*/
|
||||
onSend: (params: Record<string, unknown> | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MethodText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
ff: "monospace",
|
||||
});
|
||||
|
||||
const Footer = Group.withProps({ justify: "flex-end", gap: "sm" });
|
||||
|
||||
// Names the params replay cannot carry. `light` and gray rather than a warning
|
||||
// colour: nothing is wrong and there is nothing for the user to fix — it is a
|
||||
// property of how replay dispatches.
|
||||
const DroppedNote = Alert.withProps({ variant: "light", color: "gray" });
|
||||
|
||||
/** "`a`, `b` are" / "`a` is" — the list, agreeing with its verb. */
|
||||
function formatDroppedKeys(keys: string[]): string {
|
||||
const names = keys.map((key) => `\`${key}\``).join(", ");
|
||||
return `${names} ${keys.length === 1 ? "is" : "are"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor's text currently means: the params to send, or why they
|
||||
* cannot be sent.
|
||||
*
|
||||
* Empty text is "no params" rather than an error — a `tools/list` replay
|
||||
* genuinely carries none, and an empty editor wearing a red error would read as
|
||||
* broken. A non-object is reported separately from text that is not JSON at
|
||||
* all: JSON-RPC `params` must be a structured value, so `42` is a shape that
|
||||
* cannot be sent however finished it is. A number that overflows, and a key
|
||||
* this method's dispatch does not read, are the two subtle cases — see the
|
||||
* notes on those branches.
|
||||
*/
|
||||
function parseParamsDraft(
|
||||
method: string,
|
||||
text: string,
|
||||
tools: Tool[] | undefined,
|
||||
):
|
||||
| { ok: true; value: Record<string, unknown> | null }
|
||||
| { ok: false; error: string } {
|
||||
if (text.trim() === "") return { ok: true, value: null };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: "Not valid JSON — this cannot be sent" };
|
||||
}
|
||||
if (!isJsonObject(parsed)) {
|
||||
return { ok: false, error: "Params must be a JSON object (`{ … }`)" };
|
||||
}
|
||||
// `JSON.parse` accepts numeric literals it cannot represent: `1e400` parses to
|
||||
// `Infinity`, which `JSON.stringify` then writes as `null`. Accepting it here
|
||||
// would show the user one value and send another — the one thing an inspector
|
||||
// must not do. Same guard, same reason, as `parseJsonObjectDraft`.
|
||||
if (!isSerializableJson(parsed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
|
||||
};
|
||||
}
|
||||
const unsendable = findUnsendableNumberLiteral(text);
|
||||
if (unsendable !== null) {
|
||||
return { ok: false, error: unsendableNumberError(unsendable) };
|
||||
}
|
||||
const duplicate = findDuplicateObjectKey(text);
|
||||
if (duplicate !== null) {
|
||||
return { ok: false, error: duplicateKeyError(duplicate) };
|
||||
}
|
||||
// Seeding from the projection is only half of it: a key the dispatcher does
|
||||
// not read can also be *added* to the draft, and Send would then discard an
|
||||
// edit the user was looking at. Checked with the same function the seed is
|
||||
// built from, so the editor and the dispatcher cannot disagree about which
|
||||
// keys survive.
|
||||
const { dropped } = replayableParams(method, parsed);
|
||||
if (dropped.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${formatDroppedKeys(dropped)} not carried by ${method} — remove ${
|
||||
dropped.length === 1 ? "it" : "them"
|
||||
} so this matches what is sent`,
|
||||
};
|
||||
}
|
||||
// Surviving the key check is not the same as being sent as written: see
|
||||
// `reshapedReplayParam` for the two ways `arguments` does not.
|
||||
// Resolved from the *draft's* name, so retargeting the call re-validates
|
||||
// against the tool it would actually reach.
|
||||
const named = typeof parsed.name === "string" ? parsed.name : undefined;
|
||||
const reshaped = reshapedReplayParam(
|
||||
method,
|
||||
parsed,
|
||||
tools?.find((candidate) => candidate.name === named),
|
||||
);
|
||||
if (reshaped !== null) {
|
||||
return { ok: false, error: reshaped };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal's body, split out so its draft is owned by a component Mantine
|
||||
* unmounts on close — which is what makes reopening start from the entry's
|
||||
* params again rather than from whatever was last typed and abandoned.
|
||||
*/
|
||||
function EditReplayForm({
|
||||
method,
|
||||
params,
|
||||
droppedParamKeys,
|
||||
tools,
|
||||
onSend,
|
||||
onClose,
|
||||
}: Omit<EditReplayModalProps, "opened">) {
|
||||
const [draft, setDraft] = useState(() =>
|
||||
params === undefined ? "" : JSON.stringify(params, null, 2),
|
||||
);
|
||||
const parsed = parseParamsDraft(method, draft, tools);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<MethodText>{method}</MethodText>
|
||||
{droppedParamKeys && droppedParamKeys.length > 0 && (
|
||||
<DroppedNote>
|
||||
<Text size="xs">
|
||||
{`Replay re-issues this request through the client's own ${method}
|
||||
method, which carries only the fields below, so
|
||||
${formatDroppedKeys(droppedParamKeys)} not re-sent.`}
|
||||
</Text>
|
||||
</DroppedNote>
|
||||
)}
|
||||
<JsonEditor
|
||||
ariaLabel="Request params"
|
||||
label="Params"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
error={parsed.ok ? undefined : parsed.error}
|
||||
minLines={8}
|
||||
maxLines={24}
|
||||
/>
|
||||
<Footer>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
// Unlike the metadata editor, this modal *has* a commit gesture to
|
||||
// gate — so invalid text disables Send rather than quietly sending
|
||||
// the last value that parsed. Same reasoning as `SchemaJsonField`.
|
||||
disabled={!parsed.ok}
|
||||
onClick={() => {
|
||||
/* v8 ignore next -- Send is disabled while the draft is invalid */
|
||||
if (!parsed.ok) return;
|
||||
onSend(parsed.value);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Footer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a Protocol entry's request params, then replay it (#2151).
|
||||
*
|
||||
* Replay itself is one click and unchanged; this is a second way to *supply*
|
||||
* the params, not a second replay implementation — Send dispatches through the
|
||||
* same `replayProtocolRequest` the plain Replay button does, so the same
|
||||
* method gate and the same "Can't replay" failure toast apply.
|
||||
*/
|
||||
export function EditReplayModal({
|
||||
opened,
|
||||
method,
|
||||
params,
|
||||
droppedParamKeys,
|
||||
tools,
|
||||
onSend,
|
||||
onClose,
|
||||
}: EditReplayModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Edit and replay" size="lg">
|
||||
<EditReplayForm
|
||||
method={method}
|
||||
params={params}
|
||||
droppedParamKeys={droppedParamKeys}
|
||||
tools={tools}
|
||||
onSend={onSend}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue