mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## 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 (``, `[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 -->
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
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'
|
||
}
|