Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx
Charis b366496c8c [FE-4246] fix(studio): resolve notebook database labels (#49554)
## I have read the
[CONTRIBUTING.md](<https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md>)
file.

YES

## What kind of change does this PR introduce?

Bug fix for Studio notebook previews.

## What is the current behavior?

Notebook diffs display the raw database_identifier value. This exposes
opaque database IDs and does not communicate whether the cell targets
the primary database or a read replica.

Related issue:
[https://linear.app/supabase/issue/FE-4246](<https://linear.app/supabase/issue/FE-4246>)

## What is the new behavior?

Notebook database metadata is resolved independently from existing
selector formatting:

* an omitted database identifier is Primary
* an identifier matching the project ref is Primary
* other identifiers show a loading state while databases load
* a loaded non-primary match is Replica
* an unmatched identifier is Unknown
* database lookup failures use a neutral unavailable state

Labels are compact: Database: Primary, Database: Replica, and Database:
Unknown.

The databases query is enabled only when a preview contains an explicit
non-primary identifier.

## Additional context

Validation:

* 39 focused notebook preview tests
* Studio typecheck
* targeted ESLint
* Prettier check
* git diff --check

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

## Summary by CodeRabbit

* **New Features**
* Notebook previews now identify database targets as primary, read
replica, or unknown.
  * Database metadata is resolved automatically when needed.
  * Loading states display a clear “Loading database…” indicator.
* Database metadata now handles unavailable, hidden, and error states
more clearly.
* Notebook entries reflect updated database information before and after
replacement.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-26 17:58:35 +08:00

217 lines
7.3 KiB
TypeScript
Raw Permalink 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 { ChevronRight, FileText, Loader2, SquareCode } from 'lucide-react'
import {
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import {
getCellCodeBlockLanguage,
getCellLabel,
getCellMonacoLanguage,
getCellSourceText,
getEntryMetadata,
type NotebookDatabaseContext,
} from './AssistantNotebookPreview.utils'
import {
ExplorerQueryFooter,
ExplorerQueryResults,
} from '@/components/interfaces/Explorer/ExplorerQuery'
import { QueryResultRenderer } from '@/components/interfaces/Explorer/QueryEditor/QueryResultRenderer'
import type { QueryResult } from '@/components/interfaces/Explorer/types'
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 AssistantNotebookPreviewCellProps {
entry: NotebookCellDiffEntry
isExpanded: boolean
onExpandedChange: (isExpanded: boolean) => void
/** Create and run previews omit per-row change glyphs. */
mode: 'create' | 'update' | 'run'
result?: QueryResult
databaseContext: NotebookDatabaseContext
}
/**
* A single-character gutter marker per change type The glyph is decorative —
* `changeLabel` carries the same information into the row's accessible name and its tooltip.
*/
const CHANGE_MARKERS = {
added: { glyph: '+', changeLabel: 'Added', className: 'text-brand-600' },
removed: { glyph: '', changeLabel: 'Removed', className: 'text-destructive' },
replaced: { glyph: '~', changeLabel: 'Replaced', className: 'text-warning-600' },
moved: { glyph: '↕', changeLabel: 'Moved', className: 'text-foreground-lighter' },
unchanged: { glyph: '', changeLabel: 'Unchanged', className: '' },
} as const
type ChangeMarker = (typeof CHANGE_MARKERS)[keyof typeof CHANGE_MARKERS]
/** The cell a row is labelled by — for a replacement, the proposed cell rather than the current one. */
function getEntryCell(entry: NotebookCellDiffEntry): CellWire | AgentCell {
return entry._tag === 'replaced' ? entry.after : entry.cell
}
/** One row of the diff card: a header line that collapses to a single row, plus its content. */
export const AssistantNotebookPreviewCell = ({
entry,
isExpanded,
onExpandedChange,
mode,
result,
databaseContext,
}: AssistantNotebookPreviewCellProps) => {
const marker = CHANGE_MARKERS[entry._tag]
const isRemoved = entry._tag === 'removed'
const cell = getEntryCell(entry)
const label = getCellLabel(cell)
const metadata = getEntryMetadata(entry, databaseContext)
return (
<Collapsible open={isExpanded} onOpenChange={onExpandedChange}>
<CollapsibleTrigger
aria-label={`${marker.changeLabel} ${label}`}
className="group flex w-full items-center gap-2 bg-muted px-3 py-2 text-left transition-colors hover:bg-accent"
>
{mode === 'update' && <ChangeGlyph marker={marker} />}
<ChevronRight
size={12}
className="shrink-0 text-foreground-lighter transition-transform group-data-[state=open]:rotate-90"
/>
<CellTypeIcon cell={cell} />
<span
className={cn(
'min-w-0 flex-1 truncate text-sm text-foreground',
isRemoved && 'text-foreground-lighter line-through'
)}
>
{label}
</span>
{metadata.status !== 'hidden' ? (
<span className="min-w-0 max-w-[45%] shrink truncate text-sm text-muted-foreground">
{metadata.status === 'loading' ? (
<span className="flex items-center gap-1.5">
<span className="animate-spin">
<Loader2 aria-hidden size={12} />
</span>
Loading database
</span>
) : (
metadata.text
)}
</span>
) : null}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
<CellBody entry={entry} />
</div>
</CollapsibleContent>
{result !== undefined && cell._tag !== 'markdown_cell' && (
<QueryCellResult cell={cell} result={result} />
)}
</Collapsible>
)
}
const QueryCellResult = ({
cell,
result,
}: {
cell: Extract<CellWire | AgentCell, { _tag: 'database_cell' | 'log_cell' }>
result: QueryResult
}) => {
const rowCount = result.rows?.length ?? 0
const rowLimit = cell._tag === 'database_cell' ? cell.row_limit : undefined
return (
<>
<ExplorerQueryResults
className={cn(
'border-t',
rowCount === 0 ? 'min-h-20 items-center justify-center' : 'h-56 overflow-x-auto'
)}
>
<QueryResultRenderer view={cell.view ?? 'table'} result={result} chart={cell.chart} />
</ExplorerQueryResults>
<ExplorerQueryFooter className="flex items-center gap-x-2">
<p>
{rowCount.toLocaleString()} {rowCount === 1 ? 'row' : 'rows'}
</p>
{rowLimit !== undefined && (
<>
<p>·</p>
<p>{rowLimit < 0 ? 'No row limit' : `Limit ${rowLimit} rows`}</p>
</>
)}
</ExplorerQueryFooter>
</>
)
}
/**
* The gutter glyph, with a tooltip naming the change type it stands for. The glyph stays
* `aria-hidden` — the row's `aria-label` already carries the same word — so the tooltip is a
* pointer affordance rather than a second announcement.
*/
const ChangeGlyph = ({ marker }: { marker: ChangeMarker }) => {
const className = cn('w-3 shrink-0 text-center font-mono text-xs leading-none', marker.className)
// An unchanged cell has no glyph, so there is nothing to explain — render the spacer alone.
if (!marker.glyph) return <span aria-hidden className={className} />
return (
<Tooltip>
<TooltipTrigger asChild>
<span aria-hidden className={className}>
{marker.glyph}
</span>
</TooltipTrigger>
<TooltipContent side="left">{marker.changeLabel}</TooltipContent>
</Tooltip>
)
}
/** Same icons the notebook tab uses for query vs markdown cells. */
const CellTypeIcon = ({ cell }: { cell: CellWire | AgentCell }) => {
const Icon = cell._tag === 'markdown_cell' ? FileText : SquareCode
return <Icon aria-hidden size={14} className="shrink-0 text-foreground-muted" />
}
const CellBody = ({ entry }: { entry: NotebookCellDiffEntry }) =>
entry._tag === 'replaced' ? (
<ReplacedCellBody before={entry.before} after={entry.after} />
) : (
<CellSource cell={entry.cell} />
)
const ReplacedCellBody = ({ before, after }: { before: CellWire; after: AgentCell }) => (
<DiffEditor
original={getCellSourceText(before)}
modified={getCellSourceText(after)}
language={getCellMonacoLanguage(after)}
height={240}
/>
)
/**
* The cell's source, always rendered as literal text via `CodeBlock` — agent-authored markdown
* must never be interpreted into real DOM nodes.
*/
const CellSource = ({ cell }: { cell: CellWire | AgentCell }) => (
<CodeBlock
hideCopy
hideLineNumbers
language={getCellCodeBlockLanguage(cell)}
value={getCellSourceText(cell)}
wrapLongLines
className="wrap-break-word rounded-none border-0 px-3! py-2! text-xs"
wrapperClassName="max-w-none"
/>
)