+ 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 ''
}