From f90471ba40d11e98fa3afd112fa2e7b01be9347a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 4 Sep 2026 20:38:13 +1000 Subject: [PATCH] tables --- .../interfaces/Explorer/ExplorerQueryTab.tsx | 12 +- .../Explorer/ExplorerQueryTabCoordinator.tsx | 5 +- .../interfaces/Explorer/QueryEditor/index.tsx | 113 +++++++++++--- .../Explorer/__tests__/QueryTab.test.tsx | 83 ++++++++++ .../Explorer/entityQuery.utils.test.ts | 31 ++++ .../interfaces/Explorer/entityQuery.utils.ts | 38 +++++ .../components/interfaces/Explorer/hooks.ts | 38 ++++- .../ExplorerLayout.constants.tsx | 142 +++++++++++++----- .../layouts/ExplorerLayout/ExplorerLayout.tsx | 24 ++- .../ExplorerLayout/ExplorerNavDatabase.tsx | 42 ++++++ .../ExplorerLayout/ExplorerNavHome.tsx | 29 ++-- .../ExplorerLayout/ExplorerNavTables.test.tsx | 95 ++++++++++++ .../ExplorerLayout/ExplorerNavTables.tsx | 132 ++++++++++++++++ .../components/layouts/Tabs/SortableTab.tsx | 6 +- apps/studio/components/ui/EntityTypeIcon.tsx | 10 ++ .../project/[ref]/explorer/query/[id].tsx | 5 +- apps/studio/state/explorer-query.test.ts | 80 ++++++++++ apps/studio/state/explorer-query.ts | 34 +++++ apps/studio/state/tabs.tsx | 6 + 19 files changed, 833 insertions(+), 92 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/entityQuery.utils.test.ts create mode 100644 apps/studio/components/interfaces/Explorer/entityQuery.utils.ts create mode 100644 apps/studio/components/layouts/ExplorerLayout/ExplorerNavDatabase.tsx create mode 100644 apps/studio/components/layouts/ExplorerLayout/ExplorerNavTables.test.tsx create mode 100644 apps/studio/components/layouts/ExplorerLayout/ExplorerNavTables.tsx diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx index b33cf18958f..bebebeea452 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx @@ -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} diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx index 51848b4f862..26bec0b2265 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index f8fc7176096..a8debbf3056 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -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(funct showQuery, onShowQueryChange, isRunDisabled = false, + autoRun = false, onTitleChange, onSqlChange, onSqlCommit, @@ -240,6 +258,41 @@ export const QueryEditor = forwardRef(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(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(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(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(funct icon={ showQuery ? : } + aria-label={showQuery ? 'Hide query' : 'Show query'} disabled={pendingProposal !== null} tooltip={showQuery ? 'Hide query' : 'Show query'} onClick={() => onShowQueryChange(!showQuery)} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx index e9c2dbf799f..a78376299fb 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx @@ -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) + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/entityQuery.utils.test.ts b/apps/studio/components/interfaces/Explorer/entityQuery.utils.test.ts new file mode 100644 index 00000000000..b7fdf38bd4c --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/entityQuery.utils.test.ts @@ -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"' + ) + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/entityQuery.utils.ts b/apps/studio/components/interfaces/Explorer/entityQuery.utils.ts new file mode 100644 index 00000000000..97cad53d397 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/entityQuery.utils.ts @@ -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 + +/** + * 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) => + `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) => + safeSql`select * from ${ident(schema)}.${ident(name)}` diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index 6d152d9ffb2..92e097cc464 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -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 } +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx index 70bdf43bf6f..ebfb5bb49e2 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx @@ -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 ( } /> - - {searchPlaceholder} - - - setSearch(event.target.value)} - /> - + {setSearch === undefined ? ( + {label} + ) : ( + <> + + {searchPlaceholder} + + + setSearch(event.target.value)} + /> + + + )} + {action} + + {children} + + ) +} + +/** 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 ( + section.level === type)?.label ?? type} + className={className} + search={search} + setSearch={setSearch} + searchPlaceholder={searchPlaceholder} + onBack={onBack} + action={ - + } + > {children} - + ) } diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 03b9ad36930..b59b9a549f0 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -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() + // 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([]) + 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={
- {section === undefined && } - {section === 'notebook' && ( - setSection(undefined)} /> + {level === undefined && } + {level === 'database' && ( + )} - {section === 'chat' && ( - setSection(undefined)} /> + {level === 'database-tables' && ( + )} + {level === 'notebook' && } + {level === 'chat' && }
} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavDatabase.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavDatabase.tsx new file mode 100644 index 00000000000..3c70cd31fda --- /dev/null +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavDatabase.tsx @@ -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 ( + + + + ) +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx index 547e3d3470f..b40ccf911c0 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx @@ -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" >