Files
supabase/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx
T
Joshen Lim 8ac64a4349 Add copy notebook as markdown action (#50200)
## Context

Adds a "Copy as Markdown" CTA for notebooks
<img width="265" height="198" alt="image"
src="https://github.com/user-attachments/assets/5eccf36e-24ea-4d2f-b1cf-72d5353b70a2"
/>

Query cell titles will be rendered as h3 tags and labelled either
Postgres or Logs - Clickhouse (with time range)
The query content will then be rendered as triple backticks with `sql`
e.g `
```sql...```
`

Query results will be copied to markdown if the query has been run, will otherwise be omitted
Also, if the query was updated (e.g content, source, etc) after it was run (as the result is hence stale), result will also be omitted
e.g:
| Notebook | Markdown |
| --- | --- |
| <img width="1291" height="630" alt="image" src="https://github.com/user-attachments/assets/c49f23e5-c70b-46dd-a298-6e1a90cd30d7" /> | <img width="731" height="536" alt="image" src="https://github.com/user-attachments/assets/4bbe3041-bd8e-42d3-86d2-1691e7a6bc7b" /> |
| <img width="1220" height="832" alt="image" src="https://github.com/user-attachments/assets/67de523a-18f7-4f1b-b764-7f0f3152f9e7" /> | <img width="757" height="803" alt="image" src="https://github.com/user-attachments/assets/d217504b-bee6-4088-9049-87ff647b9bc7" /> |



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

* **New Features**
  * Added Markdown export for notebook queries, results, errors, and time ranges.
  * Added error notifications when copying notebook content fails.

* **Bug Fixes**
  * Prevented stale query results after relevant source or time-range changes.
  * Improved Markdown export for queries containing backticks.
  * Escaped backslashes, pipes, and line breaks in Markdown tables.

* **Style**
  * Adjusted spacing for empty query-result messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-11 11:25:22 +08:00

152 lines
5.4 KiB
TypeScript

import { AlignLeft } from 'lucide-react'
import { forwardRef, useState } from 'react'
import { KeyboardShortcut } from 'ui'
import { type Snapshot } from 'valtio'
import { AddCellDropdown } from '../AddCellDropdown'
import { ExplorerToolbarAction } from '../ExplorerToolbar'
import { MoveCellDropdownContent } from '../MoveCellDropdownContent'
import { QueryEditor, type QueryEditorHandle } from '../QueryEditor'
import { type QueryDisplay, type QueryResult } from '../types'
import {
changeCellSource,
cloneChartConfig,
cloneQueryCell,
getCellDisplay,
setCellRowLimit,
setCellSql,
shouldInvalidateResultOnSourceChange,
toQueryModel,
} from './QueryCell.utils'
import { SortableSection } from '@/components/ui/SortableSection'
import {
isQueryCell,
type QueryCell as QueryCellSchema,
} from '@/data/content/notebooks/notebook-schema'
import { type QuerySourceBinding } from '@/data/query-sources/query-source-registry'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { useLocalRoleImpersonationState } from '@/state/role-impersonation-state'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
const PRETTIFY_SHORTCUT_KEYS = hotkeyToKeys(
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
)
interface QueryCellProps {
cell: Snapshot<QueryCellSchema>
onEdit?: () => void
onPrettifyQuery?: () => void
}
/** Notebook adapter around the shared QueryEditor. */
export const QueryCell = forwardRef<QueryEditorHandle, QueryCellProps>(function QueryCell(
{ cell, onEdit, onPrettifyQuery },
ref
) {
const snap = useNotebooksStateSnapshot()
const currentNotebook = useCurrentNotebook()
const [sql, setSql] = useState<string>(cell.unchecked_sql)
const [result, setResult] = useState<QueryResult>()
const roleImpersonationState = useLocalRoleImpersonationState()
const title = cell.title ?? 'Untitled query'
const showQuery =
snap.cellLocalState.get(cell._id)?.showQuery ?? currentNotebook?.status === 'new'
/**
* Applies an update to this cell. The updater runs against the cell as the store holds
* it rather than the snapshot this component rendered with, so a concurrent edit isn't
* clobbered; `isQueryCell` keeps the per-backend helpers off a markdown cell that
* somehow shares the id.
*/
const updateQueryCell = (updater: (candidate: Snapshot<QueryCellSchema>) => QueryCellSchema) => {
const notebookId = currentNotebook?.notebook.id
if (!notebookId) return
onEdit?.()
snap.updateCell({
id: notebookId,
cellId: cell._id,
updater: (candidate) => {
if (!isQueryCell(candidate)) return candidate
return updater(candidate)
},
})
}
const handleSourceChange = (source: QuerySourceBinding) => {
// The query text carries over (see `changeCellSource`), so the editor's buffer stays
// valid — but a result run against the old source (backend or time range) does not.
if (shouldInvalidateResultOnSourceChange(cell, source)) setResult(undefined)
updateQueryCell((candidate) => changeCellSource(candidate, source))
}
const handleTitleChange = (value: string) => {
const nextTitle = value.trim()
if (!nextTitle) return
updateQueryCell((candidate) => ({ ...cloneQueryCell(candidate), title: nextTitle }))
}
// Running a cell re-commits its current SQL (see QueryEditor's handleRunQuery) even when
// nothing changed — skip the store write so that doesn't spuriously mark the notebook
// unsaved.
const handleSqlCommit = (value: string) => {
if (value === cell.unchecked_sql) return
updateQueryCell((candidate) => setCellSql(candidate, value))
}
const handleDisplayChange = (display: QueryDisplay) =>
updateQueryCell((candidate) => ({
...cloneQueryCell(candidate),
view: display.view,
chart: cloneChartConfig(display.chart),
}))
const handleRowLimitChange = (rowLimit: number) =>
updateQueryCell((candidate) => setCellRowLimit(candidate, rowLimit))
return (
<SortableSection
id={cell._id}
actions={<AddCellDropdown cellId={cell._id} />}
gripDropdownContent={<MoveCellDropdownContent cellId={cell._id} />}
gripClassName="mt-2 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
>
<QueryEditor
ref={ref}
id={cell._id}
variant="embedded"
className="min-h-0"
title={title}
query={toQueryModel(cell, sql)}
result={result}
showQuery={showQuery}
onShowQueryChange={(showQuery) => snap.setQueryVisibility({ cellId: cell._id, showQuery })}
roleImpersonationState={roleImpersonationState}
display={getCellDisplay(cell)}
onTitleChange={handleTitleChange}
onSqlChange={setSql}
onSqlCommit={handleSqlCommit}
onSourceChange={handleSourceChange}
onResultChange={setResult}
onRowLimitChange={handleRowLimitChange}
onDisplayChange={handleDisplayChange}
toolbarActions={
<ExplorerToolbarAction
icon={<AlignLeft size={16} strokeWidth={2} />}
tooltip={
<div className="flex items-center gap-2.5">
<span>Prettify SQL</span>
<KeyboardShortcut keys={PRETTIFY_SHORTCUT_KEYS} />
</div>
}
onClick={onPrettifyQuery}
/>
}
/>
</SortableSection>
)
})