feat(studio): add Explorer query tabs

This commit is contained in:
Saxon Fletcher committed 2026-08-13 16:11:30 +10:00
1 parent 47bba3df57
commit 72379ebf4e
15 files changed
+484 -51

No files matched your search

@@ -1,15 +1,14 @@
import { untrustedSql } from '@supabase/pg-meta'
import { MessageCirclePlus, NotebookText, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { useCreateNotebook } from './hooks'
import { useCreateNotebook, useCreateQuery } from './hooks'
import { ActionCard } from '@/components/layouts/Tabs/ActionCard'
import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm'
import { generateUuid } from '@/lib/api/snippets.browser'
import { AssistantModel } from '@/state/ai-assistant-state'
export const ExplorerHome = () => {
const { createNotebook } = useCreateNotebook()
const { createQuery } = useCreateQuery()
const [value, setValue] = useState<string>('')
const [selectedModel, setSelectedModal] = useState<AssistantModel>('gpt-5.4-nano')
@@ -52,19 +51,7 @@ export const ExplorerHome = () => {
title="Run SQL"
description="Write and run an ad-hoc query"
bgColor="bg-blue-500"
onClick={() =>
createNotebook({
name: 'SQL query',
cells: [
{
_tag: 'database_cell',
id: generateUuid(),
unchecked_sql: untrustedSql(''),
row_limit: 100,
},
],
})
}
onClick={createQuery}
/>
</div>
</section>
@@ -0,0 +1,45 @@
import { useParams } from 'common'
import { useContext, useEffect } from 'react'
import { explorerQueryState } from '@/state/explorer-query'
import { TabsStateContext } from '@/state/tabs'
/**
* Owns local query-draft cleanup and close confirmation for every Explorer page,
* including Explorer home where no individual query editor is mounted.
*/
export const ExplorerQueryTabCoordinator = () => {
const { ref } = useParams()
const tabs = useContext(TabsStateContext)
useEffect(() => {
return tabs.registerTabTypeHandler('query', {
confirmClose: (queryTabs) => {
const populatedDraftCount = queryTabs.filter((tab) => {
const queryId = tab.metadata?.queryId
if (!ref || !queryId) return false
explorerQueryState.restoreDraft({ id: queryId, projectRef: ref })
return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0
}).length
if (populatedDraftCount === 0) return null
return {
title: populatedDraftCount === 1 ? 'Discard query?' : 'Discard queries?',
description:
populatedDraftCount === 1
? 'This ad-hoc query is stored only in this browser. Closing the tab will discard it.'
: `These ${populatedDraftCount} ad-hoc queries are stored only in this browser. Closing their tabs will discard them.`,
}
},
onClose: (tab) => {
const queryId = tab.metadata?.queryId
if (ref && queryId) explorerQueryState.removeDraft({ id: queryId, projectRef: ref })
},
})
}, [ref, tabs])
return null
}
@@ -0,0 +1,89 @@
import { useParams } from 'common'
import { Loader2, SquareCode } from 'lucide-react'
import { useRouter } from 'next/router'
import { useContext, useEffect, useState } from 'react'
import { Button } from 'ui'
import { QueryEditor } from './QueryEditor'
import { type QueryResult } from './types'
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { createTabId, TabsStateContext } from '@/state/tabs'
const QUERY_ROW_LIMIT = 100
/** Query-tab lifecycle adapter around the shared QueryEditor. */
export const QueryTab = () => {
const { id, ref } = useParams()
const router = useRouter()
const tabs = useContext(TabsStateContext)
const querySnap = useExplorerQueryStateSnapshot()
const [hasRestored, setHasRestored] = useState(false)
const stateDraft = id ? querySnap.drafts[id] : undefined
const draft = stateDraft?.projectRef === ref ? stateDraft : undefined
const result = draft && id ? querySnap.results[id] : undefined
useEffect(() => {
if (!id || !ref) return
const restored = explorerQueryState.restoreDraft({ id, projectRef: ref })
const restoredDraft = explorerQueryState.drafts[id]
if (restored && restoredDraft) {
tabs.addTab({
id: createTabId('query', { id }),
type: 'query',
label: restoredDraft.name,
metadata: { queryId: id },
isPreview: false,
})
}
setHasRestored(true)
}, [id, ref, tabs])
if (!hasRestored) {
return (
<div className="flex h-full items-center justify-center bg-surface-100">
<Loader2 className="animate-spin text-foreground-muted" size={18} />
</div>
)
}
if (!id || !draft) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-100">
<SquareCode className="text-foreground-muted" size={24} />
<div className="text-center">
<p className="text-sm font-medium">Query draft not found</p>
<p className="text-sm text-foreground-lighter">
This local draft may have been closed or cleared from this browser.
</p>
</div>
<Button onClick={() => router.push(`/project/${ref}/explorer`)}>Back to Explorer</Button>
</div>
)
}
const handleResultChange = (nextResult: QueryResult) => {
explorerQueryState.setResult({
id,
result: { ...nextResult, executedAt: Date.now() },
})
}
return (
<QueryEditor
id={id}
variant="viewport"
title={draft.name}
sql={draft.uncheckedSql}
result={result}
rowLimit={QUERY_ROW_LIMIT}
onTitleChange={(value) => {
const name = value.trim() || 'Untitled query'
explorerQueryState.updateDraft({ id, name })
tabs.updateTab(createTabId('query', { id }), { label: name })
}}
onSqlChange={(sql) => explorerQueryState.updateDraft({ id, sql })}
onResultChange={handleResultChange}
/>
)
}
@@ -4,8 +4,10 @@ import { useRouter } from 'next/router'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { generateUuid } from '@/lib/api/snippets.browser'
import { useProfile } from '@/lib/profile'
import { useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { type Notebook } from '@/state/notebooks/types'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
import { Notebooks } from '@/types'
export const useCreateNotebook = () => {
@@ -78,3 +80,29 @@ This is a sample paragraph to demonstrate the Markdown cells
return { createNotebook }
}
export const useCreateQuery = () => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const querySnap = useExplorerQueryStateSnapshot()
const tabs = useTabsStateSnapshot()
const createQuery = () => {
if (!project) return console.error('Project is required')
const id = crypto.randomUUID()
querySnap.createDraft({ id, projectRef: project.ref })
tabs.addTab({
id: createTabId('query', { id }),
type: 'query',
label: 'Untitled query',
metadata: { queryId: id },
isPreview: false,
})
router.push(`/project/${project.ref}/explorer/query/${id}`)
return id
}
return { createQuery }
}
@@ -1,6 +1,6 @@
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Home, MessageCirclePlus, NotebookText, Plus } from 'lucide-react'
import { Home, MessageCirclePlus, NotebookText, Plus, SquareCode } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, ReactNode, useState } from 'react'
@@ -12,7 +12,8 @@ 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'
import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator'
import { useCreateNotebook, useCreateQuery } from '@/components/interfaces/Explorer/hooks'
export interface ExplorerLayoutProps extends ComponentProps<typeof ProjectLayoutWithAuth> {
children: ReactNode
@@ -49,6 +50,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
</div>
}
>
<ExplorerQueryTabCoordinator />
<div className="flex flex-col h-full">
<div className={cn('h-10 md:min-h-(--header-height) flex items-center bg-surface-100')}>
<EditorTabs
@@ -87,6 +89,7 @@ const HomeTabButton = () => {
const NewTabButton = () => {
const { createNotebook } = useCreateNotebook()
const { createQuery } = useCreateQuery()
return (
<DropdownMenu>
@@ -106,6 +109,10 @@ const NewTabButton = () => {
</motion.button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuItem className="gap-x-2" onClick={() => createQuery()}>
<SquareCode size={14} />
<span>New query</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => createNotebook()}>
<NotebookText size={14} />
<span>New notebook</span>
+9 -21
View File
@@ -50,7 +50,7 @@ export const EditorTabs = ({
newTabButton,
isCollapseButtonHidden,
}: EditorTabsProps) => {
const { ref, id } = useParams()
const { ref } = useParams()
const router = useRouter()
const { setLastVisitedSnippet, setLastVisitedTable } = useDashboardHistory()
@@ -76,6 +76,7 @@ export const EditorTabs = ({
const editorTabs = !!editor
? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type))
: []
const editorTabIds = editorTabs.map((tab) => tab.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -119,44 +120,32 @@ export const EditorTabs = ({
const handleCloseAll = () => {
if (editor) {
const tabsToClose =
editor === 'table'
? tabs.openTabs.filter((x) => !x.startsWith('sql'))
: tabs.openTabs.filter((x) => x.startsWith('sql'))
const tabsToClose = editorTabIds
closeWithConfirmation(tabsToClose, () => {
tabs.closeTabs(tabsToClose)
onClearDashboardHistory()
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`)
const editorPath = editor === 'table' ? 'editor' : editor
router.push(`/project/${ref}/${editorPath}`)
})
}
}
const handleCloseOthers = (tabId: string) => {
if (editor) {
const tabsToClose =
editor === 'table'
? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId)
: tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId)
const tabsToClose = editorTabIds.filter((id) => id !== tabId)
closeWithConfirmation(tabsToClose, () => {
tabs.closeTabs(tabsToClose)
onClearDashboardHistory()
const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
if (id !== entityId) {
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`)
}
tabs.handleTabNavigation(tabId, router)
})
}
}
const handleCloseRight = (tabId: string) => {
if (editor) {
const openedTabs =
editor === 'table'
? tabs.openTabs.filter((x) => !x.startsWith('sql'))
: tabs.openTabs.filter((x) => x.startsWith('sql'))
const openedTabs = editorTabIds
const tabIdx = openedTabs.indexOf(tabId)
const activeTabIdx = openedTabs.indexOf(tabs.activeTab!)
const tabsToClose = openedTabs.slice(tabIdx + 1)
@@ -166,8 +155,7 @@ export const EditorTabs = ({
const isActiveTabClosed = tabIdx < activeTabIdx
if (isActiveTabClosed) {
const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`)
tabs.handleTabNavigation(tabId, router)
}
})
}
+6 -2
View File
@@ -1,4 +1,4 @@
import { Eye, GitBranch, NotebookText, ScrollText, Table2 } from 'lucide-react'
import { Eye, GitBranch, NotebookText, ScrollText, SquareCode, 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' | 'notebook'
type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook' | 'query'
size?: number
strokeWidth?: number
isActive?: boolean
@@ -107,6 +107,10 @@ export const EntityTypeIcon = ({
return <NotebookText size={size} strokeWidth={strokeWidth} className={''} />
}
if (type === 'query') {
return <SquareCode size={size} strokeWidth={strokeWidth} />
}
return (
<div
className={cn(
@@ -0,0 +1,14 @@
import { QueryTab } from '@/components/interfaces/Explorer/QueryTab'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout'
import type { NextPageWithLayout } from '@/types'
const QueryPage: NextPageWithLayout = () => <QueryTab />
QueryPage.getLayout = (page) => (
<DefaultLayout>
<ExplorerLayout>{page}</ExplorerLayout>
</DefaultLayout>
)
export default QueryPage
+22
View File
@@ -229,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 ProjectRefExplorerQueryIdRouteImport } from './routes/project/$ref/explorer/query/$id'
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'
@@ -1516,6 +1517,12 @@ const ProjectRefFunctionsFunctionSlugCodeRoute =
path: '/code',
getParentRoute: () => ProjectRefFunctionsFunctionSlugRoute,
} as any)
const ProjectRefExplorerQueryIdRoute =
ProjectRefExplorerQueryIdRouteImport.update({
id: '/query/$id',
path: '/query/$id',
getParentRoute: () => ProjectRefExplorerRoute,
} as any)
const ProjectRefExplorerNotebookIdRoute =
ProjectRefExplorerNotebookIdRouteImport.update({
id: '/notebook/$id',
@@ -2283,6 +2290,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -2580,6 +2588,7 @@ export interface FileRoutesByTo {
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -2893,6 +2902,7 @@ export interface FileRoutesById {
'/project/$ref/database/triggers/data': typeof ProjectRefDatabaseTriggersDataRoute
'/project/$ref/database/triggers/event': typeof ProjectRefDatabaseTriggersEventRoute
'/project/$ref/explorer/notebook/$id': typeof ProjectRefExplorerNotebookIdRoute
'/project/$ref/explorer/query/$id': typeof ProjectRefExplorerQueryIdRoute
'/project/$ref/functions/$functionSlug/code': typeof ProjectRefFunctionsFunctionSlugCodeRoute
'/project/$ref/functions/$functionSlug/details': typeof ProjectRefFunctionsFunctionSlugDetailsRoute
'/project/$ref/functions/$functionSlug/invocations': typeof ProjectRefFunctionsFunctionSlugInvocationsRoute
@@ -3205,6 +3215,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/explorer/query/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -3502,6 +3513,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/explorer/query/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -3814,6 +3826,7 @@ export interface FileRouteTypes {
| '/project/$ref/database/triggers/data'
| '/project/$ref/database/triggers/event'
| '/project/$ref/explorer/notebook/$id'
| '/project/$ref/explorer/query/$id'
| '/project/$ref/functions/$functionSlug/code'
| '/project/$ref/functions/$functionSlug/details'
| '/project/$ref/functions/$functionSlug/invocations'
@@ -5561,6 +5574,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectRefFunctionsFunctionSlugCodeRouteImport
parentRoute: typeof ProjectRefFunctionsFunctionSlugRoute
}
'/project/$ref/explorer/query/$id': {
id: '/project/$ref/explorer/query/$id'
path: '/query/$id'
fullPath: '/project/$ref/explorer/query/$id'
preLoaderRoute: typeof ProjectRefExplorerQueryIdRouteImport
parentRoute: typeof ProjectRefExplorerRoute
}
'/project/$ref/explorer/notebook/$id': {
id: '/project/$ref/explorer/notebook/$id'
path: '/notebook/$id'
@@ -6517,11 +6537,13 @@ const ProjectRefEditorRouteWithChildren =
interface ProjectRefExplorerRouteChildren {
ProjectRefExplorerIndexRoute: typeof ProjectRefExplorerIndexRoute
ProjectRefExplorerNotebookIdRoute: typeof ProjectRefExplorerNotebookIdRoute
ProjectRefExplorerQueryIdRoute: typeof ProjectRefExplorerQueryIdRoute
}
const ProjectRefExplorerRouteChildren: ProjectRefExplorerRouteChildren = {
ProjectRefExplorerIndexRoute: ProjectRefExplorerIndexRoute,
ProjectRefExplorerNotebookIdRoute: ProjectRefExplorerNotebookIdRoute,
ProjectRefExplorerQueryIdRoute: ProjectRefExplorerQueryIdRoute,
}
const ProjectRefExplorerRouteWithChildren =
@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import QueryPage from '@/pages/project/[ref]/explorer/query/[id]'
export const Route = createFileRoute('/project/$ref/explorer/query/$id')({
component: ProjectExplorerQueryRoute,
})
function ProjectExplorerQueryRoute() {
return <QueryPage dehydratedState={undefined} />
}
+47
View File
@@ -0,0 +1,47 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { describe, expect, it } from 'vitest'
import { createExplorerQueryState } from './explorer-query'
const createMemoryStorage = () => {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
describe('explorer query drafts', () => {
it('persists and restores drafts within their project', () => {
const storage = createMemoryStorage()
const firstState = createExplorerQueryState(storage)
firstState.createDraft({ id: 'query-1', projectRef: 'project-a' })
firstState.updateDraft({ id: 'query-1', name: 'Active users', sql: 'select * from users' })
const secondState = createExplorerQueryState(storage)
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-a' })).toBe(true)
expect(secondState.drafts['query-1']).toMatchObject({
name: 'Active users',
uncheckedSql: 'select * from users',
projectRef: 'project-a',
})
expect(secondState.restoreDraft({ id: 'query-1', projectRef: 'project-b' })).toBe(false)
})
it('removes the persisted draft and its session result when its tab closes', () => {
const storage = createMemoryStorage()
const state = createExplorerQueryState(storage)
state.createDraft({ id: 'query-1', projectRef: 'project-a', sql: 'select 1' })
state.setResult({ id: 'query-1', result: { rows: [{ value: 1 }], executedAt: 1 } })
state.removeDraft({ id: 'query-1', projectRef: 'project-a' })
expect(state.drafts['query-1']).toBeUndefined()
expect(state.results['query-1']).toBeUndefined()
expect(storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'))).toBeNull()
})
})
+156
View File
@@ -0,0 +1,156 @@
import { untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import { type QueryResult } from '@/components/interfaces/Explorer/types'
export type ExplorerQueryDraft = {
id: string
projectRef: string
name: string
uncheckedSql: UntrustedSqlFragment
updatedAt: number
}
export type ExplorerQueryResult = QueryResult & {
executedAt: number
}
type PersistedExplorerQueryDraft = {
name: string
sql: string
updatedAt: number
}
type PersistedExplorerQueryDrafts = Record<string, PersistedExplorerQueryDraft>
type StorageLike = Pick<typeof safeLocalStorage, 'getItem' | 'setItem' | 'removeItem'>
const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
const raw = storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef))
if (!raw) return {} as PersistedExplorerQueryDrafts
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, PersistedExplorerQueryDraft] => {
const draft = entry[1]
return (
draft !== null &&
typeof draft === 'object' &&
'name' in draft &&
typeof draft.name === 'string' &&
'sql' in draft &&
typeof draft.sql === 'string' &&
'updatedAt' in draft &&
typeof draft.updatedAt === 'number'
)
})
)
} catch {
return {} as PersistedExplorerQueryDrafts
}
}
const writePersistedDrafts = (
storage: StorageLike,
projectRef: string,
drafts: PersistedExplorerQueryDrafts
) => {
const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS(projectRef)
if (Object.keys(drafts).length === 0) storage.removeItem(key)
else storage.setItem(key, JSON.stringify(drafts))
}
export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage) => {
const state = proxy({
drafts: {} as Record<string, ExplorerQueryDraft>,
results: {} as Record<string, ExplorerQueryResult>,
createDraft: ({
id,
projectRef,
name = 'Untitled query',
sql = '',
}: {
id: string
projectRef: string
name?: string
sql?: string
}) => {
const draft: ExplorerQueryDraft = {
id,
projectRef,
name,
uncheckedSql: untrustedSql(sql),
updatedAt: Date.now(),
}
state.drafts[id] = draft
const persisted = readPersistedDrafts(storage, projectRef)
persisted[id] = { name, sql, updatedAt: draft.updatedAt }
writePersistedDrafts(storage, projectRef, persisted)
return id
},
restoreDraft: ({ id, projectRef }: { id: string; projectRef: string }) => {
if (state.drafts[id]?.projectRef === projectRef) return true
const persisted = readPersistedDrafts(storage, projectRef)[id]
if (!persisted) return false
state.drafts[id] = {
id,
projectRef,
name: persisted.name,
uncheckedSql: untrustedSql(persisted.sql),
updatedAt: persisted.updatedAt,
}
return true
},
updateDraft: ({ id, name, sql }: { id: string; name?: string; sql?: string }) => {
const draft = state.drafts[id]
if (!draft) return
if (name !== undefined) draft.name = name
if (sql !== undefined) draft.uncheckedSql = untrustedSql(sql)
draft.updatedAt = Date.now()
const persisted = readPersistedDrafts(storage, draft.projectRef)
persisted[id] = {
name: draft.name,
sql: draft.uncheckedSql,
updatedAt: draft.updatedAt,
}
writePersistedDrafts(storage, draft.projectRef, persisted)
},
removeDraft: ({ id, projectRef }: { id: string; projectRef: string }) => {
if (state.drafts[id]?.projectRef === projectRef) {
delete state.drafts[id]
delete state.results[id]
}
const persisted = readPersistedDrafts(storage, projectRef)
delete persisted[id]
writePersistedDrafts(storage, projectRef, persisted)
},
setResult: ({ id, result }: { id: string; result: ExplorerQueryResult }) => {
state.results[id] = ref(result)
},
})
return state
}
export const explorerQueryState = createExplorerQueryState()
export const getExplorerQueryStateSnapshot = () => snapshot(explorerQueryState)
export const useExplorerQueryStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(explorerQueryState, options)
+22
View File
@@ -307,3 +307,25 @@ describe('tabs close handlers', () => {
expect(store.handlerRegistrationVersion).toBeGreaterThan(afterRegister)
})
})
describe('explorer query tabs', () => {
beforeEach(() => {
localStorage.clear()
})
it('navigates to a query tab using its Explorer route', () => {
const store = createTabsState('default')
const router = fakeRouter()
store.addTab({
id: 'query-query-1',
type: 'query',
label: 'Untitled query',
metadata: { queryId: 'query-1' },
isPreview: false,
})
store.handleTabNavigation('query-query-1', router)
expect(router.push).toHaveBeenCalledWith('/project/default/explorer/query/query-1')
})
})
+21 -10
View File
@@ -19,10 +19,10 @@ import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
export const editorEntityTypes = {
table: ['r', 'v', 'm', 'f', 'p'],
sql: ['sql'],
explorer: ['notebook'],
explorer: ['notebook', 'query'],
}
export type TabType = ENTITY_TYPE | 'sql' | 'notebook'
export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query'
type CreateTabIdParams = {
r: { id: number }
@@ -32,6 +32,7 @@ type CreateTabIdParams = {
p: { id: number }
sql: { id: string }
notebook: { id: string }
query: { id: string }
schema: { schema: string }
view: never
function: never
@@ -48,6 +49,7 @@ export interface Tab {
tableId?: number
sqlId?: string
notebookId?: string
queryId?: string
scrollTop?: number
/**
* For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`),
@@ -111,6 +113,8 @@ export interface RecentItem {
name?: string
tableId?: number
sqlId?: string
notebookId?: string
queryId?: string
sqlSource?: SqlSnippetSource
}
}
@@ -391,6 +395,9 @@ export function createTabsState(projectRef: string) {
case 'notebook':
router.push(`/project/${router.query.ref}/explorer/notebook/${tab.metadata?.notebookId}`)
break
case 'query':
router.push(`/project/${router.query.ref}/explorer/query/${tab.metadata?.queryId}`)
break
case 'r':
case 'v':
case 'm':
@@ -525,6 +532,7 @@ export function createTabsState(projectRef: string) {
router.push(`/project/${router.query.ref}/sql`)
break
case 'notebook':
case 'query':
router.push(`/project/${router.query.ref}/explorer`)
break
case 'r':
@@ -553,17 +561,18 @@ export function createTabsState(projectRef: string) {
router,
onClearDashboardHistory,
}: {
editor: 'sql' | 'table'
editor: 'sql' | 'table' | 'explorer'
router: NextRouter
onClearDashboardHistory: () => void
}) => {
const tabsToClose =
editor === 'table'
? store.openTabs.filter((x) => !x.startsWith('sql'))
: store.openTabs.filter((x) => x.startsWith('sql'))
store.removeTabs(tabsToClose)
const tabsToClose = store.openTabs.filter((id) => {
const tab = store.tabsMap[id]
return tab !== undefined && editorEntityTypes[editor].includes(tab.type)
})
store.closeTabs(tabsToClose)
onClearDashboardHistory()
router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`)
const editorPath = editor === 'table' ? 'editor' : editor
router.push(`/project/${router.query.ref}/${editorPath}`)
},
handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => {
// Make permanent if needed
@@ -647,7 +656,9 @@ export function createTabId<T extends TabType>(type: T, params: CreateTabIdParam
case 'sql':
return `sql-${(params as CreateTabIdParams['sql']).id}`
case 'notebook':
return `notebook-${(params as CreateTabIdParams['sql']).id}`
return `notebook-${(params as CreateTabIdParams['notebook']).id}`
case 'query':
return `query-${(params as CreateTabIdParams['query']).id}`
default:
return ''
}
@@ -52,6 +52,8 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`,
SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED: 'sql-editor-manual-save-notice-dismissed',
EXPLORER_QUERY_DRAFTS: (ref: string) => `explorer-query-drafts-${ref}`,
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>
`graphql-introspection-notice-collapsed-${ref}`,