import { DndContext, DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core' import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { acceptUntrustedSql } from '@supabase/pg-meta' import { useQueryClient } from '@tanstack/react-query' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { Check, FileText, Keyboard, Loader2, MoreVertical, Notebook, NotebookText, Play, Save, SearchX, SquareCode, Trash, } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useEffectEvent, useRef, useState } from 'react' import { toast } from 'sonner' import { AiIconAnimation, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { findQueryCellsMatchingSql, isMutatingSql, type QueryCellSummary, } from './ExplorerNotebookTab.utils' import { ExplorerToolbar, ExplorerToolbarAction, ExplorerToolbarActions, ExplorerToolbarIcon, ExplorerToolbarTitle, } from './ExplorerToolbar' import { useCreateChat, useLoadNotebook } from './hooks' import { MarkdownCell } from './MarkdownCell' import { QueryCell } from './QueryCell' import { type QueryEditorHandle } from './QueryEditor' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { checkDestructiveQuery } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' import { evictNotebookFromCaches, hasDiscardableChanges, } from '@/data/content/notebooks/notebook-cache' import { isQueryCell, WritableCell, WritableNotebook, } from '@/data/content/notebooks/notebook-schema' import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation' import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { getNotebooksStateSnapshot, useCurrentNotebook, useNotebooksStateSnapshot, } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' export const ExplorerNotebookTab = () => { const router = useRouter() const { id, ref } = useParams() const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() const queryClient = useQueryClient() const { createChat, isCreating } = useCreateChat() const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE, true ) const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} const { isNotFound } = useLoadNotebook({ id, projectRef: ref }) const cells = content?.cells ?? [] const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id) const [isRunningNotebook, setIsRunningNotebook] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false) const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false) const [pendingQueryMatches, setPendingQueryMatches] = useState<{ destructiveQueries: QueryCellSummary[] mutatingQueries: QueryCellSummary[] } | null>(null) const [skipMutatingCells, setSkipMutatingCells] = useState(false) const queryCellRefs = useRef(new Map()) const savedContentRef = useRef(undefined) const scrollContainerRef = useRef(null) const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({ onSuccess: () => { if (id && content === savedContentRef.current) { snap.markSaved({ id }) toast.success('Successfully saved notebook!') if (isSaveBeforeAnalyzeOpen) { setIsSaveBeforeAnalyzeOpen(false) handleAnalyze() } } }, }) const { mutate: deleteNotebook, isPending: isDeleting } = useContentDeleteMutation({ onSuccess: () => { toast.success('Successfully deleted notebook') if (id) { tabs.removeTab(createTabId('notebook', { id })) snap.removeNotebook({ id }) } setIsDeleteModalOpen(false) router.push(`/project/${ref}/explorer`) }, onError: (error) => toast.error(`Failed to delete notebook: ${error.message}`), }) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) const persistNotebookTab = () => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return tabs.makeTabPermanent(createTabId('notebook', { id: notebookId })) } const handleSaveTitle = (titleValue: string) => { persistNotebookTab() const trimmedName = titleValue.trim() if (id && trimmedName && trimmedName !== name) { snap.renameNotebook({ id, name: trimmedName }) tabs.updateTab(createTabId('notebook', { id }), { label: trimmedName }) } } const runNotebook = async ({ cellIdsToRun, force = false, }: { cellIdsToRun: string[] force?: boolean }) => { persistNotebookTab() setIsRunningNotebook(true) try { await Promise.allSettled( cellIdsToRun.map((cellId) => queryCellRefs.current.get(cellId)?.run(force)) ) } finally { setIsRunningNotebook(false) } } const getFreshCells = () => { const freshNotebook = id ? getNotebooksStateSnapshot().notebooks[id] : undefined if (!freshNotebook || freshNotebook.projectRef !== ref) return cells return freshNotebook.notebook.content?.cells ?? [] } const handleRunNotebook = () => { const freshCells = getFreshCells() const { destructiveQueries, mutatingQueries } = findQueryCellsMatchingSql({ cells: freshCells, getLiveSql: (cellId) => queryCellRefs.current.get(cellId)?.getSql(), matchers: { destructiveQueries: checkDestructiveQuery, mutatingQueries: isMutatingSql, }, }) if (mutatingQueries.length === 0) { runNotebook({ cellIdsToRun: freshCells.filter(isQueryCell).map((cell) => cell._id) }) } else { setSkipMutatingCells(false) setPendingQueryMatches({ destructiveQueries, mutatingQueries }) } } const handleConfirmRunNotebook = () => { const mutatingCellIds = new Set( (pendingQueryMatches?.mutatingQueries ?? []).map((cell) => cell.id) ) const freshCells = getFreshCells() const freshQueryCellIds = freshCells.filter(isQueryCell).map((cell) => cell._id) const cellIdsToRun = skipMutatingCells ? freshQueryCellIds.filter((id) => !mutatingCellIds.has(id)) : freshQueryCellIds setPendingQueryMatches(null) runNotebook({ cellIdsToRun, force: true }) } const persistNotebook = () => { const notebookId = currentNotebook?.notebook.id if (!ref || !notebookId || !name || !content) return persistNotebookTab() const writableContent: WritableNotebook = { schema_version: content.schema_version, cells: content.cells.map((cell): WritableCell => { switch (cell._tag) { case 'markdown_cell': return cell case 'database_cell': { const { unchecked_sql, chart, ...rest } = cell return { ...rest, chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined, sql: acceptUntrustedSql(unchecked_sql), } } case 'log_cell': { const { unchecked_sql, chart, ...rest } = cell return { ...rest, chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined, sql: acceptUntrustedLogsSql(unchecked_sql), } } } }), } if (snap.serverDivergedWhileDirty.get(notebookId) === 'deleted') { writableContent.cells = writableContent.cells.map(({ _id: _, ...cell }) => cell) } // [Joshen] For tracking if a notebook is updated while being saved, so that we do not // incorrectly show the saved toast if it's subsequently then saved once again while // the initial save is midflight savedContentRef.current = content updateNotebook({ projectRef: ref, id: notebookId, name, description: currentNotebook?.notebook.description, content: writableContent, }) } const handleSaveNotebook = () => { const notebookId = currentNotebook?.notebook.id if (notebookId && snap.serverDivergedWhileDirty.get(notebookId)) { setIsSaveConflictOpen(true) return } persistNotebook() } const handleSaveAnyway = () => { setIsSaveConflictOpen(false) persistNotebook() } const handleDiscardNotebookChanges = async () => { if (!ref || !id) return const wasDeletedOnServer = snap.serverDivergedWhileDirty.get(id) === 'deleted' setIsSaveConflictOpen(false) const evicted = await evictNotebookFromCaches({ queryClient, projectRef: ref, id }) if (wasDeletedOnServer && evicted) { tabs.removeTab(createTabId('notebook', { id })) } } const handleAnalyze = () => { createChat({ name: `Analyze ${name} notebook`, initialMessage: `Run the notebook "${name}" (id: ${id}) and analyze the results. Summarize the key findings per cell, calling out anomalies or trends, and use any markdown cells for context. Skip or flag any cell that would mutate data rather than running it.`, }) } const handleClickAnalyze = () => { if (hasDiscardableChanges(currentNotebook)) { setIsSaveBeforeAnalyzeOpen(true) } else { handleAnalyze() } } const handleConfirmDeleteNotebook = () => { if (!ref || !id) return deleteNotebook({ projectRef: ref, ids: [id] }) } const handleDragEnd = (event: DragEndEvent) => { persistNotebookTab() const { active, over } = event if (!id || !over || active.id === over.id) return snap.reorderCells({ id, activeCellId: active.id, overCellId: over.id }) } const onSelectAddCell = (type: 'markdown' | 'query') => { persistNotebookTab() const notebookId = currentNotebook?.notebook.id if (!notebookId) return const cell = type === 'markdown' ? createMarkdownCellSkeleton() : createQueryCellSkeleton() const lastCellId = cells[cells.length - 1]?._id snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell }) } const scrollToBottomIfPending = useEffectEvent(() => { if (!id || snap.pendingScrollToBottom !== id || !scrollContainerRef.current) return scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, }) snap.clearPendingScrollToBottom() }) useEffect(() => scrollToBottomIfPending(), [id, snap.pendingScrollToBottom, content]) if (isNotFound) { return (
} title="Notebook not found" description="This notebook may have been deleted or does not exist." contentClassName="[&>h3]:text-sm [&>p]:text-xs" />
) } if (!content) { return (
) } return (
{name ?? ''} } loading={isCreating} disabled={cells.length === 0} tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined} onClick={handleClickAnalyze} > Analyze } tooltip="Save changes" loading={isUpdating} onClick={handleSaveNotebook} /> } /> setIsIntellisenseEnabled(!isIntellisenseEnabled)} >
Intellisense enabled
{isIntellisenseEnabled && }
setIsDeleteModalOpen(true)}> Delete notebook
} tooltip="Run notebook" loading={isRunningNotebook} disabled={queryCellIds.length === 0} onClick={handleRunNotebook} > Run
{cells.length === 0 && ( } title="This notebook is empty" description="Add a query cell to run SQL against your database or logs." contentClassName="[&>h3]:text-sm [&>p]:text-xs" >
)} {cells.length > 0 && ( <> cell._id)} strategy={verticalListSortingStrategy} >
{cells.map((cell) => isQueryCell(cell) ? ( queryCellRefs.current.get(cell._id)?.prettify()} ref={(instance) => { if (instance) queryCellRefs.current.set(cell._id, instance) else queryCellRefs.current.delete(cell._id) }} /> ) : ( ) )}
} className="w-7" onClick={() => onSelectAddCell('query')} tooltip={{ content: { side: 'bottom', text: 'Add query cell' } }} /> } className="w-7" onClick={() => onSelectAddCell('markdown')} tooltip={{ content: { side: 'bottom', text: 'Add markdown cell' } }} />
)}
setIsDeleteModalOpen(false)} onConfirm={handleConfirmDeleteNotebook} >

This action cannot be undone. Are you sure you want to delete '{name}'?

setIsSaveBeforeAnalyzeOpen(false)} onConfirm={handleSaveNotebook} >

This notebook has unsaved changes. Save it first so the assistant analyzes the latest content.

setIsSaveConflictOpen(false)} onConfirm={handleSaveAnyway} >

{id && snap.serverDivergedWhileDirty.get(id) === 'deleted' ? 'An assistant deleted this notebook after your local changes. Saving will recreate it.' : "An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update."}

setPendingQueryMatches(null)} onConfirm={handleConfirmRunNotebook} >

This notebook has {pendingQueryMatches?.mutatingQueries.length ?? 0}{' '} {pendingQueryMatches?.mutatingQueries.length === 1 ? 'query' : 'queries'} that{' '} {pendingQueryMatches?.mutatingQueries.length === 1 ? 'modifies' : 'modify'} data or schema and cannot be undone once run:

    {pendingQueryMatches?.mutatingQueries.map((cell) => (
  • {cell.title} {pendingQueryMatches.destructiveQueries.some(({ id }) => id === cell.id) && ( Destructive )}
  • ))}
setSkipMutatingCells(!!value)} />
) }