This commit is contained in:
Saxon Fletcher committed 2026-09-04 20:38:13 +10:00
1 parent 84d089f1e7
commit f90471ba40
19 files changed
+833 -92

No files matched your search

@@ -94,8 +94,14 @@ export const ExplorerQueryTab = () => {
useEffect(() => {
if (!id || !ref) return
setShowQuery(true)
explorerQueryState.restoreDraft({ id, projectRef: ref })
// A table is opened for its rows, so it lands on the results with the query tucked away;
// an ad-hoc query opens on the editor the user came to write in.
const restored = explorerQueryState.drafts[id]
const isEntityQuery = restored?._tag === 'database' && restored.entity !== undefined
setShowQuery(!isEntityQuery)
setRestoredQueryKey(`${ref}:${id}`)
}, [id, ref])
@@ -138,6 +144,7 @@ export const ExplorerQueryTab = () => {
...toQuerySourceBinding(draft),
uncheckedSql: draft.uncheckedSql,
rowLimit: draft.rowLimit,
entity: draft.entity,
}
const persistTab = () => tabs.makeTabPermanent(createTabId('query', { id }))
@@ -183,6 +190,9 @@ export const ExplorerQueryTab = () => {
title={draft.name}
query={query}
result={result}
// A table is opened to see its rows, so it runs itself. The editor honors this only
// while the query is still the generated one — an edited query waits for Run.
autoRun
display={display}
showQuery={showQuery}
onShowQueryChange={setShowQuery}
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { useContext, useEffect } from 'react'
import { explorerQueryState } from '@/state/explorer-query'
import { explorerQueryState, hasDiscardableContent } from '@/state/explorer-query'
import { TabsStateContext } from '@/state/tabs'
/**
@@ -23,7 +23,8 @@ export const ExplorerQueryTabCoordinator = () => {
const populatedDraftCount = queryTabs.filter((tab) => {
const queryId = tab.metadata?.queryId
if (!queryId) return false
return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0
const draft = explorerQueryState.drafts[queryId]
return draft !== undefined && hasDiscardableContent(draft)
}).length
if (populatedDraftCount === 0) return null
@@ -1,5 +1,10 @@
import { useMonaco } from '@monaco-editor/react'
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import {
acceptUntrustedSql,
untrustedSql,
type SafeSqlFragment,
type UntrustedSqlFragment,
} from '@supabase/pg-meta'
import { useFlag } from 'common'
import { CodeSquare, Eye, EyeOff } from 'lucide-react'
import type { editor as monacoEditor, Selection } from 'monaco-editor'
@@ -15,6 +20,7 @@ import {
import { Button, cn } from 'ui'
import { resolveLogTimeRange } from '../../QuerySources/LogTimeRange.utils'
import { buildEntitySelectSql, type QueryEntityBinding } from '../entityQuery.utils'
import {
ExplorerQuery,
ExplorerQueryEditor,
@@ -99,6 +105,8 @@ export type ExplorerQueryModel =
_tag: 'database'
uncheckedSql: UntrustedSqlFragment
rowLimit?: number
/** Set when this query was opened from a database object rather than typed. */
entity?: QueryEntityBinding
} & DatabaseSourceParameters)
| ({
_tag: 'logs'
@@ -133,6 +141,15 @@ type QueryEditorProps = {
onShowQueryChange: (showQuery: boolean) => void
/** When true, toolbar and editor run actions are disabled. */
isRunDisabled?: boolean
/**
* Runs the query once on open, without waiting for the user to hit Run — for a surface
* whose whole point is the rows, such as a table opened from the sidebar.
*
* Honored only while the query still holds exactly the SQL its `entity` generates. An
* automatic run has no gesture behind it to approve the editor's untrusted text, so the
* SQL that runs is rebuilt from the binding instead of promoted from the buffer.
*/
autoRun?: boolean
onTitleChange: (title: string) => void
onSqlChange: (sql: string) => void
onSqlCommit?: (sql: string) => void
@@ -163,6 +180,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
showQuery,
onShowQueryChange,
isRunDisabled = false,
autoRun = false,
onTitleChange,
onSqlChange,
onSqlCommit,
@@ -240,6 +258,41 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
const isExecuting = isExecutingSql || isExecutingLogs
const isBusy = isLoadingProject || isResolvingDatabase || isExecuting
/**
* Runs SQL that has already cleared the untrusted boundary — promoted by a run gesture in
* `handleRunQuery`, or generated by Studio itself for an automatic run. Splitting it out is
* what lets the automatic path execute without ever promoting the editor's buffer.
*/
const executeDatabaseSql = async (
safeSql: SafeSqlFragment,
querySnapshot: { sql: string; source: 'database' | 'logs' }
) => {
if (!project) return
const limitedSql = applyAutoLimit(safeSql, rowLimit)
if (!isValidConnString(connectionString)) {
onResultChange({
error: { message: 'Unable to run query: Connection string is missing' },
...querySnapshot,
})
return
}
await executeSql({
projectRef: project.ref,
connectionString,
sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState),
autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined,
contextualInvalidation: true,
isStatementTimeoutDisabled: true,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
}).then(
(data) => onResultChange({ rows: data.result, ...querySnapshot }),
(error) => onResultChange({ error, ...querySnapshot })
)
}
const handleRunQuery = async ({
rawSql = sql,
shouldForce = false,
@@ -288,31 +341,28 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
return
}
const safeSql = acceptUntrustedSql(untrustedSql(rawSql))
const limitedSql = applyAutoLimit(safeSql, rowLimit)
if (!isValidConnString(connectionString)) {
onResultChange({
error: { message: 'Unable to run query: Connection string is missing' },
...querySnapshot,
})
return
}
await executeSql({
projectRef: project.ref,
connectionString,
sql: wrapWithRoleImpersonation(limitedSql.sql, roleImpersonationState),
autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined,
contextualInvalidation: true,
isStatementTimeoutDisabled: true,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
}).then(
(data) => onResultChange({ rows: data.result, ...querySnapshot }),
(error) => onResultChange({ error, ...querySnapshot })
)
await executeDatabaseSql(acceptUntrustedSql(untrustedSql(rawSql)), querySnapshot)
}
/**
* The SQL an entity-backed query generates, rebuilt from the binding rather than read from
* the editor. `autoRun` is honored only while the two still match: once the user edits the
* query it is their own untrusted text, which may not run without a gesture.
*/
const generatedSql =
query._tag === 'database' && query.entity ? buildEntitySelectSql(query.entity) : undefined
// Compared trimmed, which also drops the branding both sides carry — the point is whether
// the text on screen is still the text Studio generated.
const canAutoRun = autoRun && generatedSql !== undefined && generatedSql.trim() === sql.trim()
const runGeneratedQueryRef = useLatest(() => {
if (generatedSql === undefined) return
// No blocking-issue check: `generatedSql` is a plain select Studio built itself, and a
// confirmation modal has no meaning on a run the user did not ask for.
onRun?.()
executeDatabaseSql(generatedSql, { sql: generatedSql, source: 'database' })
})
const handleConfirmPendingRun = () => {
if (!pendingRun) return
const runSql = pendingRun.sql
@@ -381,6 +431,20 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
prettify: handlePrettify,
}))
// Runs once per query, and only once the project has resolved — the same conditions that
// enable the Run button. Skipped when a result is already in hand, so returning to an open
// tab shows what it had rather than re-querying.
const autoRunKeyRef = useRef<string>(undefined)
const isReadyToAutoRun =
canAutoRun && project !== undefined && result === undefined && !isBusy && !isRunDisabled
useEffect(() => {
if (!isReadyToAutoRun || autoRunKeyRef.current === id) return
autoRunKeyRef.current = id
runGeneratedQueryRef.current()
}, [isReadyToAutoRun, id, runGeneratedQueryRef])
useEffect(() => {
if (!promptState?.isOpen) return
const node = editorInstanceRef.current?.getDomNode()
@@ -431,6 +495,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
icon={
showQuery ? <EyeOff size={16} strokeWidth={2} /> : <Eye size={16} strokeWidth={2} />
}
aria-label={showQuery ? 'Hide query' : 'Show query'}
disabled={pendingProposal !== null}
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => onShowQueryChange(!showQuery)}
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ExplorerQueryTab } from '../ExplorerQueryTab'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import type { ReadReplicasData } from '@/data/read-replicas/replicas-query'
import { explorerQueryState } from '@/state/explorer-query'
import { createTabId, createTabsState, TabsStateContext } from '@/state/tabs'
@@ -166,6 +167,39 @@ const createDraft = (
})
}
const TABLE_ENTITY = { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE } as const
const createEntityDraft = (sql: string = 'select * from public.users') => {
explorerQueryState.removeDraft({ id: 'query-test', projectRef: 'default' })
explorerQueryState.createDraft({
id: 'query-test',
projectRef: 'default',
sql,
source: { _tag: 'database' },
entity: TABLE_ENTITY,
})
}
/**
* Intellisense, event triggers and other background fetches share the pg-meta query endpoint
* with executions, each tagged by a `key` search param. A run carries no key.
*/
const captureQueryExecutions = () => {
const bodies: Array<{ query: string }> = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const key = new URL(request.url).searchParams.get('key')
if (!key) bodies.push((await request.json()) as { query: string })
return HttpResponse.json([])
},
})
return bodies
}
beforeEach(() => {
setupSqlEditorMocks()
testContext.flags.otelLegacyLogs = true
@@ -536,3 +570,52 @@ describe('QueryTab execution', () => {
expect(executedQueries[0]).toContain('select 2')
})
})
describe('QueryTab auto-run', () => {
it('runs a table query on open without waiting for the user', async () => {
createEntityDraft()
const executions = captureQueryExecutions()
renderQueryTab()
await waitFor(() => expect(executions).toHaveLength(1))
expect(executions[0]?.query).toContain('select * from public.users')
})
it('runs only once, even as the editor re-renders', async () => {
createEntityDraft()
const executions = captureQueryExecutions()
renderQueryTab()
await waitFor(() => expect(executions).toHaveLength(1))
await userEvent.click(await screen.findByRole('button', { name: 'Show query' }))
expect(executions).toHaveLength(1)
})
it('waits for Run once the query is no longer the one it generated', async () => {
createEntityDraft('select id from public.users')
const executions = captureQueryExecutions()
renderQueryTab()
const runButton = await screen.findByRole('button', { name: 'Run' })
await waitFor(() => expect(runButton).toBeEnabled())
expect(executions).toHaveLength(0)
await userEvent.click(runButton)
await waitFor(() => expect(executions).toHaveLength(1))
})
it('leaves an ad-hoc query alone', async () => {
createDraft({ _tag: 'database' }, 'select 1')
const executions = captureQueryExecutions()
renderQueryTab()
const runButton = await screen.findByRole('button', { name: 'Run' })
await waitFor(() => expect(runButton).toBeEnabled())
expect(executions).toHaveLength(0)
})
})
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { buildEntitySelectSql, entityQueryId } from './entityQuery.utils'
describe('entityQueryId', () => {
it('is stable for the same entity, so reopening a table returns to the same draft', () => {
expect(entityQueryId({ schema: 'public', name: 'users' })).toBe(
entityQueryId({ schema: 'public', name: 'users' })
)
})
it('separates schema from name, so identifiers containing the separator cannot collide', () => {
expect(entityQueryId({ schema: 'a:b', name: 'c' })).not.toBe(
entityQueryId({ schema: 'a', name: 'b:c' })
)
})
})
describe('buildEntitySelectSql', () => {
it('reads the relation by its qualified name', () => {
expect(buildEntitySelectSql({ schema: 'public', name: 'users' })).toBe(
'select * from public.users'
)
})
it('escapes identifiers that would otherwise break out of the quoting', () => {
expect(buildEntitySelectSql({ schema: 'public', name: 'we"ird' })).toBe(
'select * from public."we""ird"'
)
})
})
@@ -0,0 +1,38 @@
import { ident, safeSql } from '@supabase/pg-meta'
import { z } from 'zod'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
/** Entity types the sidebar's Tables level lists, and that open as a `select *` query. */
export const TABLE_ENTITY_TYPES: ENTITY_TYPE[] = [ENTITY_TYPE.TABLE, ENTITY_TYPE.PARTITIONED_TABLE]
/**
* The database entity a query was opened from. A query opened this way is an ordinary
* editable query — the binding records only where it came from, which is what lets its tab
* show the entity's icon, lets closing it skip the discard prompt, and is where in-place
* editing of the results will hang off once the query editor supports it.
*/
export const queryEntityBindingSchema = z.object({
schema: z.string(),
name: z.string(),
type: z.nativeEnum(ENTITY_TYPE),
})
export type QueryEntityBinding = z.infer<typeof queryEntityBindingSchema>
/**
* Deterministic draft id for an entity, so clicking the same table twice returns to the one
* tab — and to whatever the user had edited in it — rather than stacking up new drafts.
* Both parts are percent-encoded, which escapes the separator, so no pair of identifiers
* can produce the same id. The `entity:` prefix keeps these out of the uuid namespace that
* ad-hoc queries use.
*/
export const entityQueryId = ({ schema, name }: Pick<QueryEntityBinding, 'schema' | 'name'>) =>
`entity:${encodeURIComponent(schema)}:${encodeURIComponent(name)}`
/** The query an entity opens with. Regenerated rather than stored, so it stays reproducible. */
export const buildEntitySelectSql = ({
schema,
name,
}: Pick<QueryEntityBinding, 'schema' | 'name'>) =>
safeSql`select * from ${ident(schema)}.${ident(name)}`
@@ -1,6 +1,7 @@
import { useRouter } from 'next/router'
import { useEffect, useEffectEvent, useState } from 'react'
import { buildEntitySelectSql, entityQueryId, type QueryEntityBinding } from './entityQuery.utils'
import { useNotebookQuery } from '@/data/content/notebooks/notebook-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -8,7 +9,7 @@ import { generateUuid } from '@/lib/api/snippets.browser'
import { useProfile } from '@/lib/profile'
import type { AssistantModel } from '@/state/ai-assistant-state'
import { useAiAssistantState, whenAiAssistantInitialized } from '@/state/ai-assistant-state'
import { useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { type Notebook } from '@/state/notebooks/types'
import { Notebooks } from '@/types'
@@ -161,3 +162,38 @@ export const useCreateQuery = () => {
return { createQuery }
}
/**
* Opens a database entity as an ordinary Explorer query — a `select *` draft keyed by the
* entity, landing in the same tab, editor and result surface as any query the user types
* themselves. An entity is not a separate kind of thing to open; it is a query with a known
* origin, which is all the extra the draft records.
*/
export const useOpenEntityQuery = () => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const openEntityQuery = (entity: QueryEntityBinding) => {
if (!project) return console.error('Project is required')
const id = entityQueryId(entity)
// Reuses the draft when one already exists, so reopening a table returns the user to
// whatever they had edited there rather than resetting it.
if (!explorerQueryState.restoreDraft({ id, projectRef: project.ref })) {
explorerQueryState.createDraft({
id,
projectRef: project.ref,
name: `${entity.schema}.${entity.name}`,
sql: buildEntitySelectSql(entity),
entity,
})
}
router.push(`/project/${project.ref}/explorer/query/${id}`)
return id
}
return { openEntityQuery }
}
@@ -1,32 +1,46 @@
import { motion } from 'framer-motion'
import { ChevronLeft, MessageSquare, NotebookText, Plus } from 'lucide-react'
import { type ComponentType, type PropsWithChildren } from 'react'
import { ChevronLeft, Database, MessageSquare, NotebookText, Plus } from 'lucide-react'
import { type ComponentType, type PropsWithChildren, type ReactNode } from 'react'
import { Button, cn } from 'ui'
import { InnerSideBarFilters, InnerSideBarFilterSearchInput } from 'ui-patterns/InnerSideMenu'
import { useCreateChat, useCreateNotebook } from '@/components/interfaces/Explorer/hooks'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
/** Explorer resources the sidebar can both list and create. */
export type ExplorerResourceType = 'notebook' | 'chat'
/**
* A level in the sidebar's drill-down stack. The layout keeps these on a stack, so going
* back is a pop and a new level costs one member here plus the panel that renders it.
*/
export type ExplorerNavLevel = ExplorerResourceType | 'database' | 'database-tables'
type ExplorerNavIcon = ComponentType<{ size?: number; className?: string }>
/** Icon and filter copy per resource, shared by its panel and the recently updated list. */
export const EXPLORER_RESOURCES: Record<
ExplorerResourceType,
{ icon: ExplorerNavIcon; searchPlaceholder: string }
> = {
notebook: { icon: NotebookText, searchPlaceholder: 'Search notebooks' },
chat: { icon: MessageSquare, searchPlaceholder: 'Search chats' },
}
/** Top-level sidebar destinations, in display order. */
export const EXPLORER_SECTIONS: Array<{
level: ExplorerNavLevel
label: string
icon: ExplorerNavIcon
}> = [
{ level: 'database', label: 'Database', icon: Database },
{ level: 'notebook', label: 'Notebooks', icon: EXPLORER_RESOURCES.notebook.icon },
{ level: 'chat', label: 'Chats', icon: EXPLORER_RESOURCES.chat.icon },
]
export const LEVEL_OFFSET = 8
export const LEVEL_TRANSITION = { duration: 0.09, ease: 'easeOut' } as const
export const EXPLORER_SECTIONS: Array<{
type: ExplorerResourceType
label: string
icon: ComponentType<{ size?: number; className?: string }>
searchPlaceholder: string
}> = [
{
type: 'notebook',
label: 'Notebooks',
icon: NotebookText,
searchPlaceholder: 'Search notebooks',
},
{ type: 'chat', label: 'Chats', icon: MessageSquare, searchPlaceholder: 'Search chats' },
]
export const rowClassName = (isActive: boolean) =>
cn(
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm',
@@ -35,26 +49,30 @@ export const rowClassName = (isActive: boolean) =>
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
)
export const ExplorerNavResourceWrapper = ({
type,
/**
* One level of the sidebar: the sliding panel, its back control, an optional filter input,
* and an optional action. Levels differ only in what they list, so they compose this rather
* than each rebuilding the header.
*/
export const ExplorerNavPanel = ({
label,
className,
children,
search,
setSearch,
searchPlaceholder,
action,
onBack,
}: PropsWithChildren<{
type: ExplorerResourceType
label?: string
label: string
className?: string
search?: string
setSearch: (value: string) => void
/** Renders the filter input when given; otherwise the header shows `label` as a title. */
setSearch?: (value: string) => void
searchPlaceholder?: string
action?: ReactNode
onBack: () => void
}>) => {
const { createNotebook } = useCreateNotebook()
const { createChat } = useCreateChat()
const searchPlaceholder = EXPLORER_SECTIONS.find((x) => x.type === type)?.searchPlaceholder
return (
<motion.div
role="group"
@@ -74,18 +92,61 @@ export const ExplorerNavResourceWrapper = ({
className="size-7 shrink-0 px-0"
icon={<ChevronLeft />}
/>
<span id="explorer-sidebar-search-label" className="sr-only">
{searchPlaceholder}
</span>
<InnerSideBarFilters className="w-full gap-0 p-0">
<InnerSideBarFilterSearchInput
name="explorer-sidebar-search"
value={search}
placeholder={searchPlaceholder}
aria-labelledby="explorer-sidebar-search-label"
onChange={(event) => setSearch(event.target.value)}
/>
</InnerSideBarFilters>
{setSearch === undefined ? (
<span className="flex-1 truncate text-sm text-foreground">{label}</span>
) : (
<>
<span id="explorer-sidebar-search-label" className="sr-only">
{searchPlaceholder}
</span>
<InnerSideBarFilters className="w-full gap-0 p-0">
<InnerSideBarFilterSearchInput
name="explorer-sidebar-search"
value={search}
placeholder={searchPlaceholder}
aria-labelledby="explorer-sidebar-search-label"
onChange={(event) => setSearch(event.target.value)}
/>
</InnerSideBarFilters>
</>
)}
{action}
</div>
{children}
</motion.div>
)
}
/** An `ExplorerNavPanel` for a creatable resource, adding its "new" action to the header. */
export const ExplorerNavResourceWrapper = ({
type,
label,
className,
children,
search,
setSearch,
onBack,
}: PropsWithChildren<{
type: ExplorerResourceType
label?: string
className?: string
search?: string
setSearch: (value: string) => void
onBack: () => void
}>) => {
const { createNotebook } = useCreateNotebook()
const { createChat } = useCreateChat()
const { searchPlaceholder } = EXPLORER_RESOURCES[type]
return (
<ExplorerNavPanel
label={label ?? EXPLORER_SECTIONS.find((section) => section.level === type)?.label ?? type}
className={className}
search={search}
setSearch={setSearch}
searchPlaceholder={searchPlaceholder}
onBack={onBack}
action={
<ButtonTooltip
size="tiny"
variant="outline"
@@ -98,8 +159,9 @@ export const ExplorerNavResourceWrapper = ({
if (type === 'chat') createChat()
}}
/>
</div>
}
>
{children}
</motion.div>
</ExplorerNavPanel>
)
}
@@ -15,10 +15,12 @@ import {
import { EditorNavigationButton } from '../EditorNavigationButton'
import { ProjectLayoutWithAuth } from '../ProjectLayout'
import { EditorTabs } from '../Tabs/Tabs'
import { type ExplorerResourceType } from './ExplorerLayout.constants'
import { type ExplorerNavLevel } from './ExplorerLayout.constants'
import { ExplorerNavChats } from './ExplorerNavChats'
import { ExplorerNavDatabase } from './ExplorerNavDatabase'
import { ExplorerNavHome } from './ExplorerNavHome'
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
import { ExplorerNavTables } from './ExplorerNavTables'
import { ExplorerGeneratedPageTabCoordinator } from '@/components/interfaces/Explorer/ExplorerGeneratedPageTabCoordinator'
import { ExplorerNotebookTabCoordinator } from '@/components/interfaces/Explorer/ExplorerNotebookTabCoordinator'
import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator'
@@ -46,7 +48,13 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
const { ref } = useParams()
const tabs = useTabsStateSnapshot()
const [section, setSection] = useState<ExplorerResourceType>()
// A stack rather than a single value, so drilling in is a push and going back is a pop —
// which is what lets Database sit two levels deep without the layout tracking parents.
const [navStack, setNavStack] = useState<ExplorerNavLevel[]>([])
const level = navStack.at(-1)
const pushLevel = (next: ExplorerNavLevel) => setNavStack((stack) => [...stack, next])
const popLevel = () => setNavStack((stack) => stack.slice(0, -1))
const { setIsTemporary: setIsTemporarySqlEditorVisit } = useIsTemporarySqlEditorVisit(ref)
@@ -73,13 +81,15 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
productMenu={
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{section === undefined && <ExplorerNavHome key="home" onSelectSection={setSection} />}
{section === 'notebook' && (
<ExplorerNavNotebooks key="notebooks" onBack={() => setSection(undefined)} />
{level === undefined && <ExplorerNavHome key="home" onSelectLevel={pushLevel} />}
{level === 'database' && (
<ExplorerNavDatabase key="database" onBack={popLevel} onSelectLevel={pushLevel} />
)}
{section === 'chat' && (
<ExplorerNavChats key="chats" onBack={() => setSection(undefined)} />
{level === 'database-tables' && (
<ExplorerNavTables key="database-tables" onBack={popLevel} />
)}
{level === 'notebook' && <ExplorerNavNotebooks key="notebooks" onBack={popLevel} />}
{level === 'chat' && <ExplorerNavChats key="chats" onBack={popLevel} />}
</AnimatePresence>
</div>
}
@@ -0,0 +1,42 @@
import { ChevronRight, Table2 } from 'lucide-react'
import { type ComponentType } from 'react'
import { ExplorerNavPanel, rowClassName, type ExplorerNavLevel } from './ExplorerLayout.constants'
/**
* Database object kinds, in sidebar order. Tables ship first; functions, triggers and the
* rest each add a row here plus the panel their level drills into.
*/
const DATABASE_SECTIONS: Array<{
level: ExplorerNavLevel
label: string
icon: ComponentType<{ size?: number; className?: string }>
}> = [{ level: 'database-tables', label: 'Tables', icon: Table2 }]
export const ExplorerNavDatabase = ({
onBack,
onSelectLevel,
}: {
onBack: () => void
onSelectLevel: (level: ExplorerNavLevel) => void
}) => {
return (
<ExplorerNavPanel label="Database" onBack={onBack}>
<nav className="flex flex-col gap-px px-3 pb-3">
{DATABASE_SECTIONS.map(({ level, label, icon: Icon }) => (
<button
key={level}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => onSelectLevel(level)}
>
<Icon size={14} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
</button>
))}
</nav>
</ExplorerNavPanel>
)
}
@@ -5,11 +5,12 @@ import Link from 'next/link'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import {
EXPLORER_RESOURCES,
EXPLORER_SECTIONS,
ExplorerResourceType,
LEVEL_OFFSET,
LEVEL_TRANSITION,
rowClassName,
type ExplorerNavLevel,
} from './ExplorerLayout.constants'
import { formatRelativeTimeShort, getRecentlyUpdatedItems } from './ExplorerNavHome.utils'
import { useCreateChat } from '@/components/interfaces/Explorer/hooks'
@@ -18,9 +19,9 @@ import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-in
import { useAiAssistantChatList } from '@/state/ai-assistant-state'
export const ExplorerNavHome = ({
onSelectSection,
onSelectLevel,
}: {
onSelectSection: (section: ExplorerResourceType) => void
onSelectLevel: (level: ExplorerNavLevel) => void
}) => {
const { ref } = useParams()
const { openChat } = useCreateChat()
@@ -50,24 +51,22 @@ export const ExplorerNavHome = ({
className="absolute inset-0 flex flex-col gap-4 overflow-y-auto p-3"
>
<nav className="flex flex-col gap-px">
{EXPLORER_SECTIONS.map(({ type, label, icon: Icon }) => {
{EXPLORER_SECTIONS.map(({ level, label, icon: Icon }) => {
return (
<button
key={type}
key={level}
type="button"
tabIndex={0}
className={rowClassName(false)}
onClick={() => onSelectSection(type)}
onClick={() => onSelectLevel(level)}
>
<Icon size={14} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
{type === 'notebook' ? (
isPending ? (
<ShimmeringLoader className="w-3 py-2" />
) : (
<span className="text-xs text-foreground-lighter">{notebookCount}</span>
)
) : (
{level === 'notebook' && isPending && <ShimmeringLoader className="w-3 py-2" />}
{level === 'notebook' && !isPending && (
<span className="text-xs text-foreground-lighter">{notebookCount}</span>
)}
{level === 'chat' && (
<span className="text-xs text-foreground-lighter">{chats.length}</span>
)}
<ChevronRight size={14} className="shrink-0 text-foreground-muted" />
@@ -84,10 +83,10 @@ export const ExplorerNavHome = ({
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
) : (
recentItems.map((item) => {
const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon
const { icon: Icon } = EXPLORER_RESOURCES[item.type]
const content = (
<>
{Icon && <Icon size={14} className="shrink-0" aria-hidden="true" />}
<Icon size={14} className="shrink-0" aria-hidden="true" />
<span className="flex-1 truncate text-left">{item.label}</span>
<span className="shrink-0 text-xs text-foreground-lighter">
{formatRelativeTimeShort(item.updatedAt)}
@@ -0,0 +1,95 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentType, PropsWithChildren } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ExplorerNavTables } from './ExplorerNavTables'
import { entityQueryId } from '@/components/interfaces/Explorer/entityQuery.utils'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import { customRender } from '@/tests/lib/custom-render'
const openEntityQuery = vi.fn()
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
useParams: () => ({ id: entityQueryId({ schema: 'public', name: 'posts' }) }),
}
})
vi.mock('next/router', () => ({
useRouter: () => ({ pathname: '/project/[ref]/explorer/query/[id]' }),
}))
vi.mock('./ExplorerLayout.constants', () => ({
ExplorerNavPanel: ({ children }: PropsWithChildren) => <div>{children}</div>,
rowClassName: (isActive: boolean) => (isActive ? 'active' : 'inactive'),
}))
vi.mock('@/components/ui/SchemaSelector', () => ({ SchemaSelector: () => null }))
// The virtualizer measures a zero-height container in jsdom and renders no rows, so this
// stands in for it to exercise the row itself.
type Row = { id: number; schema: string; name: string; type: ENTITY_TYPE }
vi.mock('@/components/ui/InfiniteList', () => ({
LoaderForIconMenuItems: () => null,
InfiniteListDefault: ({
items,
itemProps,
ItemComponent,
}: {
items: Row[]
itemProps: object
ItemComponent: ComponentType<{ item: Row }>
}) => items.map((item) => <ItemComponent key={item.id} item={item} {...itemProps} />),
}))
vi.mock('@/components/interfaces/Explorer/hooks', () => ({
useOpenEntityQuery: () => ({ openEntityQuery }),
}))
vi.mock('@/hooks/misc/useSchemaQueryState', () => ({
useQuerySchemaState: () => ({ selectedSchema: 'public', setSelectedSchema: vi.fn() }),
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: () => ({ data: { ref: 'abc', connectionString: 'postgres://' } }),
}))
vi.mock('@/data/entity-types/entity-types-infinite-query', () => ({
useEntityTypesQuery: () => ({
data: {
pages: [
{
data: {
entities: [
{ id: 1, schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
{ id: 2, schema: 'public', name: 'posts', type: ENTITY_TYPE.TABLE },
],
},
},
],
},
isPending: false,
}),
}))
describe('ExplorerNavTables', () => {
it('marks the table backing the open query and opens the clicked one as a query', async () => {
customRender(<ExplorerNavTables onBack={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'users' })).toBeInTheDocument())
expect(screen.getByRole('button', { name: 'posts' })).toHaveClass('active')
expect(screen.getByRole('button', { name: 'users' })).toHaveClass('inactive')
await userEvent.click(screen.getByRole('button', { name: 'users' }))
expect(openEntityQuery).toHaveBeenCalledWith({
schema: 'public',
name: 'users',
type: ENTITY_TYPE.TABLE,
})
})
})
@@ -0,0 +1,132 @@
import { useDebounce } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import { cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ExplorerNavPanel, rowClassName } from './ExplorerLayout.constants'
import {
entityQueryId,
TABLE_ENTITY_TYPES,
type QueryEntityBinding,
} from '@/components/interfaces/Explorer/entityQuery.utils'
import { useOpenEntityQuery } from '@/components/interfaces/Explorer/hooks'
import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
import {
InfiniteListDefault,
LoaderForIconMenuItems,
type RowComponentBaseProps,
} from '@/components/ui/InfiniteList'
import { SchemaSelector } from '@/components/ui/SchemaSelector'
import { useEntityTypesQuery, type Entity } from '@/data/entity-types/entity-types-infinite-query'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const TABLE_ROW_HEIGHT = 28
type TableListItemProps = RowComponentBaseProps<Entity> & {
activeQueryId: string | undefined
onOpen: (entity: QueryEntityBinding) => void
}
const TableListItem = ({ item: table, style, activeQueryId, onOpen }: TableListItemProps) => {
const entity = { schema: table.schema, name: table.name, type: table.type }
const isActive = activeQueryId === entityQueryId(entity)
return (
<button
type="button"
tabIndex={0}
className={rowClassName(isActive)}
style={style}
onClick={() => onOpen(entity)}
>
<EntityTypeIcon type={table.type} size={14} isActive={isActive} />
<span className={cn('truncate text-left', isActive && 'text-foreground')}>{table.name}</span>
</button>
)
}
/**
* Lists the tables of the selected schema. Opening one is not a separate destination — it
* creates the query that reads it, so a table lands in the same tab, editor and results as
* any other query.
*/
export const ExplorerNavTables = ({ onBack }: { onBack: () => void }) => {
const router = useRouter()
const { id } = useParams()
const { data: project } = useSelectedProjectQuery()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const { openEntityQuery } = useOpenEntityQuery()
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 500)
const {
data: entitiesData,
isPending,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useEntityTypesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schemas: [selectedSchema],
search: search.length === 0 ? search : debouncedSearch,
sort: 'alphabetical',
filterTypes: TABLE_ENTITY_TYPES,
limit: 100,
})
const tables = useMemo(
() => entitiesData?.pages.flatMap((page) => page.data.entities) ?? [],
[entitiesData?.pages]
)
const activeQueryId = router.pathname.includes('/explorer/query/') ? id : undefined
return (
<ExplorerNavPanel
label="Tables"
search={search}
setSearch={setSearch}
searchPlaceholder="Search tables"
onBack={onBack}
>
<div className="px-3 pb-2">
<SchemaSelector
size="tiny"
selectedSchemaName={selectedSchema}
onSelectSchema={setSelectedSchema}
/>
</div>
<div className="flex flex-1 min-h-0 flex-col px-3 pb-3">
{isPending && <GenericSkeletonLoader />}
{!isPending && tables.length === 0 && (
<p className="px-2 py-2 text-xs text-foreground-lighter">
{search ? 'No tables found' : 'No tables in this schema'}
</p>
)}
{!isPending && tables.length > 0 && (
<InfiniteListDefault
className="h-full w-full"
items={tables}
itemProps={{ activeQueryId, onOpen: openEntityQuery }}
ItemComponent={TableListItem}
LoaderComponent={LoaderForIconMenuItems}
getItemKey={(index) => {
const table = tables[index]
return table ? `${table.schema}.${table.name}` : `table-${index}`
}}
getItemSize={() => TABLE_ROW_HEIGHT}
gap={1}
hasNextPage={hasNextPage}
isLoadingNextPage={isFetchingNextPage}
onLoadNextPage={fetchNextPage}
/>
)}
</div>
</ExplorerNavPanel>
)
}
@@ -105,7 +105,11 @@ export const SortableTab = ({
)}
{...listeners}
>
<EntityTypeIcon type={tab.type} sqlSource={tab.metadata?.sqlSource} />
<EntityTypeIcon
type={tab.type}
sqlSource={tab.metadata?.sqlSource}
entityType={tab.metadata?.entityType}
/>
<div className="flex items-center gap-0">
<AnimatePresence mode="popLayout" initial>
{shouldShowSchema && (
@@ -53,6 +53,8 @@ interface EntityTypeIconProps {
strokeWidth?: number
isActive?: boolean
sqlSource?: SqlSnippetSource
/** For a `query`, the database object it was opened from, if any. */
entityType?: ENTITY_TYPE
}
export const EntityTypeIcon = ({
@@ -61,7 +63,15 @@ export const EntityTypeIcon = ({
strokeWidth = 1.5,
isActive,
sqlSource,
entityType,
}: EntityTypeIconProps) => {
// A query opened from a database object reads as that object, not as a generic query.
if (type === 'query' && entityType !== undefined) {
return (
<EntityTypeIcon type={entityType} size={size} strokeWidth={strokeWidth} isActive={isActive} />
)
}
if (type === 'sql' && sqlSource === 'logs') {
return (
<LogsSnippetIcon
@@ -22,7 +22,10 @@ const QueryPage: NextPageWithLayout = () => {
id: createTabId('query', { id }),
type: 'query',
label: draft.name,
metadata: { queryId: id },
metadata: {
queryId: id,
entityType: draft._tag === 'database' ? draft.entity?.type : undefined,
},
isPreview: true,
})
}
+80
View File
@@ -4,8 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createExplorerQueryState,
EXPLORER_QUERY_PERSIST_DELAY,
hasDiscardableContent,
MAX_PERSISTED_EXPLORER_QUERY_DRAFTS,
} from './explorer-query'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
const createMemoryStorage = () => {
const values = new Map<string, string>()
@@ -438,4 +440,82 @@ describe('explorer query drafts', () => {
expect(state.results['query-1']).toBeUndefined()
expect(storage.getItem(LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'))).toBeNull()
})
it('persists and restores the entity a draft was opened from', () => {
const storage = createMemoryStorage()
const firstState = createExplorerQueryState(storage)
firstState.createDraft({
id: 'entity-1',
projectRef: 'project-a',
name: 'public.users',
sql: 'select * from public.users',
entity: { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
})
firstState.updateDraft({ id: 'entity-1', sql: 'select id from public.users' })
firstState.flushPendingPersistence()
const secondState = createExplorerQueryState(storage)
secondState.restoreDraft({ id: 'entity-1', projectRef: 'project-a' })
expect(secondState.drafts['entity-1']).toMatchObject({
uncheckedSql: 'select id from public.users',
entity: { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE },
})
})
it('drops a malformed persisted entity rather than failing to restore the draft', () => {
const storage = createMemoryStorage()
storage.setItem(
LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('project-a'),
JSON.stringify({
'entity-1': {
name: 'public.users',
sql: 'select * from public.users',
updatedAt: 1,
entity: { schema: 'public' },
},
})
)
const state = createExplorerQueryState(storage)
expect(state.restoreDraft({ id: 'entity-1', projectRef: 'project-a' })).toBe(true)
expect(state.drafts['entity-1']).toMatchObject({ entity: undefined })
})
})
describe('hasDiscardableContent', () => {
const entity = { schema: 'public', name: 'users', type: ENTITY_TYPE.TABLE }
const draftWith = ({ sql, withEntity }: { sql: string; withEntity: boolean }) => {
const state = createExplorerQueryState(createMemoryStorage())
state.createDraft({
id: 'draft',
projectRef: 'project-a',
sql,
entity: withEntity ? entity : undefined,
})
return state.drafts['draft']!
}
it('treats an empty draft as nothing to discard', () => {
expect(hasDiscardableContent(draftWith({ sql: ' ', withEntity: false }))).toBe(false)
})
it('treats any ad-hoc query with content as discardable', () => {
expect(hasDiscardableContent(draftWith({ sql: 'select 1', withEntity: false }))).toBe(true)
})
it('treats an unedited entity draft as reproducible, so closing it needs no prompt', () => {
expect(
hasDiscardableContent(draftWith({ sql: 'select * from public.users', withEntity: true }))
).toBe(false)
})
it('treats an edited entity draft as discardable', () => {
expect(
hasDiscardableContent(draftWith({ sql: 'select id from public.users', withEntity: true }))
).toBe(true)
})
})
+34
View File
@@ -3,6 +3,11 @@ import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import { z } from 'zod'
import {
buildEntitySelectSql,
queryEntityBindingSchema,
type QueryEntityBinding,
} from '@/components/interfaces/Explorer/entityQuery.utils'
import { DEFAULT_CELL_ROW_LIMIT } from '@/components/interfaces/Explorer/QueryCell/QueryCell.utils'
import { type QueryDisplay, type QueryResult } from '@/components/interfaces/Explorer/types'
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
@@ -41,6 +46,7 @@ export type DatabaseQueryDraft = ExplorerQueryDraftBase &
uncheckedSql: UntrustedSqlFragment
rowLimit: number
role?: ImpersonationRole
entity?: QueryEntityBinding
}
export type LogsQueryDraft = ExplorerQueryDraftBase &
@@ -67,6 +73,7 @@ type PersistedExplorerQueryDraft = {
updatedAt: number
rowLimit?: number
role?: ImpersonationRole
entity?: QueryEntityBinding
} & QueryDisplay
type PersistedExplorerQueryDrafts = Record<string, PersistedExplorerQueryDraft>
@@ -84,6 +91,7 @@ const persistedDraftSchema = z.object({
source: z.unknown().optional(),
rowLimit: z.unknown().optional(),
role: z.unknown().optional(),
entity: z.unknown().optional(),
view: z.unknown().optional(),
chart: z.unknown().optional(),
})
@@ -142,6 +150,7 @@ const toDraft = ({
uncheckedSql: untrustedSql(persisted.sql),
rowLimit: persisted.rowLimit ?? DEFAULT_CELL_ROW_LIMIT,
role: persisted.role,
entity: persisted.entity,
}
}
@@ -170,6 +179,8 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
const view = parsedView.success ? parsedView.data : 'table'
const parsedChart = chartConfigSchema.safeParse(draft.data.chart)
const chart = parsedChart.success ? parsedChart.data : undefined
const parsedEntity = queryEntityBindingSchema.safeParse(draft.data.entity)
const entity = parsedEntity.success ? parsedEntity.data : undefined
return [
[
@@ -183,6 +194,7 @@ const readPersistedDrafts = (storage: StorageLike, projectRef: string) => {
rowLimit,
view,
chart,
entity,
},
],
]
@@ -209,6 +221,22 @@ const writePersistedDrafts = (
else storage.setItem(key, JSON.stringify(retainedDrafts))
}
/**
* Whether closing a draft would actually lose something. An entity-backed draft still sitting
* on its generated `select *` is reproducible — reopening the table rebuilds it — so only a
* draft the user has since edited (or an ad-hoc query with any content at all) is worth a
* discard prompt.
*/
export const hasDiscardableContent = (draft: ExplorerQueryDraft) => {
const sql = draft.uncheckedSql.trim()
if (sql.length === 0) return false
const entity = draft._tag === 'database' ? draft.entity : undefined
if (!entity) return true
return sql !== buildEntitySelectSql(entity).trim()
}
export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage) => {
const pendingPersistence = new Map<
string,
@@ -224,6 +252,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
updatedAt: draft.updatedAt,
rowLimit: draft._tag === 'database' ? draft.rowLimit : undefined,
role: draft._tag === 'database' ? draft.role : undefined,
entity: draft._tag === 'database' ? draft.entity : undefined,
view: draft.view,
chart: draft.chart,
}
@@ -241,6 +270,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
sql = '',
source = createDefaultSourceBinding('database'),
rowLimit = DEFAULT_CELL_ROW_LIMIT,
entity,
}: {
id: string
projectRef: string
@@ -248,6 +278,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
sql?: string
source?: QuerySourceBinding
rowLimit?: number
entity?: QueryEntityBinding
}) => {
const draft = toDraft({
id,
@@ -258,6 +289,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
sql,
updatedAt: Date.now(),
rowLimit,
entity,
view: 'table',
},
})
@@ -311,6 +343,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
const currentRowLimit = draft._tag === 'database' ? draft.rowLimit : undefined
const currentRole = draft._tag === 'database' ? draft.role : undefined
const currentEntity = draft._tag === 'database' ? draft.entity : undefined
state.drafts[id] = toDraft({
id,
@@ -322,6 +355,7 @@ export const createExplorerQueryState = (storage: StorageLike = safeLocalStorage
updatedAt: Date.now(),
rowLimit: rowLimit ?? currentRowLimit,
role: currentRole,
entity: currentEntity,
view: draft.view,
chart: draft.chart,
},
+6
View File
@@ -92,6 +92,12 @@ export interface Tab {
* it never goes stale once set).
*/
sqlSource?: SqlSnippetSource
/**
* For query tabs opened from a database object rather than typed from scratch, which
* entity backs the query — so the tab shows that object's icon instead of a generic
* query one. Absent on ad-hoc queries.
*/
entityType?: ENTITY_TYPE
}
isPreview?: boolean
/**