Files
supabase/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.ts
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

142 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dayjs from 'dayjs'
import type { CodeBlockLang } from 'ui-patterns/CodeBlock'
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
import type { AgentCell, CellWire, TimeRange } from '@/data/content/notebooks/notebook-schema'
/** React key for a diff entry. Added/replaced cells have no `id`, so they key off the operation. */
export function getEntryKey(entry: NotebookCellDiffEntry): string {
switch (entry._tag) {
case 'unchanged':
case 'removed':
case 'moved':
return entry.cell.id
case 'added':
case 'replaced':
return `op-${entry.operationIndex}`
}
}
/** Human label for a collapsed/badge row. */
export function getCellLabel(cell: CellWire | AgentCell): string {
switch (cell._tag) {
case 'markdown_cell':
return 'Markdown cell'
case 'database_cell':
case 'log_cell':
return `Query: ${cell.title ?? 'Untitled query'}`
}
}
/** The cell's underlying source text, regardless of backend. */
export function getCellSourceText(cell: CellWire | AgentCell): string {
switch (cell._tag) {
case 'markdown_cell':
return cell.text
case 'database_cell':
case 'log_cell':
return cell.sql
}
}
/** Language for rendering the cell's source via `CodeBlock`. */
export function getCellCodeBlockLanguage(cell: CellWire | AgentCell): CodeBlockLang {
switch (cell._tag) {
case 'markdown_cell':
return 'markdown'
case 'database_cell':
case 'log_cell':
return 'sql'
}
}
/** Monaco language id for rendering the cell's source via `DiffEditor`. */
export function getCellMonacoLanguage(cell: CellWire | AgentCell): string {
switch (cell._tag) {
case 'markdown_cell':
return 'markdown'
case 'database_cell':
case 'log_cell':
return 'pgsql'
}
}
/** Formats a `TimeRange` as plain text, e.g. "Last 7 days" or an absolute bound pair. */
export function formatTimeRange(range: TimeRange): string {
if (range._tag === 'relative_time_range') {
return `Last ${range.amount} ${range.unit}${range.amount === 1 ? '' : 's'}`
}
const format = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A')
return `${format(range.start)} → ${format(range.end)}`
}
/**
* Plain-text metadata line for a query cell (its source parameters, not its SQL) — `null`
* when the cell has none. A `replace_cell` can change only this and leave `sql` identical,
* so it's compared independently of the source text rather than folded into it.
*/
export function getCellMetadataLine(cell: CellWire | AgentCell): string | null {
switch (cell._tag) {
case 'markdown_cell':
return null
case 'database_cell':
return cell.database_identifier ? `Database: ${cell.database_identifier}` : null
case 'log_cell':
return `Time range: ${formatTimeRange(cell.time_range)}`
}
}
export type NotebookDiffSummary =
| { mode: 'create'; cellCount: number }
| { mode: 'update'; counts: { added: number; removed: number; replaced: number; moved: number } }
/** Summarizes a set of diff entries into counts suitable for a header line. */
export function summarizeNotebookDiff(
entries: NotebookCellDiffEntry[],
mode: 'create' | 'update'
): NotebookDiffSummary {
if (mode === 'create') {
return { mode: 'create', cellCount: entries.length }
}
const counts = { added: 0, removed: 0, replaced: 0, moved: 0 }
for (const entry of entries) {
switch (entry._tag) {
case 'added':
counts.added++
break
case 'removed':
counts.removed++
break
case 'replaced':
counts.replaced++
break
case 'moved':
counts.moved++
break
case 'unchanged':
break
}
}
return { mode: 'update', counts }
}
/** Formats a `NotebookDiffSummary` into the header string. */
export function formatNotebookDiffSummary(summary: NotebookDiffSummary): string {
if (summary.mode === 'create') {
const { cellCount } = summary
return `${cellCount} cell${cellCount === 1 ? '' : 's'}`
}
const { added, removed, replaced, moved } = summary.counts
const parts: string[] = []
if (added > 0) parts.push(`+${added}`)
if (removed > 0) parts.push(`−${removed}`)
if (replaced > 0) parts.push(`~${replaced}`)
if (moved > 0) parts.push(`↕${moved}`)
return parts.length > 0 ? parts.join(' ') : 'No changes'
}