Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreviewCell.tsx
Joshen Lim cdbe2963fa Add DownloadResultsButton to explorer query editor (#50563)
## Context

Adds the `DownloadResultsButton` component to the footer of the
explorer's query editor - will show up in notebook + query tab

<img width="1147" height="907" alt="image"
src="https://github.com/user-attachments/assets/141278a8-1e00-424e-84ec-8ddb7cf0a96b"
/>
<img width="1152" height="913" alt="image"
src="https://github.com/user-attachments/assets/0e4a48b0-ebc3-473e-8bc0-19ab633e1904"
/>


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

- **New Features**
- Added a results footer displaying row counts and optional row limits.
  - Added download and export actions when query results are available.
  - Standardized the results footer across query and notebook previews.
- Added keyboard shortcut hints to export options when shortcuts are
enabled.

- **Improvements**
- Export actions now support read-only result sets without changing
displayed output.
- Export menu sizing and shortcut labels adapt to the enabled shortcut
configuration.
  - Improved accessibility with a label for refreshing logs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-18 17:13:44 +08:00

210 lines
7.1 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 { ExplorerQueryResults } from '@/components/interfaces/Explorer/ExplorerQuery'
import { QueryResultFooter } from '@/components/interfaces/Explorer/QueryEditor/QueryResultFooter'
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>
<QueryResultFooter
results={result.rows}
count={rowCount}
rowLimit={rowLimit}
fileName={cell.title}
/>
</>
)
}
/**
* 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"
/>
)