From 3a383c87c7669d7989d1dd9f00bc6ca2bc07a7e6 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 7 Aug 2026 11:21:38 +0700 Subject: [PATCH] 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 image Which should open a placeholder page image Closing the notebook brings you back to the explorer home page ## 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. --- apps/studio/TANSTACK_MIGRATION.md | 7 +- .../interfaces/Explorer/NotebookEditor.tsx | 26 +++++ .../__tests__/NotebookEditor.test.tsx | 87 ++++++++++++++ .../components/interfaces/Explorer/hooks.ts | 43 +++++++ .../layouts/ExplorerLayout/ExplorerLayout.tsx | 5 +- apps/studio/components/ui/EntityTypeIcon.tsx | 8 +- .../project/[ref]/explorer/notebook/[id].tsx | 16 +++ apps/studio/routeTree.gen.ts | 62 +++++++++- apps/studio/routes/project/$ref/explorer.tsx | 9 +- .../routes/project/$ref/explorer/index.tsx | 11 ++ .../project/$ref/explorer/notebook/$id.tsx | 11 ++ .../state/notebooks/notebook-session-state.ts | 9 ++ .../state/notebooks/notebooks-state.test.ts | 79 +++++++++++++ .../studio/state/notebooks/notebooks-state.ts | 110 ++++++++++++++++++ apps/studio/state/notebooks/types.ts | 34 ++++++ apps/studio/state/tabs.tsx | 9 +- 16 files changed, 510 insertions(+), 16 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/NotebookEditor.tsx create mode 100644 apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx create mode 100644 apps/studio/components/interfaces/Explorer/hooks.ts create mode 100644 apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx create mode 100644 apps/studio/routes/project/$ref/explorer/index.tsx create mode 100644 apps/studio/routes/project/$ref/explorer/notebook/$id.tsx create mode 100644 apps/studio/state/notebooks/notebook-session-state.ts create mode 100644 apps/studio/state/notebooks/notebooks-state.test.ts create mode 100644 apps/studio/state/notebooks/notebooks-state.ts create mode 100644 apps/studio/state/notebooks/types.ts diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 536450f6635..410932b3e8c 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -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` diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx new file mode 100644 index 00000000000..ef473ebe2b5 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.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
This is a notebook
+} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx new file mode 100644 index 00000000000..5706043bfae --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx @@ -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() + return { + ...actual, + useTabsStateSnapshot: () => ({ addTab: mockAddTab }), + } +}) + +function makeNotebook(id: string, overrides: Partial = {}): 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() + + 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() + + 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() + + expect(mockAddTab).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts new file mode 100644 index 00000000000..5c23c31eee1 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -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 } +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index e8e4b916ea5..c3c47f4f3fb 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -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 { children: ReactNode @@ -85,6 +86,8 @@ const HomeTabButton = () => { } const NewTabButton = () => { + const { createNotebook } = useCreateNotebook() + return ( @@ -103,7 +106,7 @@ const NewTabButton = () => { - + createNotebook()}> New notebook diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index 2dc05a6df58..f238dcab366 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -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 + } + return (
{ + return +} + +NotebookPage.getLayout = (page) => ( + + {page} + +) + +export default NotebookPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index c134b2af421..93a2d78dc37 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -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, diff --git a/apps/studio/routes/project/$ref/explorer.tsx b/apps/studio/routes/project/$ref/explorer.tsx index d31f51f8179..96a78bdcee6 100644 --- a/apps/studio/routes/project/$ref/explorer.tsx +++ b/apps/studio/routes/project/$ref/explorer.tsx @@ -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 ( - + ) } diff --git a/apps/studio/routes/project/$ref/explorer/index.tsx b/apps/studio/routes/project/$ref/explorer/index.tsx new file mode 100644 index 00000000000..c77bdd40f67 --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/index.tsx @@ -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 +} diff --git a/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx b/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx new file mode 100644 index 00000000000..54c476a94ad --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/notebook/$id.tsx @@ -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 +} diff --git a/apps/studio/state/notebooks/notebook-session-state.ts b/apps/studio/state/notebooks/notebook-session-state.ts new file mode 100644 index 00000000000..ed760aecd43 --- /dev/null +++ b/apps/studio/state/notebooks/notebook-session-state.ts @@ -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 + */ diff --git a/apps/studio/state/notebooks/notebooks-state.test.ts b/apps/studio/state/notebooks/notebooks-state.test.ts new file mode 100644 index 00000000000..6c0de761ebf --- /dev/null +++ b/apps/studio/state/notebooks/notebooks-state.test.ts @@ -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 { + 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') + }) +}) diff --git a/apps/studio/state/notebooks/notebooks-state.ts b/apps/studio/state/notebooks/notebooks-state.ts new file mode 100644 index 00000000000..4cca4f9e95e --- /dev/null +++ b/apps/studio/state/notebooks/notebooks-state.ts @@ -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, + needsSaving: proxyMap([]), + + /** + * 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[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] + ) +} diff --git a/apps/studio/state/notebooks/types.ts b/apps/studio/state/notebooks/types.ts new file mode 100644 index 00000000000..69ee18a1ea1 --- /dev/null +++ b/apps/studio/state/notebooks/types.ts @@ -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 +} diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index dc8ba7e00fe..62a02e5c279 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -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(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 '' }