* feat(web): add View Source toggle to resource preview (closes #1552) Markdown, CSV, and HTML resources render in a way that hides the underlying text (react-markdown, the CSV table, the sandboxed HTML iframe). Add a "View Source" button to the resource preview footer, left of Refresh and styled to match it, that swaps the rendered preview for the raw resource text. In source mode the button reads "View Rendered". - Only shown for markdown/csv/html (other kinds already show their text, or have no meaningful text source). - Source mode forces ContentViewer through its plain-text renderer; the displayed MIME label keeps the resource's real type. - The toggle resets to the rendered view when the previewed resource changes (React's adjust-state-during-render pattern — no effect). Tests cover the toggle round-trip, per-kind visibility, and the reset; a Storybook play function exercises rendered → source → rendered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): address View Source review nits (#1553) - Per-item source gating: in source mode only source-toggleable items switch to plain text, so a mixed multi-part resource doesn't force an image/PDF blob through the text decoder. - Share a FooterButton constant between Refresh and the View Source toggle so the two stay visually identical (per the subcomponent guidance — both had the same two inline props). - Add aria-pressed to the toggle so assistive tech gets explicit on/off state beyond the label flip. Tests added for the aria-pressed state and the mixed multi-part case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
baf79b843f
commit
2a60ac5dce
3 changed files with 264 additions and 12 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
|
||||
|
||||
const meta: Meta<typeof ResourcePreviewPanel> = {
|
||||
|
|
@ -108,6 +108,44 @@ export const SubscriptionsUnsupported: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
// Markdown, CSV, and HTML resources expose a "View Source" link (left of
|
||||
// Refresh) that swaps the rendered preview for the raw resource text. The play
|
||||
// function drives the toggle: rendered heading → raw source → back.
|
||||
export const MarkdownWithViewSource: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "README.md",
|
||||
uri: "file:///README.md",
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
uri: "file:///README.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Project\n\nA **bold** intro with a [link](https://example.com).",
|
||||
},
|
||||
],
|
||||
isSubscribed: false,
|
||||
lastUpdated: new Date("2026-03-17T10:30:00Z"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Rendered by default.
|
||||
await expect(
|
||||
canvas.getByRole("heading", { level: 1, name: "Project" }),
|
||||
).toBeInTheDocument();
|
||||
// Switch to raw source.
|
||||
await userEvent.click(canvas.getByRole("button", { name: "View Source" }));
|
||||
await expect(canvas.getByText(/# Project/)).toBeInTheDocument();
|
||||
// Switch back to rendered.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "View Rendered" }),
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("heading", { level: 1, name: "Project" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAnnotations: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
|
|
|
|||
|
|
@ -306,6 +306,168 @@ describe("ResourcePreviewPanel", () => {
|
|||
expect(screen.getByText("# not a heading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("View Source toggle", () => {
|
||||
const markdownProps = {
|
||||
...baseProps,
|
||||
resource: { name: "readme", uri: "file:///readme.md" },
|
||||
contents: [
|
||||
{
|
||||
uri: "file:///readme.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Hello",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("toggles a markdown preview between rendered and raw source", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ResourcePreviewPanel {...markdownProps} />);
|
||||
|
||||
// Rendered by default: heading shown, no raw "# Hello" text.
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Hello" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
|
||||
// Source mode: raw markdown text shown, heading gone, label flipped.
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 1 }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View Rendered" }));
|
||||
|
||||
// Back to rendered.
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Hello" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects toggle state via aria-pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ResourcePreviewPanel {...markdownProps} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Rendered" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("only switches source-toggleable items in a mixed multi-part resource", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "mix", uri: "file:///mix.md" }}
|
||||
contents={[
|
||||
{ uri: "file:///mix.md", mimeType: "text/markdown", text: "# Hi" },
|
||||
{ uri: "file:///mix.png", mimeType: "image/png", blob: "abc" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// Toggle gated on the first (markdown) item.
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
// Markdown switches to raw text...
|
||||
expect(screen.getByText("# Hi")).toBeInTheDocument();
|
||||
// ...but the image is not forced through the text decoder — it still
|
||||
// renders as an image rather than garbled bytes or a binary notice.
|
||||
const img = screen
|
||||
.getAllByRole("img")
|
||||
.find((el) => el.getAttribute("src")?.startsWith("data:image/png"));
|
||||
expect(img).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers the toggle for CSV resources", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "data", uri: "file:///data.csv" }}
|
||||
contents={[{ uri: "file:///data.csv", text: "a,b\n1,2" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the toggle for HTML resources", () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "page", uri: "file:///page.html" }}
|
||||
contents={[{ uri: "file:///page.html", text: "<p>hi</p>" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hides the toggle for non-toggleable kinds (JSON, plain text, image)", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcePreviewPanel {...baseProps} />,
|
||||
);
|
||||
// JSON
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View Source" }),
|
||||
).not.toBeInTheDocument();
|
||||
// Plain text
|
||||
rerender(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "notes", uri: "file:///notes.txt" }}
|
||||
contents={[
|
||||
{ uri: "file:///notes.txt", mimeType: "text/plain", text: "hi" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View Source" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets to the rendered view when the resource changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcePreviewPanel {...markdownProps} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Rendered" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Switch to a different markdown resource — the toggle should reset.
|
||||
rerender(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "other", uri: "file:///other.md" }}
|
||||
contents={[
|
||||
{
|
||||
uri: "file:///other.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Two",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Two" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("URI-suffix MIME inference", () => {
|
||||
it("infers text/csv from a .csv URI and renders a table", () => {
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
Resource,
|
||||
|
|
@ -15,6 +16,7 @@ import type {
|
|||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton";
|
||||
|
||||
|
|
@ -44,6 +46,19 @@ function formatLastUpdated(date: Date): string {
|
|||
return `Last updated: ${date.toLocaleString()}`;
|
||||
}
|
||||
|
||||
// MIME kinds whose rendered preview (react-markdown, the CSV table, the
|
||||
// sandboxed HTML iframe) hides the underlying text. For these the panel offers
|
||||
// a "View Source" toggle that swaps the renderer for the raw resource text.
|
||||
const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]);
|
||||
|
||||
// MIME forced on ContentViewer in source mode so it routes through the plain
|
||||
// preformatted-text renderer regardless of the resource's real type.
|
||||
const SOURCE_MIME = "text/plain";
|
||||
|
||||
function isSourceToggleable(mimeType: string): boolean {
|
||||
return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType));
|
||||
}
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
|
|
@ -95,6 +110,14 @@ const ActionGroup = Group.withProps({
|
|||
gap: "xs",
|
||||
});
|
||||
|
||||
// Subtle footer action button. Shared by Refresh and the View Source toggle so
|
||||
// the two stay visually identical (the toggle is deliberately styled to match
|
||||
// Refresh, which sits immediately to its right).
|
||||
const FooterButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const Spacer = Flex.withProps({});
|
||||
|
||||
// The panel sizes to its content: when the resource body is short the
|
||||
|
|
@ -180,6 +203,17 @@ export function ResourcePreviewPanel({
|
|||
const { uri, annotations } = resource;
|
||||
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
|
||||
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
// Reset to the rendered view when the previewed resource changes (the panel
|
||||
// is reused, not remounted, across resources). React's documented
|
||||
// "adjust state during render" pattern — no effect, so no cascading render.
|
||||
const [prevUri, setPrevUri] = useState(uri);
|
||||
if (uri !== prevUri) {
|
||||
setPrevUri(uri);
|
||||
setShowSource(false);
|
||||
}
|
||||
const sourceToggleable = isSourceToggleable(mimeType);
|
||||
|
||||
return (
|
||||
<PanelStack>
|
||||
<HeaderRow>
|
||||
|
|
@ -196,14 +230,26 @@ export function ResourcePreviewPanel({
|
|||
</HeaderRow>
|
||||
<ContentScroll>
|
||||
<ContentStack>
|
||||
{contents.map((item, index) => (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
contents={item}
|
||||
mimeType={effectiveMime(item.mimeType, resource)}
|
||||
copyable
|
||||
/>
|
||||
))}
|
||||
{contents.map((item, index) => {
|
||||
const itemMime = effectiveMime(item.mimeType, resource);
|
||||
// The toggle is gated on the first content item but applies per
|
||||
// item: in source mode only the source-toggleable items (the ones
|
||||
// whose rendered view hides their text) switch to plain text, so a
|
||||
// mixed multi-part resource doesn't force an image/PDF blob through
|
||||
// the text decoder.
|
||||
const renderMime =
|
||||
showSource && isSourceToggleable(itemMime)
|
||||
? SOURCE_MIME
|
||||
: itemMime;
|
||||
return (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
contents={item}
|
||||
mimeType={renderMime}
|
||||
copyable
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContentStack>
|
||||
</ContentScroll>
|
||||
<MetaRow>
|
||||
|
|
@ -224,9 +270,15 @@ export function ResourcePreviewPanel({
|
|||
)}
|
||||
</AnnotationGroup>
|
||||
<ActionGroup>
|
||||
<Button variant="subtle" size="sm" onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
{sourceToggleable && (
|
||||
<FooterButton
|
||||
aria-pressed={showSource}
|
||||
onClick={() => setShowSource((shown) => !shown)}
|
||||
>
|
||||
{showSource ? "View Rendered" : "View Source"}
|
||||
</FooterButton>
|
||||
)}
|
||||
<FooterButton onClick={onRefresh}>Refresh</FooterButton>
|
||||
{subscriptionsSupported && (
|
||||
<SubscribeButton
|
||||
subscribed={isSubscribed}
|
||||
|
|
|
|||
Loading…
Reference in a new issue