Files
supabase/apps/studio/state/notebooks/notebooks-state.ts
Joshen Lim 2f89014f74 Add logs cells (#49064)
## Context

Related to Explorer/Notebooks - adds the source selector for query cell
within a notebook

<img width="1108" height="648" alt="image"
src="https://github.com/user-attachments/assets/d1434197-3738-41b0-a8ef-1919c91181d9"
/>

<img width="1103" height="633" alt="image"
src="https://github.com/user-attachments/assets/97f8c6a3-629d-4a41-aab4-3fdc5e8b2c7e"
/>


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

* **New Features**
  * Added support for displaying log cells in the query editor.
  * Added switching between database and log query sources.
  * Log and database cells can display results as tables or charts.
  * Log queries support optional row limits.
  * Improved reliability when changing query settings.
  * Notebook query views now default to table display when unspecified.

* **Bug Fixes**
  * Log cells no longer appear blank or get omitted from notebook views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 02:23:06 +07:00

237 lines
7.9 KiB
TypeScript

import { type UniqueIdentifier } from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { useParams } from 'common'
import { useMemo } from 'react'
import { proxy, snapshot, useSnapshot, type Snapshot } from 'valtio'
import { proxyMap } from 'valtio/utils'
import type { Notebook, StateNotebook } from './types'
import type { SnippetStatus } from '@/data/content/snippet-status'
import type { Notebooks } from '@/types'
// [Joshen] Deliberately copied from sql-editor-lifecycle cause we might deprecate
// that in favor of notebooks in the long run
function statusOnEdit(status: SnippetStatus): SnippetStatus {
return status === 'saved' ? 'unsaved' : status
}
export const notebooksState = proxy({
notebooks: {} as Record<string, StateNotebook>,
needsSaving: proxyMap<string, boolean>([]),
/**
* Load notebook into the Valtio store. No-ops if already present.
*/
addNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
if (notebooksState.notebooks[notebook.id]) return
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'new' }
},
/**
* Load notebook content into the store. Notebooks fetched from the list
* endpoint don't have `content` loaded (to keep that response small), so
* content is fetched separately and merged in here on demand.
*
* Unlike `addNotebook` (for locally-created notebooks, status 'new'), a
* notebook reaching this function was already persisted, so it's inserted
* with status 'saved'.
*/
setNotebook: ({ projectRef, notebook }: { projectRef: string; notebook: Notebook }) => {
const stateNotebook = notebooksState.notebooks[notebook.id]
if (stateNotebook) {
if (!stateNotebook.notebook.content) {
stateNotebook.notebook.content = notebook.content
}
} else {
notebooksState.notebooks[notebook.id] = { projectRef, notebook, status: 'saved' }
}
},
/**
* Rename follows its own async save directly at the call site rather than going
* through needsSaving/the debounced scheduler.
*/
renameNotebook: ({ id, name }: { id: string; name: string }) => {
const stateNotebook = notebooksState.notebooks[id]
if (stateNotebook) {
stateNotebook.notebook.name = name
}
},
/**
* Remove notebook from the store, and optionally remove it from the sync
* saving queue. Also clears any cached query-cell results for this notebook
* from the ephemeral session store.
*/
removeNotebook: ({ id, skipSave = false }: { id: string; skipSave?: boolean }) => {
const { [id]: notebook, ...otherNotebooks } = notebooksState.notebooks
notebooksState.notebooks = otherNotebooks
if (!skipSave) notebooksState.needsSaving.delete(id)
// TODO: clear notebookSessionState once it exists
},
/**
* Replace a notebook's full cell array and queue it for sync saving. The
* single entry point for every cell-level change — adding, removing,
* reordering, or editing a cell's content all compute the next `cells` array
* at the call site and pass it here, since the notebook is saved as one JSON
* document rather than per-cell.
*/
updateCells: ({
id,
cells,
skipSave,
}: {
id: string
cells: Snapshot<Notebooks.Cell>[]
skipSave?: boolean
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
stateNotebook.notebook.content.cells = cells as Notebooks.Cell[]
stateNotebook.status = statusOnEdit(stateNotebook.status)
if (!skipSave) notebooksState.needsSaving.set(id, false)
},
/**
* Insert a cell right after `cellId` in a notebook's cell array — or at the
* end, if `cellId` is omitted or isn't found (e.g. an empty notebook has no
* cell to insert after). The caller builds the cell to insert (e.g. via
* `createMarkdownCellSkeleton`/`createQueryCellSkeleton`) since deciding
* what kind of cell to create is a UI concern, not a state one.
*/
insertCellAfter: ({
id,
cellId,
cell,
}: {
id: string
cellId?: string
cell: Notebooks.Cell
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const insertAt = cellId ? cells.findIndex((c) => c.id === cellId) : -1
const nextCells = [...cells]
nextCells.splice(insertAt === -1 ? cells.length : insertAt + 1, 0, cell)
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Update a single cell in a notebook's cell array via an updater callback.
* The caller decides how the cell's content should change (e.g. field
* defaults, tag conversion) since that's a UI concern, not a state one —
* this only finds the cell by id and re-saves the array.
*/
updateCell: ({
id,
cellId,
updater,
}: {
id: string
cellId: string
updater: (cell: Notebooks.Cell) => Notebooks.Cell
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.map((cell) =>
cell.id === cellId ? updater(cell) : cell
)
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Remove a single cell from a notebook's cell array.
*/
removeCell: ({ id, cellId }: { id: string; cellId: string }) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const nextCells = stateNotebook.notebook.content.cells.filter((c) => c.id !== cellId)
notebooksState.updateCells({ id, cells: nextCells })
},
/**
* Shift a cell one position up or down in a notebook's cell array. No-ops if
* the cell is already at that boundary (or isn't found).
*/
moveCell: ({
id,
cellId,
direction,
}: {
id: string
cellId: string
direction: 'up' | 'down'
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const currentIndex = cells.findIndex((c) => c.id === cellId)
if (currentIndex === -1) return
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (nextIndex < 0 || nextIndex >= cells.length) return
notebooksState.updateCells({ id, cells: arrayMove([...cells], currentIndex, nextIndex) })
},
/**
* Reorder a notebook's cell array by moving the cell at `activeCellId` to
* where `overCellId` currently sits (dnd-kit's drag-end positions).
*/
reorderCells: ({
id,
activeCellId,
overCellId,
}: {
id: string
activeCellId: UniqueIdentifier
overCellId: UniqueIdentifier
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
const cells = stateNotebook.notebook.content.cells
const oldIndex = cells.findIndex((c) => c.id === activeCellId)
const newIndex = cells.findIndex((c) => c.id === overCellId)
if (oldIndex === -1 || newIndex === -1) return
notebooksState.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) })
},
addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true),
})
export const getNotebooksStateSnapshot = () => snapshot(notebooksState)
export const useNotebooksStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(notebooksState, options)
export const useNotebooks = (projectRef: string) => {
const snapshot = useNotebooksStateSnapshot()
return useMemo(
() =>
Object.values(snapshot.notebooks)
.filter((x) => x.projectRef === projectRef)
.map((x) => x.notebook),
[projectRef, snapshot.notebooks]
)
}
export const useCurrentNotebook = () => {
const { id, ref } = useParams()
const snapshot = useNotebooksStateSnapshot()
const currentNotebook = id ? snapshot.notebooks[id] : undefined
if (!currentNotebook || currentNotebook.projectRef !== ref) return undefined
return currentNotebook
}