tab states

This commit is contained in:
Saxon Fletcher committed 2026-06-03 15:51:49 +10:00
1 parent 28bf02b248
commit f86e37af23
23 files changed
+807 -210

No files matched your search

@@ -97,7 +97,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
if (ref !== undefined && id !== undefined) {
if (router.pathname.endsWith('/sql/[id]') && id !== 'new') {
const snippet = getSqlEditorV2StateSnapshot().snippets[id]?.snippet
if (!snippet?.isNotSavedInDatabaseYet) {
if (!snippet?.isDraftTab && !snippet?.isNotSavedInDatabaseYet) {
setLastVisitedSnippet(id)
}
} else if (router.pathname.endsWith('/editor/[id]')) {
@@ -214,8 +214,11 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
tabsSnap.makeActiveTabPermanent()
if (id && value) {
if (id && value !== undefined) {
setValue(value)
if (snippet) {
snapV2.setSql({ id, sql: value, skipSave: true })
}
}
}
@@ -58,6 +58,7 @@ import {
import { SqlEditorPanelHeader } from './SqlEditorPanelHeader'
import { SqlEditorShowSqlToggle } from './SqlEditorShowSqlToggle'
import { getSnippetSqlFromContent } from './sqlSnippet.utils'
import { useCreateDraftSqlTab } from './useCreateDraftSqlTab'
import { useSaveSqlSnippet } from './useSaveSqlSnippet'
import { useSqlEditorCompletion } from './useSqlEditorCompletion'
import { UtilityActions } from './UtilityPanel/UtilityActions'
@@ -85,7 +86,6 @@ import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { isError } from '@/data/utils/error-check'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { generateUuid } from '@/lib/api/snippets.browser'
import { BASE_PATH } from '@/lib/constants'
import { formatSql } from '@/lib/formatSql'
import { detectOS } from '@/lib/helpers'
@@ -171,13 +171,11 @@ export const SQLEditor = () => {
registerInCommandMenu: true,
})
const { createDraftTab } = useCreateDraftSqlTab()
const openNewSnippet = useCallback(() => {
if (!ref) return
// skip=true bypasses the "load last visited snippet" redirect on /sql/new.
// Without it, the effect in pages/project/[ref]/sql/[id].tsx bounces back
// to the previous snippet.
router.push(`/project/${ref}/sql/new?skip=true`)
}, [ref, router])
createDraftTab()
}, [createDraftTab])
useShortcut(SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET, openNewSnippet, {
registerInCommandMenu: true,
@@ -194,27 +192,22 @@ export const SQLEditor = () => {
refocusEditor()
}, [refocusEditor])
// generate a new snippet title and an id to be used for new snippets. The dependency on urlId is to avoid a bug which
// shows up when clicking on the SQL Editor while being in the SQL editor on a random snippet.
const [generatedNewSnippetName, generatedId] = useMemo(() => {
const name = generateSnippetTitle()
return [name, generateUuid([`${name}.sql`])]
}, [urlId])
// the id is stable across renders - it depends either on the url or on the memoized generated id
const effectiveUrlId = notebookEditorContext?.blockId ?? urlId
const id = !effectiveUrlId || effectiveUrlId === 'new' ? generatedId : effectiveUrlId
const id = effectiveUrlId === 'new' ? '' : (effectiveUrlId ?? '')
if (!id) {
return null
}
const limit = snapV2.limit
const results = snapV2.results[id]?.[0]
const results = id ? snapV2.results[id]?.[0] : undefined
const snippetIsLoading = !(
id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined
id &&
id in snapV2.snippets &&
snapV2.snippets[id].snippet.content !== undefined
)
const isLoading = effectiveUrlId === 'new' ? false : snippetIsLoading
const snippetDisplayTitle =
effectiveUrlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
const isLoading = !id || snippetIsLoading
const snippetDisplayTitle = snapV2.snippets[id]?.snippet.name ?? generateSnippetTitle()
useSqlEditorCompletion(id, monacoRef.current)
@@ -378,7 +371,6 @@ export const SQLEditor = () => {
id,
snippetName: snippetDisplayTitle,
getEditorSql,
isOnNewRoute: urlId === 'new',
})
useShortcut(SHORTCUT_IDS.SQL_EDITOR_SAVE, () => void saveQuery(), {
@@ -1103,11 +1095,7 @@ export const SQLEditor = () => {
: ''
}
id={id}
snippetName={
urlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
}
snippetName={snippetDisplayTitle}
className={cn(isDiffOpen && 'hidden')}
editorRef={editorRef}
monacoRef={monacoRef}
@@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger, ToggleGroup, ToggleGroupItem }
import { ChartConfig, ChartSettings } from './ChartConfig'
import { UtilityTabLogsResults } from './UtilityTabLogsResults'
import { UtilityTabResults } from './UtilityTabResults'
import { isDraftSqlSnippet } from '@/components/interfaces/SQLEditor/createDraftSqlTab'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
@@ -76,7 +77,11 @@ export const UtilityPanel = ({
},
}
snapV2.updateSnippet({ id, snippet: newSnippet as unknown as Snippet })
snapV2.updateSnippet({
id,
snippet: newSnippet as unknown as Snippet,
skipSave: isDraftSqlSnippet(snippet),
})
},
onError: async (_err, _newContent, _context) => {
toast.error(`Failed to update chart. Please try again.`)
@@ -122,6 +127,19 @@ export const UtilityPanel = ({
}
if (!ref || !snippet?.id) return
if (isDraftSqlSnippet(snippet)) {
snapV2.updateSnippet({
id,
snippet: {
content: {
...snippet.content,
chart: config,
},
} as unknown as Snippet,
skipSave: true,
})
return
}
upsertContent({
projectRef: ref,
@@ -0,0 +1,208 @@
import type { NextRouter } from 'next/router'
import {
persistDraftSqlTab,
readPersistedDraftSqlTab,
removePersistedDraftSqlTab,
} from './draftSqlTabStorage.utils'
import { generateSnippetTitle } from './SQLEditor.constants'
import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
import { generateUuid } from '@/lib/api/snippets.browser'
import type { QueryExecutionSource } from '@/state/query-execution-source'
import type { SnippetWithContent } from '@/state/sql-editor-v2'
import { createTabId } from '@/state/tabs'
type SqlEditorV2Actions = {
addSnippet: (args: { projectRef: string; snippet: SnippetWithContent }) => void
}
type TabsActions = {
addTab: (tab: {
id: string
type: 'sql'
label?: string
metadata?: {
sqlId?: string
name?: string
isDraft?: boolean
}
isPreview?: boolean
}) => void
}
export type CreateDraftSqlTabParams = {
projectRef: string
projectId: number
ownerId: number
snapV2: SqlEditorV2Actions
tabs: TabsActions
router?: NextRouter
initialSql?: string
querySource?: QueryExecutionSource
/** When true, only registers state without changing the route */
skipNavigation?: boolean
}
export function createDraftSqlTab({
projectRef,
projectId,
ownerId,
snapV2,
tabs,
router,
initialSql = '',
querySource = 'database',
skipNavigation = false,
}: CreateDraftSqlTabParams): string {
const name = generateSnippetTitle()
const draftId = generateUuid([`${name}.sql`, Date.now().toString()])
const snippet = createSqlSnippetSkeletonV2({
idOverride: draftId,
name,
sql: initialSql,
owner_id: ownerId,
project_id: projectId,
querySource,
})
snippet.isDraftTab = true
snapV2.addSnippet({ projectRef, snippet })
persistDraftSqlTab(projectRef, draftId, {
sql: initialSql,
name,
querySource,
})
tabs.addTab({
id: createTabId('sql', { id: draftId }),
type: 'sql',
label: name,
metadata: {
sqlId: draftId,
name,
isDraft: true,
},
})
if (!skipNavigation && router) {
void router.push(`/project/${projectRef}/sql/${draftId}`)
}
return draftId
}
export function restoreDraftSqlTab({
draftId,
projectRef,
projectId,
ownerId,
snapV2,
name = generateSnippetTitle(),
initialSql = '',
querySource = 'database',
}: Omit<CreateDraftSqlTabParams, 'tabs' | 'router' | 'skipNavigation'> & {
draftId: string
name?: string
initialSql?: string
}) {
const persisted = readPersistedDraftSqlTab(projectRef, draftId)
const resolvedName = persisted?.name ?? name
const resolvedSql = persisted?.sql ?? initialSql
const resolvedQuerySource = persisted?.querySource ?? querySource
const snippet = createSqlSnippetSkeletonV2({
idOverride: draftId,
name: resolvedName,
sql: resolvedSql,
owner_id: ownerId,
project_id: projectId,
querySource: resolvedQuerySource,
})
snippet.isDraftTab = true
snapV2.addSnippet({ projectRef, snippet })
}
export function clearPersistedDraftSqlTab(projectRef: string, draftId: string) {
removePersistedDraftSqlTab(projectRef, draftId)
}
export function restoreOpenDraftSqlTabs({
projectRef,
projectId,
ownerId,
snapV2,
openTabs,
tabsMap,
existingSnippetIds,
}: {
projectRef: string
projectId: number
ownerId: number
snapV2: SqlEditorV2Actions
openTabs: string[]
tabsMap: Record<string, { type: string; metadata?: { sqlId?: string; isDraft?: boolean } }>
existingSnippetIds: Set<string>
}) {
const openDraftIds: string[] = []
for (const tabId of openTabs) {
const tab = tabsMap[tabId]
if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !tab.metadata.sqlId) {
continue
}
openDraftIds.push(tab.metadata.sqlId)
if (existingSnippetIds.has(tab.metadata.sqlId)) {
continue
}
restoreDraftSqlTab({
draftId: tab.metadata.sqlId,
projectRef,
projectId,
ownerId,
snapV2,
name: tab.metadata.name,
})
}
return openDraftIds
}
export function isDraftSqlSnippet(
snippet: Pick<SnippetWithContent, 'isDraftTab' | 'isNotSavedInDatabaseYet'> | undefined
) {
return snippet?.isDraftTab === true
}
export function getOpenDraftSqlTabIds(
openTabs: string[],
tabsMap: Record<string, { type?: string; metadata?: { sqlId?: string; isDraft?: boolean } }>
) {
const ids = new Set<string>()
for (const tabId of openTabs) {
const tab = tabsMap[tabId]
if (tab?.type === 'sql' && tab.metadata?.isDraft && tab.metadata.sqlId) {
ids.add(tab.metadata.sqlId)
}
}
return ids
}
export function shouldHideDraftSqlTabFromNav(
snippetId: string,
openDraftIds: Set<string>,
snippet?: Pick<SnippetWithContent, 'isDraftTab' | 'isNotSavedInDatabaseYet'>
) {
if (openDraftIds.has(snippetId)) return true
if (isDraftSqlSnippet(snippet)) return true
if (snippet?.isNotSavedInDatabaseYet) return true
return false
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getDraftSqlTabStorageKey,
persistDraftSqlTab,
prunePersistedDraftSqlTabs,
readPersistedDraftSqlTab,
removePersistedDraftSqlTab,
} from './draftSqlTabStorage.utils'
const PROJECT_REF = 'test-project'
describe('draftSqlTabStorage', () => {
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('window', globalThis.window)
})
it('persists and reads draft tab sql', () => {
persistDraftSqlTab(PROJECT_REF, 'draft-1', {
sql: 'select 1',
name: 'Untitled query',
})
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({
sql: 'select 1',
name: 'Untitled query',
querySource: 'database',
})
})
it('merges partial updates without clearing sql', () => {
persistDraftSqlTab(PROJECT_REF, 'draft-1', {
sql: 'select 1',
name: 'Untitled query',
})
persistDraftSqlTab(PROJECT_REF, 'draft-1', {
name: 'Untitled query 2',
})
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({
sql: 'select 1',
name: 'Untitled query 2',
})
})
it('removes a persisted draft tab', () => {
persistDraftSqlTab(PROJECT_REF, 'draft-1', {
sql: 'select 1',
name: 'Untitled query',
})
removePersistedDraftSqlTab(PROJECT_REF, 'draft-1')
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toBeUndefined()
})
it('prunes drafts that are no longer open', () => {
persistDraftSqlTab(PROJECT_REF, 'draft-1', { sql: 'select 1', name: 'One' })
persistDraftSqlTab(PROJECT_REF, 'draft-2', { sql: 'select 2', name: 'Two' })
prunePersistedDraftSqlTabs(PROJECT_REF, ['draft-2'])
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toBeUndefined()
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-2')).toBeDefined()
})
it('uses a project-scoped storage key', () => {
expect(getDraftSqlTabStorageKey(PROJECT_REF)).toBe(`sql-editor-draft-tabs-${PROJECT_REF}`)
})
})
@@ -0,0 +1,94 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import type { QueryExecutionSource } from '@/state/query-execution-source'
export type PersistedDraftSqlTab = {
sql: string
name: string
querySource: QueryExecutionSource
updatedAt: number
}
export type DraftSqlTabStorage = Record<string, PersistedDraftSqlTab>
export function getDraftSqlTabStorageKey(projectRef: string) {
return LOCAL_STORAGE_KEYS.SQL_EDITOR_DRAFT_TABS(projectRef)
}
export function readDraftSqlTabStorage(projectRef: string): DraftSqlTabStorage {
if (typeof window === 'undefined' || !projectRef) return {}
try {
const raw = localStorage.getItem(getDraftSqlTabStorageKey(projectRef))
if (!raw) return {}
const parsed = JSON.parse(raw) as unknown
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {}
}
return parsed as DraftSqlTabStorage
} catch {
return {}
}
}
export function readPersistedDraftSqlTab(
projectRef: string,
draftId: string
): PersistedDraftSqlTab | undefined {
return readDraftSqlTabStorage(projectRef)[draftId]
}
export function persistDraftSqlTab(
projectRef: string,
draftId: string,
patch: {
sql?: string
name: string
querySource?: QueryExecutionSource
}
) {
if (typeof window === 'undefined' || !projectRef) return
const storage = readDraftSqlTabStorage(projectRef)
const existing = storage[draftId]
storage[draftId] = {
sql: patch.sql ?? existing?.sql ?? '',
name: patch.name,
querySource: patch.querySource ?? existing?.querySource ?? 'database',
updatedAt: Date.now(),
}
localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage))
}
export function removePersistedDraftSqlTab(projectRef: string, draftId: string) {
if (typeof window === 'undefined' || !projectRef) return
const storage = readDraftSqlTabStorage(projectRef)
if (!(draftId in storage)) return
delete storage[draftId]
localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage))
}
export function prunePersistedDraftSqlTabs(projectRef: string, openDraftIds: string[]) {
if (typeof window === 'undefined' || !projectRef) return
const storage = readDraftSqlTabStorage(projectRef)
const openDraftIdSet = new Set(openDraftIds)
let hasChanges = false
for (const draftId of Object.keys(storage)) {
if (!openDraftIdSet.has(draftId)) {
delete storage[draftId]
hasChanges = true
}
}
if (hasChanges) {
localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage))
}
}
@@ -99,6 +99,31 @@ describe('saveSqlSnippet', () => {
expect(onTabLabelUpdate).toHaveBeenCalledWith('Active users')
})
it('clears draft tab flag when saving a draft snippet', async () => {
const updateSnippet = vi.fn()
const onDraftSaved = vi.fn()
await saveSqlSnippet({
...baseParams,
updateSnippet,
onDraftSaved,
snippet: {
id: 'snippet-id',
name: untitledSnippetTitle,
visibility: 'user',
isNotSavedInDatabaseYet: true,
isDraftTab: true,
} as any,
})
expect(updateSnippet).toHaveBeenCalledWith({
id: 'snippet-id',
snippet: { isDraftTab: false },
skipSave: true,
})
expect(onDraftSaved).toHaveBeenCalled()
})
it('skips AI rename when snippet already has a custom name', async () => {
const generateSqlTitle = vi.fn()
@@ -23,8 +23,13 @@ export type SaveSqlSnippetParams = {
}) => void
addNeedsSaving: (id: string) => void
generateSqlTitle: (args: { sql: string }) => Promise<{ title: string }>
updateSnippet: (args: { id: string; snippet: { name: string } }) => void
updateSnippet: (args: {
id: string
snippet: { name?: string; isDraftTab?: boolean }
skipSave?: boolean
}) => void
onTabLabelUpdate?: (name: string) => void
onDraftSaved?: () => void
onNavigateToSnippet?: () => void
}
@@ -44,6 +49,7 @@ export async function saveSqlSnippet({
generateSqlTitle,
updateSnippet,
onTabLabelUpdate,
onDraftSaved,
onNavigateToSnippet,
}: SaveSqlSnippetParams): Promise<{ saved: boolean; reason?: 'empty' }> {
const trimmedSql = sql.trim()
@@ -68,6 +74,13 @@ export async function saveSqlSnippet({
}
const shouldInvalidate = currentSnippet.isNotSavedInDatabaseYet
if (currentSnippet.isDraftTab) {
updateSnippet({ id, snippet: { isDraftTab: false }, skipSave: true })
currentSnippet = { ...currentSnippet, isDraftTab: false }
onDraftSaved?.()
}
setSql({ id, sql: trimmedSql, shouldInvalidate })
addNeedsSaving(id)
@@ -0,0 +1,53 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { createDraftSqlTab } from './createDraftSqlTab'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProfile } from '@/lib/profile'
import type { QueryExecutionSource } from '@/state/query-execution-source'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
import { useTabsStateSnapshot } from '@/state/tabs'
export function useCreateDraftSqlTab() {
const router = useRouter()
const { ref: projectRef } = useParams()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'user_content',
{
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
}
)
const createDraftTab = useCallback(
(options?: { initialSql?: string; querySource?: QueryExecutionSource }) => {
if (!projectRef) return console.error('Project ref is required')
if (!project) return console.error('Project is required')
if (!profile) return console.error('Profile is required')
if (!canCreateSQLSnippet) return
return createDraftSqlTab({
projectRef,
projectId: project.id,
ownerId: profile.id,
snapV2,
tabs,
router,
initialSql: options?.initialSql,
querySource: options?.querySource,
})
},
[canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
)
return { createDraftTab, canCreateSQLSnippet }
}
@@ -0,0 +1,37 @@
import { useParams } from 'common'
import { useEffect, useRef } from 'react'
import { restoreOpenDraftSqlTabs } from './createDraftSqlTab'
import { prunePersistedDraftSqlTabs } from './draftSqlTabStorage.utils'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProfile } from '@/lib/profile'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
import { useTabsStateSnapshot } from '@/state/tabs'
export function useRestorePersistedDraftSqlTabs() {
const { ref: projectRef } = useParams()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const hasRestoredRef = useRef<string | null>(null)
useEffect(() => {
if (!projectRef || !project || !profile) return
if (hasRestoredRef.current === projectRef) return
hasRestoredRef.current = projectRef
const openDraftIds = restoreOpenDraftSqlTabs({
projectRef,
projectId: project.id,
ownerId: profile.id,
snapV2,
openTabs: tabs.openTabs,
tabsMap: tabs.tabsMap,
existingSnippetIds: new Set(Object.keys(getSqlEditorV2StateSnapshot().snippets)),
})
prunePersistedDraftSqlTabs(projectRef, openDraftIds)
}, [profile, project, projectRef, snapV2, tabs.openTabs, tabs.tabsMap])
}
@@ -1,9 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { clearPersistedDraftSqlTab } from './createDraftSqlTab'
import { saveSqlSnippet } from './saveSqlSnippet.utils'
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -18,16 +18,9 @@ export type UseSaveSqlSnippetOptions = {
id: string
snippetName: string
getEditorSql: () => string | undefined
isOnNewRoute?: boolean
}
export function useSaveSqlSnippet({
id,
snippetName,
getEditorSql,
isOnNewRoute = false,
}: UseSaveSqlSnippetOptions) {
const router = useRouter()
export function useSaveSqlSnippet({ id, snippetName, getEditorSql }: UseSaveSqlSnippetOptions) {
const { ref: projectRef } = useParams()
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
@@ -52,9 +45,8 @@ export function useSaveSqlSnippet({
if (!profile) return console.error('Profile is required')
const state = getSqlEditorV2StateSnapshot()
const snippet = state.snippets[id]
const isReadOnly =
snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile.id
const snippet = state.snippets[id]?.snippet
const isReadOnly = snippet?.visibility === 'project' && snippet?.owner_id !== profile.id
if (isReadOnly) return
@@ -82,11 +74,13 @@ export function useSaveSqlSnippet({
const tabId = createTabId('sql', { id })
tabs.updateTab(tabId, { label: name })
},
onNavigateToSnippet: isOnNewRoute
? () => {
void router.push(`/project/${projectRef}/sql/${id}`, undefined, { shallow: true })
}
: undefined,
onDraftSaved: () => {
const tabId = createTabId('sql', { id })
tabs.updateTab(tabId, { metadata: { isDraft: false } })
if (projectRef) {
clearPersistedDraftSqlTab(projectRef, id)
}
},
})
if (result.reason === 'empty') {
@@ -98,12 +92,10 @@ export function useSaveSqlSnippet({
getEditorSql,
id,
isHipaaProjectDisallowed,
isOnNewRoute,
profile,
project,
projectRef,
querySourceState.executionSource,
router,
snippetName,
snapV2.addNeedsSaving,
snapV2.addSnippet,
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel'
import { useRestorePersistedDraftSqlTabs } from '@/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs'
import { withAuth } from '@/hooks/misc/withAuth'
export interface SQLEditorLayoutProps {
@@ -8,6 +9,8 @@ export interface SQLEditorLayoutProps {
}
const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => {
useRestorePersistedDraftSqlTabs()
return (
<>
{children}
@@ -2,7 +2,7 @@ import { keepPreviousData } from '@tanstack/react-query'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
import { Heart } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
InnerSideBarEmptyPanel,
@@ -29,6 +29,10 @@ import { SqlEditorNavFolder } from './SqlEditorNavFolder'
import { SQLEditorSectionActions } from './SQLEditorSectionActions'
import { UnshareSnippetModal } from './UnshareSnippetModal'
import { useSqlEditorCreateActions } from './useSqlEditorCreateActions'
import {
getOpenDraftSqlTabIds,
shouldHideDraftSqlTabFromNav,
} from '@/components/interfaces/SQLEditor/createDraftSqlTab'
import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal'
import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal'
@@ -142,6 +146,17 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const snippet = snapV2.snippets[id as string]?.snippet
const openDraftSqlTabIds = useMemo(
() => getOpenDraftSqlTabIds(tabs.openTabs, tabs.tabsMap),
[tabs.openTabs, tabs.tabsMap]
)
const isHiddenDraftSqlTab = useCallback(
(snippetId: string, snippetToCheck?: SnippetWithContent) =>
shouldHideDraftSqlTabFromNav(snippetId, openDraftSqlTabIds, snippetToCheck),
[openDraftSqlTabIds]
)
// ==========================
// Private snippets & folders
// ==========================
@@ -195,7 +210,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
if (
snippet &&
snippet.visibility === 'user' &&
!snippet.isNotSavedInDatabaseYet &&
!isHiddenDraftSqlTab(snippet.id, snippet) &&
!snippetInfo.snippetIds.has(snippet.id)
) {
snippetInfo.snippetIds.add(snippet.id)
@@ -203,11 +218,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}
return snippetInfo
}, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet])
}, [
privateSnippetsPages?.pages,
subResults,
isLoading,
isPlaceholderData,
isFetching,
snippet,
isHiddenDraftSqlTab,
])
const privateSnippets = useMemo(() => {
const userSnippets =
filteredSnippets.snippets?.filter((snippet) => snippet.visibility === 'user') ?? []
filteredSnippets.snippets?.filter(
(snippet) => snippet.visibility === 'user' && !isHiddenDraftSqlTab(snippet.id)
) ?? []
return mergeSnippetsWithLogSql(
userSnippets,
@@ -216,7 +241,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
if (sort === 'name') return a.name.localeCompare(b.name)
else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
})
}, [filteredSnippets.snippets, logSqlSnippets, sort])
}, [filteredSnippets.snippets, logSqlSnippets, sort, isHiddenDraftSqlTab])
const folders = useSnippetFolders(projectRef!)
const { data: snippetCountData, error: snippetCountError } = useContentCountQuery({
@@ -264,7 +289,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
if (
snippet &&
snippet.favorite &&
!snippet.isNotSavedInDatabaseYet &&
!isHiddenDraftSqlTab(snippet.id, snippet) &&
!snippets.find((x) => x.id === snippet.id)
) {
snippets.push(snippet as SqlSnippet)
@@ -277,13 +302,14 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
return (
withLogSqlFavorites
.filter((snippet) => !isHiddenDraftSqlTab(snippet.id))
.map((snippet) => ({ ...snippet, folder_id: undefined }))
.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name)
else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
}) ?? []
)
}, [favoriteSqlSnippetsData?.pages, snippet, sort, logSqlSnippets])
}, [favoriteSqlSnippetsData?.pages, snippet, sort, logSqlSnippets, isHiddenDraftSqlTab])
const numFavoriteSnippets = snippetCountData?.favorites ?? 0
@@ -315,19 +341,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
if (
snippet &&
snippet.visibility === 'project' &&
!snippet.isNotSavedInDatabaseYet &&
!isHiddenDraftSqlTab(snippet.id, snippet) &&
!snippets.find((x) => x.id === snippet.id)
) {
snippets.push(snippet as SqlSnippet)
}
return (
snippets.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name)
else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
}) ?? []
snippets
.filter((snippet) => !isHiddenDraftSqlTab(snippet.id))
.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name)
else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
}) ?? []
)
}, [sharedSqlSnippetsData?.pages, snippet, sort, logSqlSnippets])
}, [sharedSqlSnippetsData?.pages, snippet, sort, logSqlSnippets, isHiddenDraftSqlTab])
const numProjectSnippets = snippetCountData?.shared ?? 0
const numTotalSnippets =
@@ -5,6 +5,7 @@ import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { createDraftSqlTab } from '@/components/interfaces/SQLEditor/createDraftSqlTab'
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
@@ -14,6 +15,7 @@ import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
import { useTabsStateSnapshot } from '@/state/tabs'
export type SqlEditorSnippetCreateTarget = 'private' | 'shared' | 'favorite'
@@ -23,6 +25,7 @@ export function useSqlEditorCreateActions() {
const { profile } = useProfile()
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const assistant = useAiAssistantStateSnapshot()
const [, setShowNewReportModal] = useQueryState('newReport', parseAsBoolean.withDefault(false))
const { reportsAll } = useIsFeatureEnabled(['reports:all'])
@@ -53,7 +56,14 @@ export function useSqlEditorCreateActions() {
try {
if (target === 'private') {
await router.push(`/project/${projectRef}/sql/new?skip=true`)
createDraftSqlTab({
projectRef,
projectId: project.id,
ownerId: profile.id,
snapV2,
tabs,
router,
})
return
}
@@ -74,7 +84,7 @@ export function useSqlEditorCreateActions() {
toast.error(`Failed to create new query: ${error.message}`)
}
},
[canCreateSQLSnippet, profile, project, projectRef, router, snapV2]
[canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
)
const createNewChat = useCallback(() => {
@@ -131,7 +131,7 @@ export const SortableTab = ({
className={cn(
'flex items-center',
isToggleGroupTab
? 'min-w-0 max-w-[240px] shrink'
? 'min-w-0 flex-1 max-w-fit overflow-hidden'
: 'h-(--header-height) shrink-0 first-of-type:border-l'
)}
>
@@ -140,7 +140,7 @@ export const SortableTab = ({
value={tab.id}
aria-label={tab.label || 'Untitled'}
className={cn(
'group relative flex w-auto min-w-0 max-w-full items-center gap-1.5 overflow-hidden px-2 text-xs',
'group relative flex w-full min-w-0 flex-1 max-w-fit items-center gap-1.5 overflow-hidden px-2 text-xs',
SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME,
tab.isPreview && 'italic font-light'
)}
@@ -27,7 +27,7 @@ export const TabPreview = ({
'relative flex items-center text-xs shadow-lg',
variant === 'toggle-group'
? cn(
'max-w-[240px] gap-1.5 overflow-hidden rounded-md bg-surface-300 px-2',
'max-w-fit gap-1.5 overflow-hidden rounded-md bg-surface-300 px-2',
SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME
)
: 'h-10 gap-2 rounded-xs bg-dash-sidebar px-3 dark:bg-surface-100'
+52 -84
View File
@@ -9,7 +9,7 @@ import {
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, X } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import {
cn,
@@ -30,7 +30,10 @@ import { CollapseButton } from './CollapseButton'
import { SortableTab } from './SortableTab'
import { TabPreview } from './TabPreview'
import { useTabsScroll } from './Tabs.utils'
import { clearPersistedDraftSqlTab } from '@/components/interfaces/SQLEditor/createDraftSqlTab'
import { useCreateDraftSqlTab } from '@/components/interfaces/SQLEditor/useCreateDraftSqlTab'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
import { editorEntityTypes, isSqlEditorTab, useTabsStateSnapshot, type Tab } from '@/state/tabs'
export const EditorTabs = () => {
@@ -40,6 +43,8 @@ export const EditorTabs = () => {
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const { createDraftTab } = useCreateDraftSqlTab()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -52,8 +57,6 @@ export const EditorTabs = () => {
.map((id) => tabs.tabsMap[id])
.filter((tab) => tab !== undefined) as Tab[]
const hasNewTab = router.asPath.includes('/new')
// Filter by editor type - only show SQL tabs for SQL editor and table tabs for table editor
const editorTabs = !!editor
? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type))
@@ -79,8 +82,25 @@ export const EditorTabs = () => {
}
}
const cleanupDraftTabState = (tabId: string) => {
const tab = tabs.tabsMap[tabId]
const draftSqlId = tab?.metadata?.isDraft ? tab.metadata?.sqlId : undefined
if (draftSqlId && ref) {
snapV2.removeSnippet(draftSqlId, true)
clearPersistedDraftSqlTab(ref, draftSqlId)
}
}
const handleClose = (tabId: string) => {
tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
tabs.handleTabClose({
id: tabId,
router,
editor,
onClearDashboardHistory,
onClose: () => {
cleanupDraftTabState(tabId)
},
})
}
const handleCloseAll = () => {
@@ -90,6 +110,7 @@ export const EditorTabs = () => {
? tabs.openTabs.filter((x) => !isSqlEditorTab(x, tabs.tabsMap))
: tabs.openTabs.filter((x) => isSqlEditorTab(x, tabs.tabsMap))
tabsToClose.forEach(cleanupDraftTabState)
tabs.removeTabs(tabsToClose)
onClearDashboardHistory()
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`)
@@ -103,6 +124,7 @@ export const EditorTabs = () => {
? tabs.openTabs.filter((x) => !isSqlEditorTab(x, tabs.tabsMap) && x !== tabId)
: tabs.openTabs.filter((x) => isSqlEditorTab(x, tabs.tabsMap) && x !== tabId)
tabsToClose.forEach(cleanupDraftTabState)
tabs.removeTabs(tabsToClose)
onClearDashboardHistory()
@@ -121,6 +143,7 @@ export const EditorTabs = () => {
const tabIdx = openedTabs.indexOf(tabId)
const activeTabIdx = openedTabs.indexOf(tabs.activeTab!)
const tabsToClose = openedTabs.slice(tabIdx + 1)
tabsToClose.forEach(cleanupDraftTabState)
tabs.removeTabs(tabsToClose)
const isActiveTabClosed = tabIdx < activeTabIdx
@@ -139,7 +162,7 @@ export const EditorTabs = () => {
tabCount: editorTabs.length,
enabled: editor !== 'sql',
})
const activeTabValue = hasNewTab ? 'new' : (tabs.activeTab ?? undefined)
const activeTabValue = tabs.activeTab ?? undefined
const isSqlEditor = editor === 'sql'
const sortableTabs = (
@@ -174,54 +197,32 @@ export const EditorTabs = () => {
</SortableContext>
)
const newTabCloseButton = (
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleClose('new')
}}
>
<X size={12} className="text-foreground-light" />
</span>
)
const addTabButton = (
<AnimatePresence initial={false}>
{!hasNewTab && (
<motion.button
className={cn(
'flex shrink-0 items-center justify-center rounded-md hover:bg-surface-300',
isSqlEditor
? cn(SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME, 'aspect-square shrink-0')
: 'w-10 min-h-(--header-height) hover:bg-surface-100 border-b'
)}
onClick={() =>
router.push(
`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true`
)
<motion.button
className={cn(
'flex shrink-0 items-center justify-center rounded-md hover:bg-surface-300',
isSqlEditor
? cn(SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME, 'aspect-square shrink-0')
: 'w-10 min-h-(--header-height) hover:bg-surface-100 border-b'
)}
onClick={() => {
if (editor === 'sql') {
createDraftTab()
} else {
void router.push(`/project/${router.query.ref}/editor/new?skip=true`)
}
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<Plus
size={16}
strokeWidth={1.5}
className="text-foreground-lighter hover:text-foreground-light"
/>
</motion.button>
)}
}}
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<Plus
size={16}
strokeWidth={1.5}
className="text-foreground-lighter hover:text-foreground-light"
/>
</motion.button>
</AnimatePresence>
)
@@ -241,23 +242,9 @@ export const EditorTabs = () => {
onValueChange={(value) => {
if (value) handleTabChange(value)
}}
className="flex w-max min-w-0 max-w-full flex-nowrap justify-start overflow-hidden"
className="flex min-w-0 flex-1 flex-nowrap justify-start overflow-hidden"
>
{sortableTabs}
{hasNewTab && (
<ToggleGroupItem
value="new"
aria-label="New tab"
className={cn(
'group flex shrink-0 items-center gap-1.5 px-2 text-xs',
SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME
)}
>
<Plus size={14} strokeWidth={1.5} className="text-foreground-lighter" />
<span>New</span>
{newTabCloseButton}
</ToggleGroupItem>
)}
</ToggleGroup>
{addTabButton}
</div>
@@ -277,25 +264,6 @@ export const EditorTabs = () => {
)}
>
{sortableTabs}
{hasNewTab && (
<TabsTrigger_Shadcn_
value="new"
className={cn(
'flex items-center gap-2 px-3 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'relative group h-full border-t-2 border-b-0!',
'hover:bg-surface-300 dark:hover:bg-surface-100'
)}
>
<Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
<div className="flex items-center gap-0">
<span>New</span>
</div>
{newTabCloseButton}
<div className="absolute w-full -bottom-px left-0 right-0 h-px bg-dash-sidebar dark:bg-surface-100 opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger_Shadcn_>
)}
{addTabButton}
<div className="grow h-full border-b pr-6" />
</TabsList_Shadcn_>
+96 -50
View File
@@ -2,10 +2,15 @@ import { usePrevious } from '@uidotdev/usehooks'
import { useParams } from 'common/hooks/useParams'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
import {
createDraftSqlTab,
isDraftSqlSnippet,
restoreDraftSqlTab,
} from '@/components/interfaces/SQLEditor/createDraftSqlTab'
import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor'
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import { getSnippetQuerySource } from '@/components/interfaces/SQLEditor/sqlSnippet.utils'
@@ -18,6 +23,7 @@ import { useContentIdQuery } from '@/data/content/content-id-query'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
import { useQueryExecutionSourceSnapshot } from '@/state/query-execution-source'
import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
@@ -25,26 +31,32 @@ import type { NextPageWithLayout } from '@/types'
const SqlEditor: NextPageWithLayout = () => {
const router = useRouter()
const { id, ref, content, skip } = useParams()
const { id, ref, content, source } = useParams()
const previousRoute = usePrevious(id)
const { data: project } = useSelectedProjectQuery()
const { profile } = useProfile()
const creatingDraftRef = useRef(false)
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const querySourceState = useQueryExecutionSourceSnapshot()
const { history, setLastVisitedSnippet } = useDashboardHistory()
const { setLastVisitedSnippet } = useDashboardHistory()
const allSnippets = useSnippets(ref!)
const snippet = allSnippets.find((x) => x.id === id)
const tabId = id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined
const tab = tabId ? tabs.tabsMap[tabId] : undefined
const isDraftTab = isDraftSqlSnippet(snippet) || tab?.metadata?.isDraft === true
const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined
// [Joshen] May need to investigate separately, but occasionally addSnippet doesnt exist in
// the snapV2 valtio store for some reason hence why the added typeof check here
const canFetchContentBasedOnId = Boolean(
id !== 'new' && typeof snapV2.addSnippet === 'function' && !snippet?.isNotSavedInDatabaseYet
id &&
id !== 'new' &&
typeof snapV2.addSnippet === 'function' &&
!isDraftTab &&
!snippet?.isNotSavedInDatabaseYet
)
const { data, error, isError } = useContentIdQuery(
{ projectRef: ref, id },
{
@@ -57,42 +69,72 @@ const SqlEditor: NextPageWithLayout = () => {
isError && error.code === 404 && error.message.includes('Content not found')
const invalidId = isError && error.code === 400 && error.message.includes('Invalid uuid')
// [Joshen] Atm we suspect that replication lag is causing this to happen whereby a newly created snippet
// shows the "Unable to find snippet" error which blocks the whole UI
// Am opting to silently swallow this error, since the saves are still going through and we're scoping this behaviour
// behaviour down to a very specific use case too with all these conditionals
// More details: https://github.com/supabase/supabase/pull/39389
const snippetMissingImmediatelyAfterCreating =
!!snippet && snippetMissing && previousRoute === 'new' && 'isNotSavedInDatabaseYet' in snippet
useEffect(() => {
if (
id !== 'new' ||
!router.isReady ||
!ref ||
!project ||
!profile ||
creatingDraftRef.current
) {
return
}
creatingDraftRef.current = true
const initialSql = typeof content === 'string' ? content : ''
const querySource = source === 'logs' ? ('logs' as const) : ('database' as const)
const draftId = createDraftSqlTab({
projectRef: ref,
projectId: project.id,
ownerId: profile.id,
snapV2,
tabs,
initialSql,
querySource,
skipNavigation: true,
})
if (source === 'logs') {
querySourceState.setExecutionSource('logs')
}
void router.replace(`/project/${ref}/sql/${draftId}`)
}, [content, id, profile, project, querySourceState, ref, router, snapV2, source, tabs])
useEffect(() => {
if (!router.isReady || !id || id === 'new' || !ref || !project || !profile) return
if (!tab?.metadata?.isDraft || snippet) return
restoreDraftSqlTab({
draftId: id,
projectRef: ref,
projectId: project.id,
ownerId: profile.id,
snapV2,
name: tab.metadata?.name ?? generateSnippetTitle(),
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, id, ref, project, profile, tab?.metadata?.isDraft, snippet])
useEffect(() => {
if (ref && data && project) {
// [Joshen] Check if snippet belongs to the current project
if (!IS_PLATFORM || data.project_id === project.id) {
snapV2.setSnippet(ref, data as unknown as SnippetWithContent)
setLastVisitedSnippet(data.id)
} else {
setLastVisitedSnippet(undefined)
router.replace(`/project/${ref}/sql/new`)
void router.replace(`/project/${ref}/sql/new?skip=true`)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, data, project])
useEffect(() => {
if (!router.isReady || !ref || id === 'new') return
if (snippetMissing && !snippetMissingImmediatelyAfterCreating) {
setLastVisitedSnippet(undefined)
const tabId = createTabId('sql', { id })
if (tabs.openTabs.includes(tabId)) {
tabs.removeTab(tabId)
}
router.replace(`/project/${ref}/sql/new?skip=true`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, ref, id, snippetMissing, snippetMissingImmediatelyAfterCreating])
useEffect(() => {
if (router.query.source === 'logs') {
querySourceState.setExecutionSource('logs')
@@ -102,40 +144,44 @@ const SqlEditor: NextPageWithLayout = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.query.source, data])
// Load the last visited snippet when landing on /new
useEffect(() => {
if (
id === 'new' &&
skip !== 'true' && // [Joshen] Skip flag implies to skip loading the last visited snippet
history.sql !== undefined &&
content === undefined
) {
const snippet = allSnippets.find((snippet) => snippet.id === history.sql)
if (snippet !== undefined) router.replace(`/project/${ref}/sql/${history.sql}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, allSnippets, content])
// Watch for route changes
useEffect(() => {
if (!router.isReady || !id || id === 'new') return
const tabId = createTabId('sql', { id })
const snippet = allSnippets.find((x) => x.id === id)
const nextTabId = createTabId('sql', { id })
const routeSnippet = allSnippets.find((x) => x.id === id)
tabs.addTab({
id: tabId,
id: nextTabId,
type: 'sql',
label: snippet?.name || generateSnippetTitle(),
label: routeSnippet?.name || tab?.metadata?.name || generateSnippetTitle(),
metadata: {
sqlId: id,
name: snippet?.name,
name: routeSnippet?.name ?? tab?.metadata?.name,
isDraft: isDraftTab,
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, id])
if (snippetMissing && !snippetMissingImmediatelyAfterCreating) {
useEffect(() => {
if (!router.isReady || !ref || !id || id === 'new') return
if (snippetMissing && !snippetMissingImmediatelyAfterCreating && !isDraftTab) {
setLastVisitedSnippet(undefined)
const staleTabId = createTabId('sql', { id })
if (tabs.openTabs.includes(staleTabId)) {
tabs.removeTab(staleTabId)
}
void router.replace(`/project/${ref}/sql/new?skip=true`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, ref, id, snippetMissing, snippetMissingImmediatelyAfterCreating, isDraftTab])
if (id === 'new') {
return null
}
if (snippetMissing && !snippetMissingImmediatelyAfterCreating && !isDraftTab) {
return null
}
@@ -37,7 +37,7 @@ const SQLEditorIndexPage: NextPageWithLayout = () => {
router.replace(`/project/${projectRef}/sql/${lastTab.metadata.sqlId}`)
}
} else {
router.replace(`/project/${projectRef}/sql/new`)
router.replace(`/project/${projectRef}/sql/new?skip=true`)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
+27 -1
View File
@@ -6,10 +6,12 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio'
import { devtools, proxyMap } from 'valtio/utils'
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
import { persistDraftSqlTab } from '@/components/interfaces/SQLEditor/draftSqlTabStorage.utils'
import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types'
import {
buildSnippetUpsertContent,
getSnippetContentType,
getSnippetQuerySource,
getSnippetSqlFromContent,
} from '@/components/interfaces/SQLEditor/sqlSnippet.utils'
import { upsertContent, UpsertContentPayload } from '@/data/content/content-upsert-mutation'
@@ -33,10 +35,16 @@ type StateSnippet = {
snippet: SnippetWithContent
}
function isDraftSqlTabId(id: string) {
return sqlEditorState.snippets[id]?.snippet?.isDraftTab === true
}
// [Joshen] API codegen is somehow missing the content property
export interface SnippetWithContent extends Snippet {
content?: SqlSnippets.Content
isNotSavedInDatabaseYet?: boolean
/** Ephemeral SQL editor tab — not shown in the sidebar until saved */
isDraftTab?: boolean
}
const NEW_FOLDER_ID = 'new-folder'
@@ -168,7 +176,7 @@ export const sqlEditorState = proxy({
sql: sqlValue,
label: snippet.name,
})
} else {
} else if (!isDraftSqlTabId(id)) {
sqlEditorState.needsSaving.set(id, true)
}
}
@@ -220,6 +228,18 @@ export const sqlEditorState = proxy({
return
}
if (isDraftSqlTabId(id)) {
const stateSnippet = sqlEditorState.snippets[id]
if (stateSnippet) {
persistDraftSqlTab(stateSnippet.projectRef, id, {
sql,
name: snippet.name,
querySource: getSnippetQuerySource(snippet),
})
}
return
}
if (!skipSave) {
sqlEditorState.needsSaving.set(id, shouldInvalidate)
}
@@ -345,6 +365,7 @@ export const sqlEditorState = proxy({
}
return
}
if (isDraftSqlTabId(id)) return
sqlEditorState.needsSaving.set(id, true)
},
@@ -549,6 +570,11 @@ if (typeof window !== 'undefined') {
return
}
if (isDraftSqlTabId(id)) {
sqlEditorState.needsSaving.delete(id)
return
}
const {
name,
description,
+16 -4
View File
@@ -21,9 +21,7 @@ export function isSqlEditorTab(tabOrId: Tab | string, tabsMap?: Record<string, T
const tab = tabsMap?.[tabOrId]
if (tab) return SQL_EDITOR_TAB_TYPES.includes(tab.type)
return (
tabOrId.startsWith('sql-') ||
tabOrId.startsWith('notebook-') ||
tabOrId.startsWith('chat-')
tabOrId.startsWith('sql-') || tabOrId.startsWith('notebook-') || tabOrId.startsWith('chat-')
)
}
@@ -57,6 +55,7 @@ export interface Tab {
notebookId?: string
chatId?: string
scrollTop?: number
isDraft?: boolean
}
isPreview?: boolean
createdAt?: Date
@@ -244,7 +243,14 @@ export function createTabsState(projectRef: string) {
store.previewTabId = tab.id
store.activeTab = tab.id
},
updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
updateTab: (
id: string,
updates: {
label?: string
scrollTop?: number
metadata?: Partial<NonNullable<Tab['metadata']>>
}
) => {
if (!!store.tabsMap[id]) {
if ('label' in updates) {
store.tabsMap[id].label = updates.label
@@ -260,6 +266,12 @@ export function createTabsState(projectRef: string) {
if ('scrollTop' in updates && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata.scrollTop = updates.scrollTop
}
if (updates.metadata && store.tabsMap[id].metadata) {
store.tabsMap[id].metadata = {
...store.tabsMap[id].metadata,
...updates.metadata,
}
}
}
},
// Function to remove a tab from the store
@@ -48,6 +48,7 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,
SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`,
SQL_EDITOR_DRAFT_TABS: (ref: string) => `sql-editor-draft-tabs-${ref}`,
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>