mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
## Context Changes here adds a "Draft" state for notebooks with a new `notebook-drafts` store - similar to how we handle query tabs in the explorer. This implies that if a user refreshes the tab while there's unsaved changes to notebooks, the changes can be persisted locally and the user will be able to continue from where they left off. This also implies that If you create a new notebook (OR open an existing notebook and make some changes) and refresh the browser, we no longer show the native browser confirmation dialog about discarding changes. We also reuse the existing confirmation dialog when saving a notebook if its draft has diverged from the server side content - just updated the language to be more generic rather than saying that the Assistant made changes <img width="429" height="238" alt="image" src="https://github.com/user-attachments/assets/5c392aed-1633-4428-8060-28f495a01f04" /> Also fixes an unrelated issue - renaming a notebook should mark the notebook as having unsaved changes (with the orange indicator) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Unsaved notebook edits are saved locally and restored when reopening Studio. * Drafts are scoped by project and protected from server changes through conflict detection. * Notebook tabs indicate unsaved changes, including drafts from unsaved notebooks. * **Bug Fixes** * Closing a tab with local edits prompts for confirmation and removes its saved draft. * Notebook save state reflects the server-confirmed update time. * Conflict messages clearly describe changes made on the server. * **Style** * Improved keyboard focus behavior for tab controls. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
import { useRouter } from 'next/router'
|
|
import { useEffect, useEffectEvent, useState } from 'react'
|
|
|
|
import { useNotebookQuery } from '@/data/content/notebooks/notebook-query'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { generateUuid } from '@/lib/api/snippets.browser'
|
|
import { useProfile } from '@/lib/profile'
|
|
import type { AssistantModel } from '@/state/ai-assistant-state'
|
|
import { useAiAssistantState, whenAiAssistantInitialized } from '@/state/ai-assistant-state'
|
|
import { useExplorerQueryStateSnapshot } from '@/state/explorer-query'
|
|
import { readNotebookDraft } from '@/state/notebooks/notebook-drafts'
|
|
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
|
import { type Notebook } from '@/state/notebooks/types'
|
|
import { Notebooks } from '@/types'
|
|
|
|
/**
|
|
* Fetches a notebook's content by id and merges it into the valtio store, so landing on
|
|
* a notebook any way other than creating it in this session (direct link, hard refresh,
|
|
* clicking it from the nav list) still hydrates `notebooksState`.
|
|
*
|
|
* A notebook that isn't in the store yet is also checked for a locally-persisted draft of unsaved edits
|
|
* If the notebook loaded from the server, the draft is restored on top of it, otherwise if
|
|
* the notebook was never saved at all, the draft — if present — is the only copy
|
|
* that ever existed, so it's restored as a new local-only notebook instead.
|
|
*/
|
|
export const useLoadNotebook = ({ id, projectRef }: { id?: string; projectRef?: string }) => {
|
|
const notebooksSnap = useNotebooksStateSnapshot()
|
|
const { profile } = useProfile()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const currentNotebook = id ? notebooksSnap.notebooks[id] : undefined
|
|
|
|
const isCurrentProjectNotebook = currentNotebook?.projectRef === projectRef
|
|
const isNewLocalNotebook = isCurrentProjectNotebook && currentNotebook?.status === 'new'
|
|
const hasLoadedNotebook =
|
|
isCurrentProjectNotebook && currentNotebook?.notebook.content !== undefined
|
|
|
|
const { data, error, isError } = useNotebookQuery(
|
|
{ projectRef, id },
|
|
{
|
|
retry: false,
|
|
enabled: !isNewLocalNotebook && !hasLoadedNotebook,
|
|
}
|
|
)
|
|
|
|
const isNotFound = isError && error.code === 404
|
|
|
|
const mergeNotebook = useEffectEvent(() => {
|
|
if (!projectRef || !id) return
|
|
|
|
if (data) {
|
|
const isFreshLoad = !isCurrentProjectNotebook
|
|
notebooksSnap.setNotebook({ projectRef, notebook: data })
|
|
if (isFreshLoad) {
|
|
notebooksSnap.restoreDraft({ projectRef, id, baseUpdatedAt: data.updated_at })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isNotFound && !isCurrentProjectNotebook && profile && project) {
|
|
const draft = readNotebookDraft({ projectRef, id })
|
|
if (!draft) return
|
|
|
|
notebooksSnap.addNotebook({
|
|
projectRef,
|
|
notebook: {
|
|
id,
|
|
type: 'notebook',
|
|
name: draft.name,
|
|
description: '',
|
|
visibility: 'project',
|
|
favorite: false,
|
|
content: draft.content,
|
|
owner_id: profile.id,
|
|
project_id: project.id,
|
|
},
|
|
})
|
|
}
|
|
})
|
|
|
|
useEffect(() => {
|
|
mergeNotebook()
|
|
}, [projectRef, id, data, isNotFound, !!profile, !!project])
|
|
|
|
return { isNotFound: isNotFound && !isCurrentProjectNotebook }
|
|
}
|
|
|
|
export const useCreateNotebook = () => {
|
|
const router = useRouter()
|
|
const { profile } = useProfile()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const notebooksSnap = useNotebooksStateSnapshot()
|
|
|
|
const createNotebook = ({
|
|
id: idOverride,
|
|
name,
|
|
cells,
|
|
}: { id?: string; name?: string; cells?: Notebooks.Content['cells'] } = {}) => {
|
|
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,
|
|
cells: 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 }
|
|
}
|
|
|
|
export const useCreateChat = () => {
|
|
const router = useRouter()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const { data: organization } = useSelectedOrganizationQuery()
|
|
const aiAssistantState = useAiAssistantState()
|
|
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
|
|
const openChat = (id: string) => {
|
|
if (!project) {
|
|
console.error('Project is required')
|
|
return undefined
|
|
}
|
|
|
|
router.push(`/project/${project.ref}/explorer/chat/${id}`)
|
|
}
|
|
|
|
const createChat = async ({
|
|
name,
|
|
initialMessage,
|
|
model,
|
|
}: {
|
|
name?: string
|
|
initialMessage?: string
|
|
model?: AssistantModel
|
|
} = {}) => {
|
|
if (!project) {
|
|
console.error('Project is required')
|
|
return undefined
|
|
}
|
|
|
|
setIsCreating(true)
|
|
|
|
try {
|
|
// Hydration replaces the chat map and the selected model wholesale, so wait it out before
|
|
// creating anything — otherwise the new chat is dropped as soon as the persisted state lands.
|
|
await whenAiAssistantInitialized(aiAssistantState)
|
|
|
|
aiAssistantState.setContext({
|
|
projectRef: project.ref,
|
|
orgSlug: organization?.slug,
|
|
connectionString: project.connectionString ?? '',
|
|
})
|
|
if (model) aiAssistantState.setModel(model)
|
|
|
|
const id = aiAssistantState.createChat({ name, initialMessage })
|
|
|
|
router.push(`/project/${project.ref}/explorer/chat/${id}`)
|
|
return id
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}
|
|
|
|
return { createChat, openChat, isCreating }
|
|
}
|
|
|
|
export const useCreateQuery = () => {
|
|
const router = useRouter()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const querySnap = useExplorerQueryStateSnapshot()
|
|
|
|
const createQuery = ({ sql, name }: { sql?: string; name?: string } = {}) => {
|
|
if (!project) return console.error('Project is required')
|
|
|
|
const id = generateUuid()
|
|
querySnap.createDraft({ id, projectRef: project.ref, sql, name })
|
|
|
|
router.push(`/project/${project.ref}/explorer/query/${id}`)
|
|
|
|
return id
|
|
}
|
|
|
|
return { createQuery }
|
|
}
|