From 75d16f360fb5073dfb3a782d5316bf59b08f2aa9 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 14 Aug 2026 02:03:08 +1000 Subject: [PATCH] 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 | 255 +++++++++--------- .../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, 772 insertions(+), 320 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 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/NotebookEditor.tsx b/apps/studio/components/interfaces/Explorer/NotebookEditor.tsx index bb680a21b1d..6cc223ce9e2 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 dc85d731fb8..d1ecf65fd9f 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 24e63f419a5..00000000000 --- 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 d4eedf60c54..99e21109f2a 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 0c1edef2d3c..0e797ba0b8a 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 8c3eccdd38c..28ad583c34f 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 73b0dd1c43a..734071d8879 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} + {/* Pinned outside the scrollable segment below, so it never scrolls out of view. */} + {customTabs} + +
- {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 - - - ))} - - - {/* 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 }} - > - - + 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 + + ))} - -
+ + + {/* 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 f912bdf6e49..ffd6e738af7 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 f238dcab366..755e3d0ba87 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 40dc4b68e8f..3178327f012 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 00000000000..5d3aeac8410 --- /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 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/index.tsx b/apps/studio/routes/project/$ref/explorer/index.tsx index c77bdd40f67..b84434826f4 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 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..fbef013a927 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 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}`,