Initialize notebooks store (#48801)

## Context

More groundwork for the Explorer - this one's focused on initializing
the valtio store for managing notebooks
Store architecture will follow closely with the existing
sql-editor-store

No data persistence yet, but can test creating a new notebook
<img width="195" height="143" alt="image"
src="https://github.com/user-attachments/assets/8656fb5b-3a8e-4f71-b2ce-d2f34ca9b552"
/>

Which should open a placeholder page
<img width="1387" height="527" alt="image"
src="https://github.com/user-attachments/assets/4a81b1ae-a740-40e6-9d33-29fa4f83b541"
/>

Closing the notebook brings you back to the explorer home page

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

* **New Features**
* Added support for creating and opening project notebooks from the
Explorer.
  * Added notebook tabs alongside existing entity and SQL tabs.
* Added notebook management, including loading, renaming, removing,
editing cells, and tracking unsaved changes.
  * Added support for SQL, logs, and Markdown notebook cells.
  * Added dedicated notebook routes and an initial notebook editor view.
  * Added notebook icons throughout the Explorer interface.

* **Documentation**
* Documented session-scoped notebook state for query results and row
limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-07 11:21:38 +07:00
1 parent 91a394fc83
commit 3a383c87c7
16 files changed
+510 -16

No files matched your search

+6 -1
View File
@@ -156,7 +156,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] A `routes/project/$ref/index.tsx` ← `pages/project/[ref]/index.tsx` (route wraps in `ProjectLayoutWithAuth` itself — see shell delta above)
- [x] `routes/project/$ref/merge.tsx` ← `pages/project/[ref]/merge.tsx` (leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
- [x] A `routes/project/$ref/explorer.tsx` ← `pages/project/[ref]/explorer/index.tsx` (new placeholder page; leaf wraps body in `ProjectLayoutWithAuth`; parent `project/$ref.tsx` shell provides DefaultLayout)
- [x] `routes/project/$ref/explorer.tsx` — converted from a leaf into a shell (`ExplorerLayout` + `Outlet`) to host the new `/explorer/notebook/$id` leaf; parent `project/$ref.tsx` shell still provides DefaultLayout.
### Project shell — `/api/*`
@@ -322,6 +322,11 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] A `routes/project/$ref/editor/$id.tsx` ← `pages/project/[ref]/editor/[id].tsx`
- [x] A `routes/project/$ref/editor/new.tsx` ← `pages/project/[ref]/editor/new.tsx`
### Project shell — `/explorer/*`
- [x] A `routes/project/$ref/explorer/index.tsx` ← `pages/project/[ref]/explorer/index.tsx`
- [x] A `routes/project/$ref/explorer/notebook/$id.tsx` ← `pages/project/[ref]/explorer/notebook/[id].tsx`
### Auth shell — `/sign-in`, `/sign-up`, etc.
- [x] A `routes/_auth/sign-in.tsx` ← `pages/sign-in.tsx`
@@ -0,0 +1,26 @@
import { useParams } from 'common'
import { useEffect, useEffectEvent } from 'react'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
export const NotebookEditor = () => {
const { id } = useParams()
const tabs = useTabsStateSnapshot()
const snap = useNotebooksStateSnapshot()
const stateNotebook = id ? snap.notebooks[id] : undefined
const registerTab = useEffectEvent(() => {
if (!id) return
tabs.addTab({
id: createTabId('notebook', { id }),
type: 'notebook',
label: stateNotebook?.notebook.name ?? 'New Notebook',
metadata: { notebookId: id },
})
})
useEffect(() => registerTab(), [id])
return <div>This is a notebook</div>
}
@@ -0,0 +1,87 @@
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NotebookEditor } from '../NotebookEditor'
import { notebooksState } from '@/state/notebooks/notebooks-state'
import type { Notebook } from '@/state/notebooks/types'
const { mockUseParams, mockAddTab } = vi.hoisted(() => ({
mockUseParams: vi.fn(),
mockAddTab: vi.fn(),
}))
vi.mock('common', () => ({
useParams: () => mockUseParams(),
}))
vi.mock('@/state/tabs', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/state/tabs')>()
return {
...actual,
useTabsStateSnapshot: () => ({ addTab: mockAddTab }),
}
})
function makeNotebook(id: string, overrides: Partial<Notebook> = {}): Notebook {
return {
id,
type: 'notebook',
name: 'My Notebook',
description: '',
visibility: 'project',
favorite: false,
owner_id: 7,
project_id: 42,
content: { schema_version: '1.0', cells: [] },
...overrides,
}
}
describe('NotebookEditor tab registration', () => {
beforeEach(() => {
mockAddTab.mockClear()
mockUseParams.mockReturnValue({ ref: 'default', id: 'notebook-1' })
// notebooksState is a module-level singleton, so reset the state these tests touch
for (const id of Object.keys(notebooksState.notebooks)) {
delete notebooksState.notebooks[id]
}
notebooksState.needsSaving.clear()
})
it('registers a tab with the notebook id, type, loaded name, and metadata', () => {
notebooksState.setNotebook({
projectRef: 'default',
notebook: makeNotebook('notebook-1', { name: 'My Notebook' }),
})
render(<NotebookEditor />)
expect(mockAddTab).toHaveBeenCalledTimes(1)
expect(mockAddTab).toHaveBeenCalledWith({
id: 'notebook-notebook-1',
type: 'notebook',
label: 'My Notebook',
metadata: { notebookId: 'notebook-1' },
})
})
it('falls back to "New Notebook" as the label when the notebook has not loaded yet', () => {
render(<NotebookEditor />)
expect(mockAddTab).toHaveBeenCalledWith({
id: 'notebook-notebook-1',
type: 'notebook',
label: 'New Notebook',
metadata: { notebookId: 'notebook-1' },
})
})
it('does not register a tab when there is no id in the route', () => {
mockUseParams.mockReturnValue({ ref: 'default', id: undefined })
render(<NotebookEditor />)
expect(mockAddTab).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,43 @@
import { useRouter } from 'next/router'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { generateUuid } from '@/lib/api/snippets.browser'
import { useProfile } from '@/lib/profile'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { type Notebook } from '@/state/notebooks/types'
export const useCreateNotebook = () => {
const router = useRouter()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const notebooksSnap = useNotebooksStateSnapshot()
const createNotebook = ({ id: idOverride, name }: { id?: string; name?: string } = {}) => {
if (!profile) return console.error('Profile is required')
if (!project) return console.error('Project is required')
const id = idOverride ?? generateUuid()
const notebook: Notebook = {
id,
type: 'notebook',
name: name ?? 'New Notebook',
description: '',
visibility: 'project',
favorite: false,
content: {
schema_version: '1.0',
cells: [],
},
owner_id: profile.id,
project_id: project.id,
}
notebooksSnap.addNotebook({ projectRef: project.ref, notebook })
notebooksSnap.addNeedsSaving(notebook.id)
router.push(`/project/${project.ref}/explorer/notebook/${notebook.id}`)
}
return { createNotebook }
}
@@ -12,6 +12,7 @@ import { type ExplorerResourceType } from './ExplorerLayout.constants'
import { ExplorerNavChats } from './ExplorerNavChats'
import { ExplorerNavHome } from './ExplorerNavHome'
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks'
export interface ExplorerLayoutProps extends ComponentProps<typeof ProjectLayoutWithAuth> {
children: ReactNode
@@ -85,6 +86,8 @@ const HomeTabButton = () => {
}
const NewTabButton = () => {
const { createNotebook } = useCreateNotebook()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -103,7 +106,7 @@ const NewTabButton = () => {
</motion.button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuItem className="gap-x-2">
<DropdownMenuItem className="gap-x-2" onClick={() => createNotebook()}>
<NotebookText size={14} />
<span>New notebook</span>
</DropdownMenuItem>
+6 -2
View File
@@ -1,4 +1,4 @@
import { Eye, GitBranch, ScrollText, Table2 } from 'lucide-react'
import { Eye, GitBranch, NotebookText, ScrollText, Table2 } from 'lucide-react'
import { cn, SQL_ICON } from 'ui'
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
@@ -26,7 +26,7 @@ export const LogsSnippetIcon = ({
)
interface EntityTypeIconProps {
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p'
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook'
size?: number
strokeWidth?: number
isActive?: boolean
@@ -103,6 +103,10 @@ export const EntityTypeIcon = ({
)
}
if (type === 'notebook') {
return <NotebookText size={size} strokeWidth={strokeWidth} className={''} />
}
return (
<div
className={cn(
@@ -0,0 +1,16 @@
import { NotebookEditor } from '@/components/interfaces/Explorer/NotebookEditor'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
import type { NextPageWithLayout } from '@/types'
const NotebookPage: NextPageWithLayout = () => {
return <NotebookEditor />
}
NotebookPage.getLayout = (page) => (
<DefaultLayout>
<ExplorerLayout>{page}</ExplorerLayout>
</DefaultLayout>
)
export default NotebookPage
+56 -6
View File
@@ -89,6 +89,7 @@ import { Route as ProjectRefObservabilityIndexRouteImport } from './routes/proje
import { Route as ProjectRefLogsIndexRouteImport } from './routes/project/$ref/logs/index'
import { Route as ProjectRefIntegrationsIndexRouteImport } from './routes/project/$ref/integrations/index'
import { Route as ProjectRefFunctionsIndexRouteImport } from './routes/project/$ref/functions/index'
import { Route as ProjectRefExplorerIndexRouteImport } from './routes/project/$ref/explorer/index'
import { Route as ProjectRefEditorIndexRouteImport } from './routes/project/$ref/editor/index'
import { Route as ProjectRefBranchesIndexRouteImport } from './routes/project/$ref/branches/index'
import { Route as ProjectRefApiIndexRouteImport } from './routes/project/$ref/api/index'
@@ -228,6 +229,7 @@ import { Route as ProjectRefFunctionsFunctionSlugLogsRouteImport } from './route
import { Route as ProjectRefFunctionsFunctionSlugInvocationsRouteImport } from './routes/project/$ref/functions/$functionSlug/invocations'
import { Route as ProjectRefFunctionsFunctionSlugDetailsRouteImport } from './routes/project/$ref/functions/$functionSlug/details'
import { Route as ProjectRefFunctionsFunctionSlugCodeRouteImport } from './routes/project/$ref/functions/$functionSlug/code'
import { Route as ProjectRefExplorerNotebookIdRouteImport } from './routes/project/$ref/explorer/notebook/$id'
import { Route as ProjectRefDatabaseTriggersEventRouteImport } from './routes/project/$ref/database/triggers/event'
import { Route as ProjectRefDatabaseTriggersDataRouteImport } from './routes/project/$ref/database/triggers/data'
import { Route as ProjectRefDatabaseTablesIdRouteImport } from './routes/project/$ref/database/tables/$id'
@@ -728,6 +730,11 @@ const ProjectRefFunctionsIndexRoute =
path: '/',
getParentRoute: () => ProjectRefFunctionsRoute,
} as any)
const ProjectRefExplorerIndexRoute = ProjectRefExplorerIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectRefExplorerRoute,
} as any)
const ProjectRefEditorIndexRoute = ProjectRefEditorIndexRouteImport.update({
id: '/',
path: '/',
@@ -1509,6 +1516,12 @@ const ProjectRefFunctionsFunctionSlugCodeRoute =
path: '/code',
getParentRoute: () => ProjectRefFunctionsFunctionSlugRoute,
} as any)
const ProjectRefExplorerNotebookIdRoute =
ProjectRefExplorerNotebookIdRouteImport.update({
id: '/notebook/$id',
path: '/notebook/$id',
getParentRoute: () => ProjectRefExplorerRoute,
} as any)
const ProjectRefDatabaseTriggersEventRoute =
ProjectRefDatabaseTriggersEventRouteImport.update({
id: '/event',
@@ -2099,7 +2112,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/explorer': typeof ProjectRefExplorerRouteWithChildren
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
@@ -2224,6 +2237,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/api/': typeof ProjectRefApiIndexRoute
'/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute
'/project/$ref/editor/': typeof ProjectRefEditorIndexRoute
'/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute
'/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute
'/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
@@ -2268,6 +2282,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -2403,7 +2418,6 @@ export interface FileRoutesByTo {
'/project/$ref/advisors': typeof ProjectRefAdvisorsRouteWithChildren
'/project/$ref/auth': typeof ProjectRefAuthRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/merge': typeof ProjectRefMergeRoute
'/project/$ref/realtime': typeof ProjectRefRealtimeRouteWithChildren
'/project/$ref/settings': typeof ProjectRefSettingsRouteWithChildren
@@ -2520,6 +2534,7 @@ export interface FileRoutesByTo {
'/project/$ref/api': typeof ProjectRefApiIndexRoute
'/project/$ref/branches': typeof ProjectRefBranchesIndexRoute
'/project/$ref/editor': typeof ProjectRefEditorIndexRoute
'/project/$ref/explorer': typeof ProjectRefExplorerIndexRoute
'/project/$ref/functions': typeof ProjectRefFunctionsIndexRoute
'/project/$ref/integrations': typeof ProjectRefIntegrationsIndexRoute
'/project/$ref/logs': typeof ProjectRefLogsIndexRoute
@@ -2564,6 +2579,7 @@ export interface FileRoutesByTo {
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -2706,7 +2722,7 @@ export interface FileRoutesById {
'/project/$ref/branches': typeof ProjectRefBranchesRouteWithChildren
'/project/$ref/database': typeof ProjectRefDatabaseRouteWithChildren
'/project/$ref/editor': typeof ProjectRefEditorRouteWithChildren
'/project/$ref/explorer': typeof ProjectRefExplorerRoute
'/project/$ref/explorer': typeof ProjectRefExplorerRouteWithChildren
'/project/$ref/functions': typeof ProjectRefFunctionsRouteWithChildren
'/project/$ref/integrations': typeof ProjectRefIntegrationsRouteWithChildren
'/project/$ref/logs': typeof ProjectRefLogsRouteWithChildren
@@ -2831,6 +2847,7 @@ export interface FileRoutesById {
'/project/$ref/api/': typeof ProjectRefApiIndexRoute
'/project/$ref/branches/': typeof ProjectRefBranchesIndexRoute
'/project/$ref/editor/': typeof ProjectRefEditorIndexRoute
'/project/$ref/explorer/': typeof ProjectRefExplorerIndexRoute
'/project/$ref/functions/': typeof ProjectRefFunctionsIndexRoute
'/project/$ref/integrations/': typeof ProjectRefIntegrationsIndexRoute
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
@@ -2875,6 +2892,7 @@ export interface FileRoutesById {
'/project/$ref/database/tables/$id': typeof ProjectRefDatabaseTablesIdRoute
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -3141,6 +3159,7 @@ export interface FileRouteTypes {
| '/project/$ref/api/'
| '/project/$ref/branches/'
| '/project/$ref/editor/'
| '/project/$ref/explorer/'
| '/project/$ref/functions/'
| '/project/$ref/integrations/'
| '/project/$ref/logs/'
@@ -3185,6 +3204,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/tables/$id'
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -3320,7 +3340,6 @@ export interface FileRouteTypes {
| '/project/$ref/advisors'
| '/project/$ref/auth'
| '/project/$ref/database'
| '/project/$ref/explorer'
| '/project/$ref/merge'
| '/project/$ref/realtime'
| '/project/$ref/settings'
@@ -3437,6 +3456,7 @@ export interface FileRouteTypes {
| '/project/$ref/api'
| '/project/$ref/branches'
| '/project/$ref/editor'
| '/project/$ref/explorer'
| '/project/$ref/functions'
| '/project/$ref/integrations'
| '/project/$ref/logs'
@@ -3481,6 +3501,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/tables/$id'
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -3747,6 +3768,7 @@ export interface FileRouteTypes {
| '/project/$ref/api/'
| '/project/$ref/branches/'
| '/project/$ref/editor/'
| '/project/$ref/explorer/'
| '/project/$ref/functions/'
| '/project/$ref/integrations/'
| '/project/$ref/logs/'
@@ -3791,6 +3813,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/tables/$id'
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -4558,6 +4581,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefFunctionsIndexRouteImport
parentRoute: typeof ProjectRefFunctionsRoute
}
'/project/$ref/explorer/': {
id: '/project/$ref/explorer/'
path: '/'
fullPath: '/project/$ref/explorer/'
preLoaderRoute: typeof ProjectRefExplorerIndexRouteImport
parentRoute: typeof ProjectRefExplorerRoute
}
'/project/$ref/editor/': {
id: '/project/$ref/editor/'
path: '/'
@@ -5531,6 +5561,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefFunctionsFunctionSlugCodeRouteImport
parentRoute: typeof ProjectRefFunctionsFunctionSlugRoute
}
'/project/$ref/explorer/notebook/$id': {
id: '/project/$ref/explorer/notebook/$id'
path: '/notebook/$id'
fullPath: '/project/$ref/explorer/notebook/$id'
preLoaderRoute: typeof ProjectRefExplorerNotebookIdRouteImport
parentRoute: typeof ProjectRefExplorerRoute
}
'/project/$ref/database/triggers/event': {
id: '/project/$ref/database/triggers/event'
path: '/event'
@@ -6477,6 +6514,19 @@ const ProjectRefEditorRouteChildren: ProjectRefEditorRouteChildren = {
const ProjectRefEditorRouteWithChildren =
ProjectRefEditorRoute._addFileChildren(ProjectRefEditorRouteChildren)
interface ProjectRefExplorerRouteChildren {
ProjectRefExplorerIndexRoute: typeof ProjectRefExplorerIndexRoute
ProjectRefExplorerNotebookIdRoute: typeof ProjectRefExplorerNotebookIdRoute
}
const ProjectRefExplorerRouteChildren: ProjectRefExplorerRouteChildren = {
ProjectRefExplorerIndexRoute: ProjectRefExplorerIndexRoute,
ProjectRefExplorerNotebookIdRoute: ProjectRefExplorerNotebookIdRoute,
}
const ProjectRefExplorerRouteWithChildren =
ProjectRefExplorerRoute._addFileChildren(ProjectRefExplorerRouteChildren)
interface ProjectRefFunctionsFunctionSlugRouteChildren {
ProjectRefFunctionsFunctionSlugCodeRoute: typeof ProjectRefFunctionsFunctionSlugCodeRoute
ProjectRefFunctionsFunctionSlugDetailsRoute: typeof ProjectRefFunctionsFunctionSlugDetailsRoute
@@ -6756,7 +6806,7 @@ interface ProjectRefRouteChildren {
ProjectRefBranchesRoute: typeof ProjectRefBranchesRouteWithChildren
ProjectRefDatabaseRoute: typeof ProjectRefDatabaseRouteWithChildren
ProjectRefEditorRoute: typeof ProjectRefEditorRouteWithChildren
ProjectRefExplorerRoute: typeof ProjectRefExplorerRoute
ProjectRefExplorerRoute: typeof ProjectRefExplorerRouteWithChildren
ProjectRefFunctionsRoute: typeof ProjectRefFunctionsRouteWithChildren
ProjectRefIntegrationsRoute: typeof ProjectRefIntegrationsRouteWithChildren
ProjectRefLogsRoute: typeof ProjectRefLogsRouteWithChildren
@@ -6776,7 +6826,7 @@ const ProjectRefRouteChildren: ProjectRefRouteChildren = {
ProjectRefBranchesRoute: ProjectRefBranchesRouteWithChildren,
ProjectRefDatabaseRoute: ProjectRefDatabaseRouteWithChildren,
ProjectRefEditorRoute: ProjectRefEditorRouteWithChildren,
ProjectRefExplorerRoute: ProjectRefExplorerRoute,
ProjectRefExplorerRoute: ProjectRefExplorerRouteWithChildren,
ProjectRefFunctionsRoute: ProjectRefFunctionsRouteWithChildren,
ProjectRefIntegrationsRoute: ProjectRefIntegrationsRouteWithChildren,
ProjectRefLogsRoute: ProjectRefLogsRouteWithChildren,
+4 -5
View File
@@ -1,16 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
export const Route = createFileRoute('/project/$ref/explorer')({
component: ProjectExplorerRoute,
component: ExplorerShell,
})
function ProjectExplorerRoute() {
function ExplorerShell() {
return (
<ExplorerLayout>
<ExplorerHome />
<Outlet />
</ExplorerLayout>
)
}
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome'
export const Route = createFileRoute('/project/$ref/explorer/')({
component: ProjectExplorerIndexRoute,
})
function ProjectExplorerIndexRoute() {
return <ExplorerHome />
}
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import NotebookPage from '@/pages/project/[ref]/explorer/notebook/[id]'
export const Route = createFileRoute('/project/$ref/explorer/notebook/$id')({
component: ProjectExplorerNotebookRoute,
})
function ProjectExplorerNotebookRoute() {
return <NotebookPage dehydratedState={undefined} />
}
@@ -0,0 +1,9 @@
/**
* Ephemeral, per-session notebook state that is NOT persisted: query cell
* results and the row limit. Kept separate from the notebook content store
* (which deals with persistence) because none of this is saved — it lives
* only for the current editing session, keyed by cell id rather than
* notebook id since a single notebook can have many independent query cells.
*
* [Joshen] Will be fleshed out in subsequent PRs
*/
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { notebooksState } from './notebooks-state'
import type { Notebook } from './types'
function makeNotebook(id: string, overrides: Partial<Notebook> = {}): Notebook {
return {
id,
type: 'notebook',
name: 'My Notebook',
description: '',
visibility: 'project',
favorite: false,
owner_id: 7,
project_id: 42,
content: { schema_version: '1.0', cells: [] },
...overrides,
}
}
describe('notebooksState', () => {
beforeEach(() => {
// notebooksState is a module-level singleton, so reset the state these tests touch
for (const id of Object.keys(notebooksState.notebooks)) {
delete notebooksState.notebooks[id]
}
notebooksState.needsSaving.clear()
})
it('addNotebook marks a locally-created notebook as new', () => {
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
})
it('setNotebook marks a notebook not yet in the store as saved', () => {
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
expect(notebooksState.notebooks['notebook-1'].status).toBe('saved')
})
it('editing a loaded (saved) notebook transitions it to unsaved and queues it for saving', () => {
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
notebooksState.updateCells({
id: 'notebook-1',
cells: [{ type: 'markdown', content: 'hello' }],
})
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
expect(notebooksState.needsSaving.get('notebook-1')).toBe(false)
})
it('editing a notebook that has never been saved keeps it as new', () => {
notebooksState.addNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
notebooksState.updateCells({
id: 'notebook-1',
cells: [{ type: 'markdown', content: 'hello' }],
})
expect(notebooksState.notebooks['notebook-1'].status).toBe('new')
})
it('setNotebook does not downgrade an already-loaded notebook back to saved after edits', () => {
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
notebooksState.updateCells({
id: 'notebook-1',
cells: [{ type: 'markdown', content: 'hello' }],
})
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
// Re-fetching/merging content for the same notebook (e.g. a second setNotebook
// call) must not reset its status back to 'saved' while edits are pending.
notebooksState.setNotebook({ projectRef: 'ref', notebook: makeNotebook('notebook-1') })
expect(notebooksState.notebooks['notebook-1'].status).toBe('unsaved')
})
})
@@ -0,0 +1,110 @@
import { useMemo } from 'react'
import { proxy, snapshot, useSnapshot } from 'valtio'
import { proxyMap } from 'valtio/utils'
import type { Notebook, NotebookCell, StateNotebook } from './types'
import type { SnippetStatus } from '@/data/content/snippet-status'
// [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: NotebookCell[]
skipSave?: boolean
}) => {
const stateNotebook = notebooksState.notebooks[id]
if (!stateNotebook?.notebook.content) return
stateNotebook.notebook.content.cells = cells
stateNotebook.status = statusOnEdit(stateNotebook.status)
if (!skipSave) notebooksState.needsSaving.set(id, false)
},
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]
)
}
+34
View File
@@ -0,0 +1,34 @@
import { SnippetStatus } from '@/data/content/snippet-status'
/** Start and end follows ISO8601 convention */
type AbsoluteTimeRange = { start: string; end: string }
type RelativeTimeRange = { unit: 'm' | 'h' | 'd' | 'w' | 'M' | 'y'; amount: number }
type TimeRange = AbsoluteTimeRange | RelativeTimeRange
type DatabaseQueryCell = { type: 'sql'; sql: string }
type LogsQueryCell = { type: 'logs'; sql: string; range: TimeRange }
type MarkdownCell = { type: 'markdown'; content: string }
export type NotebookCell = DatabaseQueryCell | LogsQueryCell | MarkdownCell
interface NotebookContent {
schema_version: string
cells: NotebookCell[]
}
export interface Notebook {
id: string
type: 'notebook'
name: string
description?: string
visibility: 'project'
favorite: boolean
owner_id: number
project_id: number
content?: NotebookContent // Undefined until loaded
}
export interface StateNotebook {
projectRef: string
notebook: Notebook
status: SnippetStatus
}
+8 -1
View File
@@ -22,7 +22,7 @@ export const editorEntityTypes = {
explorer: ['notebook'],
}
export type TabType = ENTITY_TYPE | 'sql'
export type TabType = ENTITY_TYPE | 'sql' | 'notebook'
type CreateTabIdParams = {
r: { id: number }
@@ -31,6 +31,7 @@ type CreateTabIdParams = {
f: { id: number }
p: { id: number }
sql: { id: string }
notebook: { id: string }
schema: { schema: string }
view: never
function: never
@@ -46,6 +47,7 @@ export interface Tab {
name?: string
tableId?: number
sqlId?: string
notebookId?: string
scrollTop?: number
/**
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
@@ -519,6 +521,9 @@ export function createTabsState(projectRef: string) {
case 'sql':
router.push(`/project/${router.query.ref}/sql`)
break
case 'notebook':
router.push(`/project/${router.query.ref}/explorer`)
break
case 'r':
case 'v':
case 'm':
@@ -638,6 +643,8 @@ export function createTabId<T extends TabType>(type: T, params: CreateTabIdParam
return `p-${(params as CreateTabIdParams['p']).id}`
case 'sql':
return `sql-${(params as CreateTabIdParams['sql']).id}`
case 'notebook':
return `notebook-${(params as CreateTabIdParams['sql']).id}`
default:
return ''
}