Files
supabase/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreviewCell.tsx
T
Charis 071a07118a feat(studio): add NotebookPreview diff component (#49112)
## Summary

Stacked on #49109 (PR 1 — `deriveNotebookDiff`). This is PR 3 of the
notebook approval-preview stack: a pure presentational component that
renders the cell-level diff for a proposed notebook create/update, for
use in the assistant approval UI (wired in a later PR).

- `NotebookPreview` — header summary (`"6 cells"` for create, `"+2 −1 ~1
↕1"` for update) + entry list + "Show N more cells" for long notebooks.
- `NotebookPreviewCell` — dispatches per entry tag:
`unchanged`/`removed`/`moved` collapse to a muted badge row; `added`
renders source via `CodeBlock` (with a max-height/expand toggle);
`replaced` renders a `DiffEditor` diff, plus a before → after metadata
line when only `database_identifier`/`time_range` changed (SQL/text
identical).
- `NotebookPreview.utils` — pure helpers (labels, source/metadata
extraction, language mapping, summary formatting), unit tested.
- **Safety property**: cell content only ever renders through
`CodeBlock`/`DiffEditor` (literal source), never through a markdown
renderer — agent-authored text can't trigger image loads or link
navigation before the user approves. Covered by an adversarial test
(`![x](evil)`, `[y](evil)`, `<img onerror>` → zero
`img`/`[href]`/`[src]` DOM nodes).
- Adds `'markdown'` as a supported `CodeBlock` language (small, additive
change to `packages/ui-patterns`).

Towards FE-4143

## Test plan

- [x] `pnpm --filter studio test` — NotebookPreview suite (21 tests)
passes
- [x] `pnpm --filter studio exec eslint
components/interfaces/Explorer/NotebookPreview` — clean
- [x] `pnpm --filter studio exec tsc --noEmit` — no new errors
- [x] `pnpm exec prettier --check` — clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added notebook previews showing create and update summaries.
* Displayed added, removed, moved, unchanged, and replaced cells with
metadata and source diffs.
* Added expandable previews with truncation and a “Show more cells”
option.
  * Added Markdown syntax highlighting to code blocks.

* **Bug Fixes**
* Safely render adversarial agent-authored Markdown as literal content.

* **Tests**
* Added comprehensive coverage for notebook previews, summaries,
metadata, formatting, and truncation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-18 11:44:23 -04:00

155 lines
4.9 KiB
TypeScript

import { useState, type ReactNode } from 'react'
import { Badge, Button, cn } from 'ui'
import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
import {
getCellCodeBlockLanguage,
getCellLabel,
getCellMetadataLine,
getCellMonacoLanguage,
getCellSourceText,
} from './NotebookPreview.utils'
import { DiffEditor } from '@/components/ui/DiffEditor'
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema'
export interface NotebookPreviewCellProps {
entry: NotebookCellDiffEntry
}
/** Renders a single diff entry, dispatching on its tag. */
export const NotebookPreviewCell = ({ entry }: NotebookPreviewCellProps) => {
switch (entry._tag) {
case 'unchanged':
return <CollapsedRow label={getCellLabel(entry.cell)} />
case 'removed':
return (
<CollapsedRow
label={getCellLabel(entry.cell)}
strikethrough
badge={{ variant: 'destructive', label: 'Removed' }}
/>
)
case 'moved':
return (
<CollapsedRow
label={getCellLabel(entry.cell)}
badge={{ variant: 'secondary', label: 'Moved' }}
/>
)
case 'added':
return <AddedCell cell={entry.cell} />
case 'replaced':
return <ReplacedCell before={entry.before} after={entry.after} />
}
}
interface CollapsedRowProps {
label: string
strikethrough?: boolean
badge?: { variant: 'destructive' | 'secondary'; label: string }
}
/** A single muted row with no content — used for unchanged, removed, and moved entries. */
const CollapsedRow = ({ label, strikethrough, badge }: CollapsedRowProps) => (
<div className="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground-light">
{badge && <Badge variant={badge.variant}>{badge.label}</Badge>}
<span className={cn('truncate', strikethrough && 'line-through text-foreground-lighter')}>
{label}
</span>
</div>
)
interface ContentCellProps {
badge: { variant: 'success' | 'warning'; label: string }
label: string
children: ReactNode
}
/** Shared frame for entries that show full cell content — added and replaced cells. */
const ContentCell = ({ badge, label, children }: ContentCellProps) => (
<div className="flex flex-col gap-2 px-3 py-2 border rounded-md bg-surface-75">
<div className="flex items-center gap-2 text-sm">
<Badge variant={badge.variant}>{badge.label}</Badge>
<span className="text-foreground truncate">{label}</span>
</div>
{children}
</div>
)
const AddedCell = ({ cell }: { cell: AgentCell }) => (
<ContentCell badge={{ variant: 'success', label: 'Added' }} label={getCellLabel(cell)}>
<ExpandableCodeBlock
language={getCellCodeBlockLanguage(cell)}
value={getCellSourceText(cell)}
/>
<MetadataLine text={getCellMetadataLine(cell)} />
</ContentCell>
)
/**
* A `replace_cell` can change only the source parameters (`database_identifier`,
* `time_range`) and leave `sql`/`text` identical — the `DiffEditor` above would then show no
* change at all, so the metadata is compared independently and rendered as its own
* before → after line whenever it differs.
*/
const ReplacedCell = ({ before, after }: { before: CellWire; after: AgentCell }) => {
const beforeMetadata = getCellMetadataLine(before)
const afterMetadata = getCellMetadataLine(after)
return (
<ContentCell badge={{ variant: 'warning', label: 'Replaced' }} label={getCellLabel(after)}>
<DiffEditor
original={getCellSourceText(before)}
modified={getCellSourceText(after)}
language={getCellMonacoLanguage(after)}
height={240}
/>
{beforeMetadata !== afterMetadata ? (
<MetadataLine
text={`${beforeMetadata ?? 'No metadata'} → ${afterMetadata ?? 'No metadata'}`}
/>
) : (
<MetadataLine text={afterMetadata} />
)}
</ContentCell>
)
}
/** A plain-text metadata line for a query cell — never rendered as a link or attribute. */
const MetadataLine = ({ text }: { text: string | null }) =>
text ? <p className="text-xs text-foreground-lighter">{text}</p> : null
interface ExpandableCodeBlockProps {
language: CodeBlockLang
value: string
}
/**
* `CodeBlock` clipped to a fixed height with a "Show more/less" toggle. `CodeBlock`'s
* wrapper already scrolls (`overflow-auto`), so clipping just changes what's visible.
*/
const ExpandableCodeBlock = ({ language, value }: ExpandableCodeBlockProps) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div>
<CodeBlock
language={language}
value={value}
hideLineNumbers
className="text-xs"
wrapperClassName={cn(!isExpanded && 'max-h-56')}
/>
<Button
variant="text"
size="tiny"
className="mt-1"
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? 'Show less' : 'Show more'}
</Button>
</div>
)
}