From ace9422bfd3f54d9e25c5986dcfee53a4764b5fb Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 13 Aug 2026 20:59:30 +1000 Subject: [PATCH 1/5] refactor(studio): share Explorer query editor (#49041) ## Stack Depends on #49027. Followed by #49038. ## Summary - extract a controlled `QueryEditor` from the existing notebook query cell - reuse it from `QueryCell`, leaving notebook persistence and sortable-block behavior in the adapter - make table/chart result settings controlled so other query surfaces can share them - persist notebook SQL on editor blur and query execution ## To test 1. Open a notebook query cell, edit and rename it, then run the query and confirm results appear. 2. Switch between table and chart results and confirm notebook move/delete actions still work. ## Why Notebooks, query tabs, and future chat tabs need consistent query actions and result rendering without duplicating the notebook implementation. ## Impact This is primarily a refactor of the existing notebook query experience. It introduces no new query-tab routes or source-selection behavior. ## Validation - fresh non-incremental Studio TypeScript check - focused NotebookEditor component tests ## Summary by CodeRabbit * **New Features** * Added a shared query editor with SQL editing, execution, validation, visibility controls, editable titles, row limits, and loading/error states. * Added table and chart result views, including customizable bar and line charts. * Added support for switching display modes and updating chart settings. * **Improvements** * Improved query result handling and display-setting updates. * Repositioned the logarithmic-scale tooltip for better visibility. --------- Co-authored-by: Joshen Lim --- .../QueryCell/DisplaySettingsButton.tsx | 67 ++----- .../Explorer/QueryCell/QueryResultChart.tsx | 9 +- .../interfaces/Explorer/QueryCell/index.tsx | 177 ++++-------------- .../interfaces/Explorer/QueryEditor.tsx | 172 +++++++++++++++++ .../components/interfaces/Explorer/types.ts | 20 +- .../ui/QueryBlock/QueryBlock.utils.ts | 6 +- 6 files changed, 253 insertions(+), 198 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/QueryEditor.tsx diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx index 29a8ee384277d..70c07a3bae55b 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/DisplaySettingsButton.tsx @@ -18,34 +18,29 @@ import { TooltipTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { type Snapshot } from 'valtio' import { ExplorerToolbarAction } from '../ExplorerToolbar' -import { type QueryResult } from '../types' +import { type QueryChartConfig, type QueryDisplay, type QueryResult } from '../types' import { checkHasNonPositiveValues } from '@/components/ui/QueryBlock/QueryBlock.utils' -import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' -import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' interface DisplaySettingsButtonProps { - cell: Snapshot + display: QueryDisplay result?: QueryResult columns: string[] disabled: boolean + onChange: (display: QueryDisplay) => void } // [Joshen] TODO support multiple y axis charts export const DisplaySettingsButton = ({ - cell, + display, result, columns, disabled, + onChange, }: DisplaySettingsButtonProps) => { - const snap = useNotebooksStateSnapshot() - const currentNotebook = useCurrentNotebook() - const cells = currentNotebook?.notebook.content?.cells ?? [] - - const { view, chart } = cell + const { view, chart } = display const { type = 'bar', x_column, @@ -66,44 +61,22 @@ export const DisplaySettingsButton = ({ }, [hasNonPositiveValues, result, y_columns.length]) const onChangeView = (view: 'table' | 'chart') => { - const notebookId = currentNotebook?.notebook.id - if (!notebookId) return - - const nextCells = cells.map((c) => - c.id === cell.id && c._tag === 'database_cell' ? { ...c, view } : c - ) - snap.updateCells({ id: notebookId, cells: nextCells }) + onChange({ ...display, view }) } - const onUpdateChartConfig = ( - payload: - | { type: 'bar' | 'line' } - | { x_column: string } - | { y_columns: string[] } - | { cumulative: boolean } - | { show_labels: boolean } - | { scale: 'linear' | 'log' } - ) => { - const notebookId = currentNotebook?.notebook.id - if (!notebookId) return - - const nextCells = cells.map((c) => { - if (c.id !== cell.id || c._tag !== 'database_cell') return c - - return { - ...c, - chart: { - type: c.chart?.type ?? 'bar', - x_column: c.chart?.x_column ?? '', - y_columns: c.chart?.y_columns ?? [], - cumulative: c.chart?.cumulative ?? false, - scale: c.chart?.scale ?? 'linear', - show_labels: c.chart?.show_labels ?? false, - ...payload, - }, - } + const onUpdateChartConfig = (payload: Partial) => { + onChange({ + ...display, + chart: { + type: chart?.type ?? 'bar', + x_column: chart?.x_column ?? '', + y_columns: chart?.y_columns ?? [], + cumulative: chart?.cumulative ?? false, + scale: chart?.scale ?? 'linear', + show_labels: chart?.show_labels ?? false, + ...payload, + }, }) - snap.updateCells({ id: notebookId, cells: nextCells }) } const resetToLinearScale = useEffectEvent(() => { @@ -230,7 +203,7 @@ export const DisplaySettingsButton = ({ {!canToggleLogScale && ( - + {y_columns.length === 0 ? 'Select a column for the Y axis first' : 'Data contains zero or negative values'} diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx index ddee91b17b7f5..da90821dbd208 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/QueryResultChart.tsx @@ -1,14 +1,12 @@ import { useMemo } from 'react' import { Chart, ChartBar, ChartCard, ChartContent, ChartLine } from 'ui-patterns/Chart' -import { type Snapshot } from 'valtio' -import { type QueryResult } from '../types' +import { type QueryChartConfig, type QueryResult } from '../types' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { formatLogTick, getCumulativeResults } from '@/components/ui/QueryBlock/QueryBlock.utils' -import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' interface QueryResultChartProps { - cell: Snapshot + chart?: QueryChartConfig result?: QueryResult } @@ -21,8 +19,7 @@ const toChartValue = (value: unknown): string | number => { return String(value) } -export const QueryResultChart = ({ cell, result }: QueryResultChartProps) => { - const { chart } = cell +export const QueryResultChart = ({ chart, result }: QueryResultChartProps) => { const { type, x_column, y_columns = [], cumulative, show_labels, scale } = chart ?? {} const hasConfig = !!x_column && y_columns.length > 0 diff --git a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx index 2a2a6a8ff1f6f..9553fa6426270 100644 --- a/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryCell/index.tsx @@ -1,177 +1,76 @@ -import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' -import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' +import { untrustedSql } from '@supabase/pg-meta' import { useState } from 'react' -import { cn } from 'ui' import { type Snapshot } from 'valtio' -import { - ExplorerQuery, - ExplorerQueryEditor, - ExplorerQueryFooter, - ExplorerQueryResults, -} from '../ExplorerQuery' -import { - ExplorerToolbar, - ExplorerToolbarAction, - ExplorerToolbarActions, - ExplorerToolbarIcon, - ExplorerToolbarTitle, -} from '../ExplorerToolbar' -import { QueryResultTable } from '../QueryResultTable' -import { type QueryResult } from '../types' -import { DisplaySettingsButton } from './DisplaySettingsButton' -import { QueryResultChart } from './QueryResultChart' -import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { QueryEditor } from '../QueryEditor' +import { type QueryDisplay, type QueryResult } from '../types' import { SortableSection } from '@/components/ui/SortableSection' import { type DatabaseCell as DatabaseCellSchema } from '@/data/content/notebooks/notebook-schema' -import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' -import { useLatest } from '@/hooks/misc/useLatest' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' -import { type ResponseError } from '@/types' interface QueryCellProps { cell: Snapshot } -/** - * [Joshen] Aiming to keep PRs small so the following are deliberating missing for now: - * - Auto limit logic - * - Database selection logic - * - Data display logic - * - * QueryCell atm minimally supports running queries and rendering results - */ +type QueryCellUpdate = { sql: string } | { title: string } | { display: QueryDisplay } +/** Notebook adapter around the shared QueryEditor. */ export const QueryCell = ({ cell }: QueryCellProps) => { const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() - const { data: project } = useSelectedProjectQuery() const cells = currentNotebook?.notebook.content?.cells ?? [] - const { title = 'Untitled snippet', row_limit, view } = cell - - const [showQuery, setShowQuery] = useState(true) - const [value, setValue] = useState(cell.unchecked_sql) + const [sql, setSql] = useState(cell.unchecked_sql) const [result, setResult] = useState() - const columns = Object.keys(result?.rows?.[0] ?? {}) - - const valueRef = useLatest(value) - - const { mutateAsync: executeQuery, isPending: isExecuting } = useExecuteSqlMutation({ - onSuccess: (data) => - setResult({ - rows: data.result, - error: undefined, - autoLimit: undefined, - }), - onError: (error) => - setResult({ - rows: undefined, - error: error as unknown as ResponseError, - autoLimit: undefined, - }), - }) - - const onRunQuery = async () => { - if (!project) return console.error('Project is required') - handleUpdateCell({ sql: value }) - - executeQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - sql: acceptUntrustedSql(untrustedSql(value)), - }) + const title = cell.title ?? 'Untitled snippet' + const display: QueryDisplay = { + view: cell.view ?? 'table', + chart: cell.chart ? { ...cell.chart, y_columns: [...cell.chart.y_columns] } : undefined, } - const handleUpdateCell = (payload: { sql: string } | { title: string }) => { + const handleUpdateCell = (payload: QueryCellUpdate) => { const notebookId = currentNotebook?.notebook.id if (!notebookId) return - const nextCells = cells.map((c) => { - if (c.id !== cell.id || c._tag !== 'database_cell') { - return c - } + const nextCells = cells.map((candidate) => { + if (candidate.id !== cell.id || candidate._tag !== 'database_cell') return candidate if ('sql' in payload) { - return { ...c, unchecked_sql: untrustedSql(payload.sql) } + return { ...candidate, unchecked_sql: untrustedSql(payload.sql) } } - const trimmedTitle = payload.title.trim() - return trimmedTitle ? { ...c, title: trimmedTitle } : c + if ('title' in payload) { + const nextTitle = payload.title.trim() + return nextTitle ? { ...candidate, title: nextTitle } : candidate + } + + return { + ...candidate, + view: payload.display.view, + chart: payload.display.chart, + } }) snap.updateCells({ id: notebookId, cells: nextCells }) } - const handleUpdateCellRef = useLatest(handleUpdateCell) - return ( - - - - - - handleUpdateCell({ title: newTitle })}> - {title} - - - - : } - tooltip={showQuery ? 'Hide query' : 'Show query'} - onClick={() => setShowQuery((prev) => !prev)} - /> - } - tooltip="Run query" - onClick={onRunQuery} - /> - - - - {showQuery && ( - - setValue(v ?? '')} - className="h-32" - actions={{ runQuery: { enabled: true, callback: onRunQuery } }} - onMount={(editor) => { - editor.onDidBlurEditorWidget(() => - handleUpdateCellRef.current({ sql: valueRef.current }) - ) - }} - /> - - )} - - - {view === 'table' && } - {view === 'chart' && } - - - -

{(result?.rows ?? []).length.toLocaleString()} rows

-

·

-

Limit {row_limit} rows

-
-
+ handleUpdateCell({ title })} + onSqlChange={setSql} + onSqlCommit={(sql) => handleUpdateCell({ sql })} + onResultChange={setResult} + onDisplayChange={(display) => handleUpdateCell({ display })} + />
) } diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx new file mode 100644 index 0000000000000..dc85d731fb8d3 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -0,0 +1,172 @@ +import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' +import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { cn } from 'ui' + +import { + ExplorerQuery, + ExplorerQueryEditor, + ExplorerQueryFooter, + ExplorerQueryResults, + ExplorerQueryViewport, +} from './ExplorerQuery' +import { + ExplorerToolbar, + ExplorerToolbarAction, + ExplorerToolbarActions, + ExplorerToolbarIcon, + ExplorerToolbarTitle, +} from './ExplorerToolbar' +import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' +import { QueryResultChart } from './QueryCell/QueryResultChart' +import { QueryResultTable } from './QueryResultTable' +import { type QueryDisplay, type QueryResult } from './types' +import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' +import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' +import { useLatest } from '@/hooks/misc/useLatest' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export type QueryEditorProps = { + id: string + variant: 'embedded' | 'viewport' + title: string + sql: string + result?: QueryResult + rowLimit: number + display?: QueryDisplay + toolbarActions?: ReactNode + onTitleChange: (title: string) => void + onSqlChange: (sql: string) => void + onSqlCommit?: (sql: string) => void + onResultChange: (result: QueryResult) => void + onDisplayChange?: (display: QueryDisplay) => void +} + +/** + * Shared query editor used by query tabs, notebook cells, and other Explorer surfaces. + * The consuming surface owns persistence and surrounding chrome; this component owns + * query-level UI and execution behavior. + */ +export const QueryEditor = ({ + id, + variant, + title, + sql, + result, + rowLimit, + display, + toolbarActions, + onTitleChange, + onSqlChange, + onSqlCommit, + onResultChange, + onDisplayChange, +}: QueryEditorProps) => { + const sqlRef = useLatest(sql) + const onSqlCommitRef = useLatest(onSqlCommit) + + const { data: project, isPending: isLoadingProject } = useSelectedProjectQuery() + + const view = display?.view ?? 'table' + const columns = Object.keys(result?.rows?.[0] ?? {}) + + const [showQuery, setShowQuery] = useState(true) + + const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({ + onSuccess: (data) => onResultChange({ rows: data.result }), + onError: (error) => onResultChange({ error }), + }) + + const handleRunQuery = (sqlToRun: string = sql) => { + if (!project || isLoadingProject || isExecuting || sqlToRun.trim().length === 0) return + + onSqlCommit?.(sql) + + const safeSql = acceptUntrustedSql(untrustedSql(sqlToRun)) + const limitedSql = applyAutoLimit(safeSql, rowLimit) + + executeSql({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: limitedSql.sql, + autoLimit: limitedSql.appendAutoLimit ? rowLimit : undefined, + contextualInvalidation: true, + isStatementTimeoutDisabled: true, + }) + } + + const Shell = variant === 'viewport' ? ExplorerQueryViewport : ExplorerQuery + + return ( + + + + + + {title} + + {toolbarActions} + {display && onDisplayChange && ( + + )} + : } + tooltip={showQuery ? 'Hide query' : 'Show query'} + onClick={() => setShowQuery((value) => !value)} + /> + } + tooltip="Run query" + disabled={isLoadingProject || isExecuting || sql.trim().length === 0} + onClick={() => handleRunQuery()} + > + Run + + + + + {showQuery && ( + + onSqlChange(value ?? '')} + onMount={(editor) => { + editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current)) + }} + /> + + )} + + + {view === 'table' && } + {view === 'chart' && } + + + +

{(result?.rows ?? []).length.toLocaleString()} rows

+

·

+

Limit {rowLimit} rows

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Explorer/types.ts b/apps/studio/components/interfaces/Explorer/types.ts index 91c1087fd094b..c3b07cad34e5c 100644 --- a/apps/studio/components/interfaces/Explorer/types.ts +++ b/apps/studio/components/interfaces/Explorer/types.ts @@ -1,7 +1,19 @@ -import { type ResponseError } from '@/types' - export type QueryResult = { - rows?: Record[] - error?: ResponseError + rows?: readonly Record[] + error?: { message: string; formattedError?: string } autoLimit?: number } + +export type QueryChartConfig = { + type: 'bar' | 'line' + x_column: string + y_columns: string[] + cumulative: boolean + scale: 'linear' | 'log' + show_labels: boolean +} + +export type QueryDisplay = { + view: 'table' | 'chart' + chart?: QueryChartConfig +} diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts index 8a18bde468862..19fec3bbd9506 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.utils.ts @@ -1,5 +1,7 @@ -export const checkHasNonPositiveValues = (data: Record[], key: string): boolean => - data.some((row) => (row[key] as number) <= 0) +export const checkHasNonPositiveValues = ( + data: readonly Record[], + key: string +): boolean => data.some((row) => (row[key] as number) <= 0) export const formatYAxisTick = (value: number): string => { if (Math.abs(value) >= 1_000_000) { From 88e916a4c08b4947021c74f0d50d5ba5940ff4c7 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:45:06 +0100 Subject: [PATCH 2/5] fix(studio): focus state for buttons with dropdown (#49055) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? We've quite a few instances where some buttons have a dropdown appendage. The focus state for these were broken as well as visually regarding the separator. This first pass fixes the instances we have in studio, I've left potentially adding this to our design system fragment components as another PR. | Before | After | |--------|--------| | Screenshot 2026-08-13 at 11 29 36 | Screenshot 2026-08-13 at 11 40
15 | | Screenshot 2026-08-13 at 11 29 48 | Screenshot 2026-08-13 at 11 39
56 | | Screenshot 2026-08-13 at 11 29 52 | Screenshot 2026-08-13 at 11 40
05 | ## Summary by CodeRabbit * **Bug Fixes** * Improved keyboard focus visibility across Studio controls, including token management, email settings, replication, log drains, query insights, infrastructure, storage, and assistant actions. * Focused buttons in adjacent or split-button groups now appear above neighboring controls, preventing borders and overlays from obscuring the active selection. * Preserved existing button behavior, layout, and appearance while improving focus-state clarity. --- .../AccessTokens/Classic/ExperimentalTokenDropdown.tsx | 2 +- .../Account/AccessTokens/Classic/NewTokenButton.tsx | 5 ++++- .../Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx | 2 +- .../CustomEmailTemplateRestrictionAdmonition.tsx | 4 ++-- .../ReplicationPipelineStatus.tsx | 4 ++-- .../components/interfaces/LogDrains/OrgAuditLogDrains.tsx | 4 ++-- .../QueryInsights/hooks/useQueryInsightsTableColumns.tsx | 4 ++-- .../General/Infrastructure/RestartServerButton.tsx | 4 ++-- .../CreateTable/CreateTableInstructionsDialog.tsx | 6 ++++-- apps/studio/components/ui/AiAssistantDropdown.tsx | 8 ++++++-- apps/studio/pages/project/[ref]/settings/log-drains.tsx | 4 ++-- 11 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx index 07efe99200679..04452562840ac 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Classic/ExperimentalTokenDropdown.tsx @@ -35,7 +35,7 @@ export const ExperimentalTokenDropdown = ({ onCreateToken }: ExperimentalTokenDr diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index e7a70677a581e..7caf6a1190d79 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -109,7 +109,7 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke
- diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx index 63a04b6ced915..23c6ba82701ea 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx @@ -28,7 +28,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => { @@ -37,7 +37,7 @@ export const CustomEmailTemplateRestrictionAdmonition = () => { @@ -167,7 +167,7 @@ export function OrgAuditLogDrains() { @@ -146,7 +150,7 @@ export function AiAssistantDropdown({ variant={variant} size={size} disabled={disabled} - className={cn('rounded-l-none px-1', iconOnly && 'px-1')} + className={cn('rounded-l-none px-1 focus-visible:z-10', iconOnly && 'px-1')} icon={} /> diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 3dac9cc99be67..d92f7971b5e68 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -215,7 +215,7 @@ const LogDrainsSettings: NextPageWithLayout = () => { disabled={!hasAccessToLogDrains || !canManageLogDrains} onClick={handleAddDestinationClick} variant="primary" - className="rounded-r-none px-3" + className="rounded-r-none px-3 hover:z-10 focus-visible:z-10" > Add destination @@ -225,7 +225,7 @@ const LogDrainsSettings: NextPageWithLayout = () => {
- {!topForPostgres && ( -
+
+ {isDatabaseConnectionsEnabled ? ( + + ) : ( -
- )} + )} +
) } From 75d16f360fb5073dfb3a782d5316bf59b08f2aa9 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 14 Aug 2026 02:03:08 +1000 Subject: [PATCH 5/5] feat(studio): add Explorer query tabs (#49038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image ## Stack Depends on #49041. Followed by #49028. ## Summary - add a dedicated ad-hoc query tab type and route under Explorer - connect query tabs to the shared `QueryEditor` through a `QueryTab` lifecycle adapter - add local query draft/result state and restore query tabs from their routes - confirm before closing populated local-only drafts and clean up their state on close ## To test 1. Open Explorer, select **Run SQL**, enter `select 1`, and run the query. 2. Rename the query, reload the page, then close the tab and confirm the discard prompt appears. ## Why Explorer needs a lightweight place to run SQL without creating a snippet. This layer adds the query-tab lifecycle on top of the shared editor foundation. ## Impact Queries in this layer run against the selected project's primary database. Drafts are local to the browser and are discarded when their tabs are closed. ## Validation - fresh non-incremental Studio TypeScript check - 22 focused tests across query draft state, tab state, and notebook tab registration ## Summary by CodeRabbit * **New Features** * Added support for creating, opening, editing, and running SQL queries in Explorer. * Added project-scoped persistence for query drafts and results. * Added dedicated query routes, query icons, and query tabs. * Added unsaved-changes warnings when closing query tabs. * Added a pinned Explorer Home tab and “New query” option. * Improved notebook tab registration and editor tab organization. * **Bug Fixes** * Improved tab navigation, closing behavior, and layout. * **Tests** * Added coverage for query persistence, cleanup, restoration, and tab navigation. --------- Co-authored-by: Joshen Lim --- .../interfaces/Explorer/ExplorerHome.tsx | 19 +- .../Explorer/ExplorerQueryTabCoordinator.tsx | 45 ++++ .../interfaces/Explorer/NotebookEditor.tsx | 14 - .../interfaces/Explorer/QueryEditor.tsx | 5 +- .../interfaces/Explorer/QueryTab.tsx | 89 ++++++ .../__tests__/NotebookEditor.test.tsx | 93 ------- .../components/interfaces/Explorer/hooks.ts | 20 ++ .../layouts/ExplorerLayout/ExplorerLayout.tsx | 70 +++-- .../components/layouts/Tabs/SortableTab.tsx | 59 ++-- apps/studio/components/layouts/Tabs/Tabs.tsx | 253 +++++++++--------- .../components/ui/CodeEditor/CodeEditor.tsx | 7 +- apps/studio/components/ui/EntityTypeIcon.tsx | 19 +- .../pages/project/[ref]/explorer/index.tsx | 11 + .../project/[ref]/explorer/notebook/[id].tsx | 24 ++ .../project/[ref]/explorer/query/[id].tsx | 16 ++ apps/studio/routeTree.gen.ts | 22 ++ .../routes/project/$ref/explorer/index.tsx | 4 +- .../project/$ref/explorer/query/$id.tsx | 11 + apps/studio/state/explorer-query.test.ts | 47 ++++ apps/studio/state/explorer-query.ts | 156 +++++++++++ apps/studio/state/tabs.test.ts | 22 ++ apps/studio/state/tabs.tsx | 82 +++++- packages/common/constants/local-storage.ts | 2 + 23 files changed, 771 insertions(+), 319 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx create mode 100644 apps/studio/components/interfaces/Explorer/QueryTab.tsx delete mode 100644 apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx create mode 100644 apps/studio/pages/project/[ref]/explorer/query/[id].tsx create mode 100644 apps/studio/routes/project/$ref/explorer/query/$id.tsx create mode 100644 apps/studio/state/explorer-query.test.ts create mode 100644 apps/studio/state/explorer-query.ts diff --git a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx index b26f10ed33ed3..786f4ad6fd220 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerHome.tsx @@ -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('') const [selectedModel, setSelectedModal] = useState('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} /> diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx new file mode 100644 index 0000000000000..14e8e58284106 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx @@ -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 +} diff --git a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx index bb680a21b1d16..6cc223ce9e270 100644 --- a/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx @@ -14,7 +14,6 @@ import { } from '@dnd-kit/sortable' import { useParams } from 'common' import { Notebook, NotebookText, Play, Save } from 'lucide-react' -import { useEffect, useEffectEvent } from 'react' import { AiIconAnimation, Button } from 'ui' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' @@ -63,19 +62,6 @@ export const NotebookEditor = () => { snap.updateCells({ id, cells: arrayMove([...cells], oldIndex, newIndex) }) } - const registerTab = useEffectEvent(() => { - if (!id) return - tabs.addTab({ - id: createTabId('notebook', { id }), - type: 'notebook', - label: name ?? 'New Notebook', - metadata: { notebookId: id }, - isPreview: false, - }) - }) - - useEffect(() => registerTab(), [id]) - return (
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index dc85d731fb8d3..d1ecf65fd9f8a 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -134,12 +134,15 @@ export const QueryEditor = ({ {showQuery && ( - + { + 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 ( +
+ +
+ ) + } + + if (!id || !draft) { + return ( +
+ +
+

Query draft not found

+

+ This local draft may have been closed or cleared from this browser. +

+
+ +
+ ) + } + + const handleResultChange = (nextResult: QueryResult) => { + explorerQueryState.setResult({ + id, + result: { ...nextResult, executedAt: Date.now() }, + }) + } + + return ( + { + 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} + /> + ) +} diff --git a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx deleted file mode 100644 index 24e63f419a5b8..0000000000000 --- a/apps/studio/components/interfaces/Explorer/__tests__/NotebookEditor.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { NotebookEditor } from '../NotebookEditor' -import { notebooksState } from '@/state/notebooks/notebooks-state' -import type { Notebook } from '@/state/notebooks/types' -import { customRender } from '@/tests/lib/custom-render' - -const { mockUseParams, mockAddTab } = vi.hoisted(() => ({ - mockUseParams: vi.fn(), - mockAddTab: vi.fn(), -})) - -vi.mock('common', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useParams: () => mockUseParams(), - } -}) - -vi.mock('@/state/tabs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTabsStateSnapshot: () => ({ addTab: mockAddTab }), - } -}) - -function makeNotebook(id: string, overrides: Partial = {}): Notebook { - return { - id, - type: 'notebook', - name: 'My Notebook', - description: '', - visibility: 'project', - favorite: false, - owner_id: 7, - project_id: 42, - content: { schema_version: 1, cells: [] }, - ...overrides, - } -} - -describe('NotebookEditor tab registration', () => { - beforeEach(() => { - mockAddTab.mockClear() - mockUseParams.mockReturnValue({ ref: 'default', id: 'notebook-1' }) - - // notebooksState is a module-level singleton, so reset the state these tests touch - for (const id of Object.keys(notebooksState.notebooks)) { - delete notebooksState.notebooks[id] - } - notebooksState.needsSaving.clear() - }) - - it('registers a tab with the notebook id, type, loaded name, and metadata', () => { - notebooksState.setNotebook({ - projectRef: 'default', - notebook: makeNotebook('notebook-1', { name: 'My Notebook' }), - }) - - customRender() - - expect(mockAddTab).toHaveBeenCalledTimes(1) - expect(mockAddTab).toHaveBeenCalledWith({ - id: 'notebook-notebook-1', - type: 'notebook', - label: 'My Notebook', - metadata: { notebookId: 'notebook-1' }, - isPreview: false, - }) - }) - - it('falls back to "New Notebook" as the label when the notebook has not loaded yet', () => { - customRender() - - expect(mockAddTab).toHaveBeenCalledWith({ - id: 'notebook-notebook-1', - type: 'notebook', - label: 'New Notebook', - metadata: { notebookId: 'notebook-1' }, - isPreview: false, - }) - }) - - it('does not register a tab when there is no id in the route', () => { - mockUseParams.mockReturnValue({ ref: 'default', id: undefined }) - - customRender() - - expect(mockAddTab).not.toHaveBeenCalled() - }) -}) diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index d4eedf60c547a..99e21109f2a6b 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -4,6 +4,7 @@ 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 { Notebooks } from '@/types' @@ -78,3 +79,22 @@ 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 createQuery = () => { + if (!project) return console.error('Project is required') + + const id = generateUuid() + querySnap.createDraft({ id, projectRef: project.ref }) + + router.push(`/project/${project.ref}/explorer/query/${id}`) + + return id + } + + return { createQuery } +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 0c1edef2d3c03..0e797ba0b8ac9 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -1,10 +1,14 @@ -import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { Home, MessageCirclePlus, NotebookText, Plus } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { ComponentProps, ReactNode, useState } from 'react' -import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from 'ui' +import { Home, MessageCirclePlus, NotebookText, Plus, SquareCode } from 'lucide-react' +import { ComponentProps, ReactNode, useEffect, useEffectEvent, useState } from 'react' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TabsTrigger, +} from 'ui' import { ProjectLayoutWithAuth } from '../ProjectLayout' import { EditorTabs } from '../Tabs/Tabs' @@ -12,7 +16,15 @@ 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' +import { + editorEntityTypes, + EXPLORER_HOME_TAB, + EXPLORER_HOME_TAB_ID, + useTabsStateSnapshot, + type Tab, +} from '@/state/tabs' export interface ExplorerLayoutProps extends ComponentProps { children: ReactNode @@ -49,6 +61,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
} > +
{ - const router = useRouter() - const { ref } = useParams() - const isActive = router.pathname.endsWith('/explorer') + const tabs = useTabsStateSnapshot() + + const openTabs = tabs.openTabs + .map((id) => tabs.tabsMap[id]) + .filter((tab) => tab !== undefined) as Tab[] + const explorerTabs = openTabs.filter((tab) => editorEntityTypes['explorer']?.includes(tab.type)) + + const ensureHomeTab = useEffectEvent(() => { + tabs.ensurePinnedTab(EXPLORER_HOME_TAB) + }) + + useEffect(() => ensureHomeTab(), []) return ( - + Open Explorer home - +
+ ) } const NewTabButton = () => { const { createNotebook } = useCreateNotebook() + const { createQuery } = useCreateQuery() return ( {}} initial={{ opacity: 0, scale: 0.8, x: -10 }} animate={{ opacity: 1, scale: 1, x: 0 }} @@ -106,6 +138,10 @@ const NewTabButton = () => { + createQuery()}> + + New query + createNotebook()}> New notebook diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 8c3eccdd38c2a..28ad583c34fec 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -65,6 +65,7 @@ export const SortableTab = ({ const isActive = tabs.activeTab === tab.id const closeTabFromKeyboard = (event: KeyboardEvent) => { + if (tab.closable === false) return if (event.key !== 'Delete' && event.key !== 'Backspace') return event.preventDefault() event.stopPropagation() @@ -85,7 +86,7 @@ export const SortableTab = ({ value={tab.id} onAuxClick={(e) => { // Middle click closes tab - if (e.button === 1) { + if (e.button === 1 && tab.closable !== false) { e.preventDefault() onClose(tab.id) } @@ -137,33 +138,35 @@ export const SortableTab = ({ {/* Sibling of TabsTrigger — not nested inside the tab button. Only the active tab's close is in the tab order (roving tabs). Delete/Backspace on the focused tab also closes. */} - + {tab.closable !== false && ( + + )}
{index < openTabs.length && (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 73b0dd1c43a94..734071d8879b9 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -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) } }) } @@ -189,123 +177,128 @@ export const EditorTabs = ({ > {!isCollapseButtonHidden && } - {customTabs} - - tab.id)} - strategy={horizontalListSortingStrategy} - > - {editorTabs.map((tab, index) => ( - - - handleClose(tab.id)} - /> - - - handleClose(tab.id)}>Close - handleCloseOthers(tab.id)}> - Close Others - - handleCloseRight(tab.id)}> - Close to the Right - - Close All - - - ))} - + {/* Pinned outside the scrollable segment below, so it never scrolls out of view. */} + {customTabs} - {/* Non-draggable new tab */} - {hasNewTab && ( -
- { - if (e.key !== 'Delete' && e.key !== 'Backspace') return - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - className={cn( - 'flex items-center gap-2 px-3 text-xs', - 'bg-dash-sidebar/50 dark:bg-surface-100/50', - 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', - 'relative group h-full border-t-2 border-b-0!', - 'hover:bg-surface-300 dark:hover:bg-surface-100' - )} - > - -
- New -
- {/* Reserve close-icon width; close is a sibling overlay. */} - -
- - -
- )} +
+ tab.id)} + strategy={horizontalListSortingStrategy} + > + {editorTabs.map((tab, index) => ( + + + handleClose(tab.id)} + /> + + + handleClose(tab.id)}>Close + handleCloseOthers(tab.id)}> + Close Others + + handleCloseRight(tab.id)}> + Close to the Right + + Close All + + + ))} + - - {!hasNewTab && - (newTabButton ?? ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) - } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} + {/* Non-draggable new tab */} + {hasNewTab && ( +
+ { + if (e.key !== 'Delete' && e.key !== 'Backspace') return + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} + className={cn( + 'flex items-center gap-2 px-3 text-xs', + 'bg-dash-sidebar/50 dark:bg-surface-100/50', + 'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100', + 'relative group h-full border-t-2 border-b-0!', + 'hover:bg-surface-300 dark:hover:bg-surface-100' + )} > - - - ))} - -
+ +
+ New +
+ {/* Reserve close-icon width; close is a sibling overlay. */} + +
+ + +
+ )} + + + {!hasNewTab && + (newTabButton ?? ( + + router.push( + `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` + ) + } + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + + ))} + +
+
diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index f912bdf6e496e..ffd6e738af7c0 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -42,6 +42,7 @@ interface CodeEditorProps { hideLineNumbers?: boolean className?: string wrapperClassName?: string + placeholderClassName?: string loading?: boolean options?: EditorProps['options'] value?: string @@ -71,6 +72,7 @@ export const CodeEditor = ({ hideLineNumbers = false, className, wrapperClassName, + placeholderClassName, loading, options, value, @@ -273,9 +275,10 @@ export const CodeEditor = ({ {placeholder !== undefined && (
div>p]:text-foreground-lighter [&>div>p]:m-0! tracking-tighter', - showPlaceholder ? 'block' : 'hidden' + showPlaceholder ? 'block' : 'hidden', + placeholderClassName )} > diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f238dcab366ea..755e3d0ba8788 100644 --- a/apps/studio/components/ui/EntityTypeIcon.tsx +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -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,18 @@ 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' + | 'explorer-home' size?: number strokeWidth?: number isActive?: boolean @@ -107,6 +118,10 @@ export const EntityTypeIcon = ({ return } + if (type === 'query') { + return + } + return (
{ + const tabs = useTabsStateSnapshot() + + const activateHomeTab = useEffectEvent(() => { + tabs.activatePinnedTab(EXPLORER_HOME_TAB) + }) + + useEffect(() => activateHomeTab(), []) + return } diff --git a/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx index 40dc4b68e8ffd..3178327f0124a 100644 --- a/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx +++ b/apps/studio/pages/project/[ref]/explorer/notebook/[id].tsx @@ -1,9 +1,33 @@ +import { useParams } from 'common' +import { useEffect, useEffectEvent } from 'react' + import { NotebookEditor } from '@/components/interfaces/Explorer/NotebookEditor' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { ExplorerLayout } from '@/components/layouts/ExplorerLayout/ExplorerLayout' +import { useCurrentNotebook } from '@/state/notebooks/notebooks-state' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' import type { NextPageWithLayout } from '@/types' const NotebookPage: NextPageWithLayout = () => { + const { id } = useParams() + const tabs = useTabsStateSnapshot() + + const currentNotebook = useCurrentNotebook() + const { name } = currentNotebook?.notebook ?? {} + + const registerTab = useEffectEvent(() => { + if (!id) return + tabs.addTab({ + id: createTabId('notebook', { id }), + type: 'notebook', + label: name ?? 'New Notebook', + metadata: { notebookId: id }, + isPreview: false, + }) + }) + + useEffect(() => registerTab(), [id]) + return } diff --git a/apps/studio/pages/project/[ref]/explorer/query/[id].tsx b/apps/studio/pages/project/[ref]/explorer/query/[id].tsx new file mode 100644 index 0000000000000..5d3aeac84109c --- /dev/null +++ b/apps/studio/pages/project/[ref]/explorer/query/[id].tsx @@ -0,0 +1,16 @@ +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 = () => { + return +} + +QueryPage.getLayout = (page) => ( + + {page} + +) + +export default QueryPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 93a2d78dc374a..57160fc4e2441 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -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 = diff --git a/apps/studio/routes/project/$ref/explorer/index.tsx b/apps/studio/routes/project/$ref/explorer/index.tsx index c77bdd40f67c9..b84434826f483 100644 --- a/apps/studio/routes/project/$ref/explorer/index.tsx +++ b/apps/studio/routes/project/$ref/explorer/index.tsx @@ -1,11 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' -import { ExplorerHome } from '@/components/interfaces/Explorer/ExplorerHome' +import ProjectExplorerPage from '@/pages/project/[ref]/explorer' export const Route = createFileRoute('/project/$ref/explorer/')({ component: ProjectExplorerIndexRoute, }) function ProjectExplorerIndexRoute() { - return + return } diff --git a/apps/studio/routes/project/$ref/explorer/query/$id.tsx b/apps/studio/routes/project/$ref/explorer/query/$id.tsx new file mode 100644 index 0000000000000..ffe6cf5bc1914 --- /dev/null +++ b/apps/studio/routes/project/$ref/explorer/query/$id.tsx @@ -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 +} diff --git a/apps/studio/state/explorer-query.test.ts b/apps/studio/state/explorer-query.test.ts new file mode 100644 index 0000000000000..b1c653430ba60 --- /dev/null +++ b/apps/studio/state/explorer-query.test.ts @@ -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() + + 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() + }) +}) diff --git a/apps/studio/state/explorer-query.ts b/apps/studio/state/explorer-query.ts new file mode 100644 index 0000000000000..28df97f7b59a3 --- /dev/null +++ b/apps/studio/state/explorer-query.ts @@ -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 + +type StorageLike = Pick + +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, + results: {} as Record, + + 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[1]) => + useSnapshot(explorerQueryState, options) diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts index 000814297f771..b5545269d0914 100644 --- a/apps/studio/state/tabs.test.ts +++ b/apps/studio/state/tabs.test.ts @@ -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') + }) +}) diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index a836818022cda..fbef013a92722 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -19,10 +19,22 @@ 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' | 'explorer-home' + +/** Fixed id for Explorer's pinned, non-closable Home tab. */ +export const EXPLORER_HOME_TAB_ID = 'explorer-home' + +/** Tab descriptor for Explorer's Home tab — shared by its trigger and its page. */ +export const EXPLORER_HOME_TAB: Tab = { + id: EXPLORER_HOME_TAB_ID, + type: 'explorer-home', + label: 'Home', + isPreview: false, + closable: false, +} type CreateTabIdParams = { r: { id: number } @@ -32,10 +44,12 @@ type CreateTabIdParams = { p: { id: number } sql: { id: string } notebook: { id: string } + query: { id: string } schema: { schema: string } view: never function: never new: never + 'explorer-home': never } export interface Tab { @@ -48,6 +62,7 @@ export interface Tab { tableId?: number sqlId?: string notebookId?: string + queryId?: string scrollTop?: number /** * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), @@ -59,6 +74,12 @@ export interface Tab { sqlSource?: SqlSnippetSource } isPreview?: boolean + /** + * Whether the tab can be closed by the user (close button, keyboard shortcut, + * or a bulk close action). Defaults to `true` — absent on every tab except a + * pinned default (e.g. Explorer's Home tab), which sets this `false`. + */ + closable?: boolean createdAt?: Date updatedAt?: Date } @@ -111,6 +132,8 @@ export interface RecentItem { name?: string tableId?: number sqlId?: string + notebookId?: string + queryId?: string sqlSource?: SqlSnippetSource } } @@ -284,6 +307,23 @@ export function createTabsState(projectRef: string) { store.previewTabId = tab.id store.activeTab = tab.id }, + // Ensures a tab that's always present, first, and outside the draggable/ + // closable set (e.g. Explorer's Home tab) exists in the store, without + // touching which tab is active. Safe to call from wherever the pinned + // tab's trigger renders, regardless of which page currently owns focus. + ensurePinnedTab: (tab: Tab) => { + if (store.tabsMap[tab.id]) return + store.tabsMap[tab.id] = tab + store.openTabs = [tab.id, ...store.openTabs] + }, + // Ensures a pinned tab exists (see ensurePinnedTab) and marks it active, + // without recording it in Recent Items — it isn't content to revisit, + // just a fixed destination. Call this from the page it represents, on + // mount, mirroring how regular tabs call addTab from their own page. + activatePinnedTab: (tab: Tab) => { + store.ensurePinnedTab(tab) + store.activeTab = tab.id + }, updateTab: ( id: string, updates: { label?: string; scrollTop?: number; sqlSource?: SqlSnippetSource } @@ -319,6 +359,8 @@ export function createTabsState(projectRef: string) { // this is used for removing tabs from the localstorage state // for handling a manual tab removal with a close action, use handleTabClose() removeTab: (id: string) => { + if (store.tabsMap[id]?.closable === false) return + const idx = store.openTabs.indexOf(id) store.openTabs = store.openTabs.filter((tabId) => tabId !== id) delete store.tabsMap[id] @@ -380,8 +422,10 @@ export function createTabsState(projectRef: string) { store.activeTab = id - // Add to recent items when navigating to a non-preview, non-new tab - if (!tab.isPreview) store.addRecentItem(tab) + // Add to recent items when navigating to a non-preview, non-new tab. + // Pinned tabs (e.g. Explorer's Home) are a fixed destination, not content + // to revisit, so they're excluded regardless of preview state. + if (!tab.isPreview && tab.closable !== false) store.addRecentItem(tab) switch (tab.type) { case 'sql': @@ -391,6 +435,12 @@ 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 'explorer-home': + router.push(`/project/${router.query.ref}/explorer`) + break case 'r': case 'v': case 'm': @@ -462,8 +512,8 @@ export function createTabsState(projectRef: string) { closeTabs: (ids: string[]) => { const closedTabs = ids .map((id) => store.tabsMap[id]) - .filter((tab): tab is Tab => tab !== undefined) - store.removeTabs(ids) + .filter((tab): tab is Tab => tab !== undefined && tab.closable !== false) + store.removeTabs(closedTabs.map((tab) => tab.id)) closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab)) }, @@ -525,6 +575,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 +604,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 +699,9 @@ export function createTabId(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 '' } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 9cea4e26385b0..91a6e9fc30a4b 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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}`,