From 72379ebf4e65ebd736cf1fd83da455f214fdf6c0 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 13 Aug 2026 16:11:30 +1000 Subject: [PATCH] feat(studio): add Explorer query tabs --- .../interfaces/Explorer/ExplorerHome.tsx | 19 +-- .../Explorer/ExplorerQueryTabCoordinator.tsx | 45 +++++ .../interfaces/Explorer/QueryTab.tsx | 89 ++++++++++ .../components/interfaces/Explorer/hooks.ts | 28 ++++ .../layouts/ExplorerLayout/ExplorerLayout.tsx | 11 +- apps/studio/components/layouts/Tabs/Tabs.tsx | 30 +--- apps/studio/components/ui/EntityTypeIcon.tsx | 8 +- .../project/[ref]/explorer/query/[id].tsx | 14 ++ apps/studio/routeTree.gen.ts | 22 +++ .../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 | 31 ++-- packages/common/constants/local-storage.ts | 2 + 15 files changed, 484 insertions(+), 51 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx create mode 100644 apps/studio/components/interfaces/Explorer/QueryTab.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 b26f10ed33e..786f4ad6fd2 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 00000000000..14e8e582841 --- /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/QueryTab.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.tsx new file mode 100644 index 00000000000..432aba351d5 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryTab.tsx @@ -0,0 +1,89 @@ +import { useParams } from 'common' +import { Loader2, SquareCode } from 'lucide-react' +import { useRouter } from 'next/router' +import { useContext, useEffect, useState } from 'react' +import { Button } from 'ui' + +import { QueryEditor } from './QueryEditor' +import { type QueryResult } from './types' +import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query' +import { createTabId, TabsStateContext } from '@/state/tabs' + +const QUERY_ROW_LIMIT = 100 + +/** Query-tab lifecycle adapter around the shared QueryEditor. */ +export const QueryTab = () => { + const { id, ref } = useParams() + const router = useRouter() + const tabs = useContext(TabsStateContext) + const querySnap = useExplorerQueryStateSnapshot() + const [hasRestored, setHasRestored] = useState(false) + const stateDraft = id ? querySnap.drafts[id] : undefined + const draft = stateDraft?.projectRef === ref ? stateDraft : undefined + const result = draft && id ? querySnap.results[id] : undefined + + useEffect(() => { + if (!id || !ref) return + + const restored = explorerQueryState.restoreDraft({ id, projectRef: ref }) + const restoredDraft = explorerQueryState.drafts[id] + if (restored && restoredDraft) { + tabs.addTab({ + id: createTabId('query', { id }), + type: 'query', + label: restoredDraft.name, + metadata: { queryId: id }, + isPreview: false, + }) + } + setHasRestored(true) + }, [id, ref, tabs]) + + if (!hasRestored) { + return ( +
+ +
+ ) + } + + 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/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index d4eedf60c54..9209c0d410b 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -4,8 +4,10 @@ import { useRouter } from 'next/router' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { generateUuid } from '@/lib/api/snippets.browser' import { useProfile } from '@/lib/profile' +import { useExplorerQueryStateSnapshot } from '@/state/explorer-query' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { type Notebook } from '@/state/notebooks/types' +import { createTabId, useTabsStateSnapshot } from '@/state/tabs' import { Notebooks } from '@/types' export const useCreateNotebook = () => { @@ -78,3 +80,29 @@ This is a sample paragraph to demonstrate the Markdown cells return { createNotebook } } + +export const useCreateQuery = () => { + const router = useRouter() + const { data: project } = useSelectedProjectQuery() + const querySnap = useExplorerQueryStateSnapshot() + const tabs = useTabsStateSnapshot() + + const createQuery = () => { + if (!project) return console.error('Project is required') + + const id = crypto.randomUUID() + querySnap.createDraft({ id, projectRef: project.ref }) + tabs.addTab({ + id: createTabId('query', { id }), + type: 'query', + label: 'Untitled query', + metadata: { queryId: id }, + isPreview: false, + }) + router.push(`/project/${project.ref}/explorer/query/${id}`) + + return id + } + + return { createQuery } +} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx index 0c1edef2d3c..2b3b6573885 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { Home, MessageCirclePlus, NotebookText, Plus } from 'lucide-react' +import { Home, MessageCirclePlus, NotebookText, Plus, SquareCode } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { ComponentProps, ReactNode, useState } from 'react' @@ -12,7 +12,8 @@ import { type ExplorerResourceType } from './ExplorerLayout.constants' import { ExplorerNavChats } from './ExplorerNavChats' import { ExplorerNavHome } from './ExplorerNavHome' import { ExplorerNavNotebooks } from './ExplorerNavNotebooks' -import { useCreateNotebook } from '@/components/interfaces/Explorer/hooks' +import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator' +import { useCreateNotebook, useCreateQuery } from '@/components/interfaces/Explorer/hooks' export interface ExplorerLayoutProps extends ComponentProps { children: ReactNode @@ -49,6 +50,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout } > +
{ const NewTabButton = () => { const { createNotebook } = useCreateNotebook() + const { createQuery } = useCreateQuery() return ( @@ -106,6 +109,10 @@ const NewTabButton = () => { + createQuery()}> + + New query + createNotebook()}> New notebook diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 73b0dd1c43a..0a2f6748fc4 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) } }) } diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx index f238dcab366..64240aedc17 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,7 @@ export const LogsSnippetIcon = ({ ) interface EntityTypeIconProps { - type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook' + type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' | 'notebook' | 'query' size?: number strokeWidth?: number isActive?: boolean @@ -107,6 +107,10 @@ export const EntityTypeIcon = ({ return } + if (type === 'query') { + return + } + return (
+ +QueryPage.getLayout = (page) => ( + + {page} + +) + +export default QueryPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 93a2d78dc37..57160fc4e24 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/query/$id.tsx b/apps/studio/routes/project/$ref/explorer/query/$id.tsx new file mode 100644 index 00000000000..ffe6cf5bc19 --- /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 00000000000..b1c653430ba --- /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 00000000000..28df97f7b59 --- /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 000814297f7..b5545269d09 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 a836818022c..9bd0dc2975f 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -19,10 +19,10 @@ import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' export const editorEntityTypes = { table: ['r', 'v', 'm', 'f', 'p'], sql: ['sql'], - explorer: ['notebook'], + explorer: ['notebook', 'query'], } -export type TabType = ENTITY_TYPE | 'sql' | 'notebook' +export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' type CreateTabIdParams = { r: { id: number } @@ -32,6 +32,7 @@ type CreateTabIdParams = { p: { id: number } sql: { id: string } notebook: { id: string } + query: { id: string } schema: { schema: string } view: never function: never @@ -48,6 +49,7 @@ export interface Tab { tableId?: number sqlId?: string notebookId?: string + queryId?: string scrollTop?: number /** * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), @@ -111,6 +113,8 @@ export interface RecentItem { name?: string tableId?: number sqlId?: string + notebookId?: string + queryId?: string sqlSource?: SqlSnippetSource } } @@ -391,6 +395,9 @@ export function createTabsState(projectRef: string) { case 'notebook': router.push(`/project/${router.query.ref}/explorer/notebook/${tab.metadata?.notebookId}`) break + case 'query': + router.push(`/project/${router.query.ref}/explorer/query/${tab.metadata?.queryId}`) + break case 'r': case 'v': case 'm': @@ -525,6 +532,7 @@ export function createTabsState(projectRef: string) { router.push(`/project/${router.query.ref}/sql`) break case 'notebook': + case 'query': router.push(`/project/${router.query.ref}/explorer`) break case 'r': @@ -553,17 +561,18 @@ export function createTabsState(projectRef: string) { router, onClearDashboardHistory, }: { - editor: 'sql' | 'table' + editor: 'sql' | 'table' | 'explorer' router: NextRouter onClearDashboardHistory: () => void }) => { - const tabsToClose = - editor === 'table' - ? store.openTabs.filter((x) => !x.startsWith('sql')) - : store.openTabs.filter((x) => x.startsWith('sql')) - store.removeTabs(tabsToClose) + const tabsToClose = store.openTabs.filter((id) => { + const tab = store.tabsMap[id] + return tab !== undefined && editorEntityTypes[editor].includes(tab.type) + }) + store.closeTabs(tabsToClose) onClearDashboardHistory() - router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`) + const editorPath = editor === 'table' ? 'editor' : editor + router.push(`/project/${router.query.ref}/${editorPath}`) }, handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: NextRouter) => { // Make permanent if needed @@ -647,7 +656,9 @@ export function createTabId(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 9cea4e26385..91a6e9fc30a 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}`,