mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
feat(studio): add Explorer query tabs
This commit is contained in:
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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user