import { safeLocalStorage, useParams } from 'common' import { partition } from 'lodash' import { type NextRouter } from 'next/router' import { createContext, PropsWithChildren, useContext, useEffect, useState, type ComponentType, } from 'react' import { proxy, subscribe, useSnapshot } from 'valtio' import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils' import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource' import type { EditorType } from '@/components/layouts/editors/EditorsLayout.hooks' import type { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' export const editorEntityTypes = { table: ['r', 'v', 'm', 'f', 'p'], sql: ['sql'], explorer: ['notebook', 'query', 'chat'], } export type TabType = ENTITY_TYPE | 'sql' | 'notebook' | 'query' | 'chat' | '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 } v: { id: number } m: { id: number } f: { id: number } p: { id: number } sql: { id: string } notebook: { id: string } query: { id: string } chat: { id: string } schema: { schema: string } view: never function: never new: never 'explorer-home': never } export interface Tab { id: string type: TabType label?: string metadata?: { schema?: string name?: string tableId?: number sqlId?: string notebookId?: string queryId?: string chatId?: string scrollTop?: number /** * For SQL tabs, which backend the snippet queries (`'database'` | `'logs'`), * so the tab can show the matching icon without re-fetching the snippet. * Absent on tabs persisted before this field existed — treat absent as * `'database'` and backfill from the loaded snippet (source is immutable, so * it never goes stale once set). */ 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 } /** Copy shown in the confirmation dialog before a close is allowed to proceed. */ export interface TabCloseConfirmation { title: string description: string } /** * Per-tab-type behavior and UI the tabs layout delegates to, so the layout * stays agnostic of what any given tab kind means. A domain (e.g. the SQL * editor) registers a handler for its tab type via `registerTabTypeHandler`; * tabs of types without a handler close with no extra behavior and show no * status indicator. */ export interface TabTypeHandler { /** * Cleanup to run when the user closes a tab of this type (e.g. discarding a * SQL snippet's unsaved local edits). Runs after the tab has been removed. */ onClose?: (tab: Tab) => void /** * Whether closing these tabs needs user confirmation. Receives the whole set * of this type being closed (e.g. a bulk "Close Others") so the handler owns * the dialog copy, including wording it for one vs. many. Return the copy to * confirm first; return null/undefined to close immediately. */ confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined /** * Optional component rendered inside the tab to show type-specific status * (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the * component here keeps the layout agnostic of what "status" means per type and * lets the domain drive its own reactivity. Rendered only when it has * something to show; otherwise it should render nothing. */ StatusIndicator?: ComponentType<{ tab: Tab }> } const MAX_RECENT_ITEMS = 8 export interface RecentItem { id: string type: TabType label: string timestamp: number metadata?: { schema?: string name?: string tableId?: number sqlId?: string notebookId?: string queryId?: string chatId?: string sqlSource?: SqlSnippetSource } } const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items' const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}` function getSavedRecentItems(ref: string): RecentItem[] { if (!ref) return [] const stored = safeLocalStorage.getItem(getRecentItemsStorageKey(ref)) try { return JSON.parse(stored ?? '{"items": []}').items } catch (error) { return [] } } const createDefaultTabsState = () => ({ activeTab: null as string | null, openTabs: [] as string[], tabsMap: {} as Record, previewTabId: undefined as string | undefined, recentItems: [], }) const TABS_STORAGE_KEY = 'supabase_studio_tabs' const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` function getSavedTabs(ref: string) { if (!ref) return createDefaultTabsState() const stored = safeLocalStorage.getItem(getTabsStorageKey(ref)) if (!stored) return createDefaultTabsState() try { const parsed = JSON.parse(stored ?? JSON.stringify(createDefaultTabsState())) as ReturnType< typeof createDefaultTabsState > if ( !parsed.openTabs || !Array.isArray(parsed.openTabs) || !parsed.tabsMap || typeof parsed.tabsMap !== 'object' ) { return createDefaultTabsState() } return parsed } catch (error) { return createDefaultTabsState() } } const getRecentItemLabel = (tab: Pick) => tab.label || tab.metadata?.name || 'Untitled' const syncRecentItemWithTab = (item: RecentItem, tab: Pick) => { const nextLabel = getRecentItemLabel(tab) item.label = nextLabel item.metadata = { ...item.metadata, ...tab.metadata, name: nextLabel, } } export function createTabsState(projectRef: string) { const recentItems = getSavedRecentItems(projectRef) const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef) // Per-type behavior/UI, kept outside the Valtio proxy so handler closures // (which may capture non-serializable things like a React Query client or a // React component) are never proxied or persisted. const tabHandlers = new Map() const store = proxy({ // RECENT ITEMS recentItems, addRecentItem: (tab: Tab) => { // Check if an item with the same ID already exists const existingItem = store.recentItems.find((item) => item.id === tab.id) if (existingItem) { // If it exists, update its timestamp existingItem.timestamp = Date.now() syncRecentItemWithTab(existingItem, tab) return // Exit the function } // If it doesn't exist, create and add a new item const recentItem: RecentItem = { id: tab.id, // Set the ID type: tab.type, // Set the type label: getRecentItemLabel(tab), // Set the label or default to 'Untitled' timestamp: Date.now(), // Set the current timestamp metadata: tab.metadata, // Set the metadata } // Add the new recent item to the beginning of the list store.recentItems.unshift(recentItem) // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => { if (editorEntityTypes.table.includes(item.type)) return item }) store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType] }, clearRecentItems: () => { store.recentItems = [] }, removeRecentItem: (itemId: string) => { store.recentItems = store.recentItems.filter((item) => item.id !== itemId) }, removeRecentItems: (itemIds: string[]) => { store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id)) }, removeRecentItemsByType: (type: TabType) => { store.recentItems = store.recentItems.filter((item) => item.type !== type) }, getRecentItemsByType: (type: TabType) => { return store.recentItems.filter((item) => item.type === type) }, // TABS activeTab, openTabs, tabsMap, previewTabId, hasTab: (id: string) => { return !!store.tabsMap[id] }, addTab: (tab: Tab) => { // If tab exists and is active, don't do anything if (store.tabsMap[tab.id] && store.activeTab === tab.id) { return } // If tab exists but isn't active, just make it active if (store.tabsMap[tab.id]) { store.activeTab = tab.id if (!tab.isPreview) store.addRecentItem(tab) return } // If this tab should be permanent, add it normally if (tab.isPreview === false) { store.openTabs = [...store.openTabs, tab.id] store.tabsMap[tab.id] = tab store.activeTab = tab.id // Add to recent items when creating permanent tab store.addRecentItem(tab) return } // Remove any existing preview tab if (store.previewTabId) { store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId) delete store.tabsMap[store.previewTabId] } // Add new preview tab store.tabsMap[tab.id] = { ...tab, isPreview: true } store.openTabs = [...store.openTabs, tab.id] 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 } ) => { const tab = store.tabsMap[id] if (!tab) return if ('label' in updates) { tab.label = updates.label // Keep the persisted name aligned with the visible label so browser titles // and tab state recover cleanly after entity renames. if (typeof updates.label === 'string' && tab.metadata) { tab.metadata.name = updates.label } const recentItem = store.recentItems.find((item) => item.id === id) if (recentItem) syncRecentItemWithTab(recentItem, tab) } if ('scrollTop' in updates && tab.metadata) { tab.metadata.scrollTop = updates.scrollTop } // Backfill the immutable source onto a tab (and its recent item) that // predates the field, so its icon resolves correctly once the snippet loads. if (updates.sqlSource !== undefined) { if (tab.metadata) tab.metadata.sqlSource = updates.sqlSource else tab.metadata = { sqlSource: updates.sqlSource } const recentItem = store.recentItems.find((item) => item.id === id) if (recentItem) syncRecentItemWithTab(recentItem, tab) } }, // Function to remove a tab from the store // 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] // Clear the preview tab reference if the removed tab was the preview tab, // so it doesn't linger in (persisted) state pointing at a closed tab if (store.previewTabId === id) { store.previewTabId = undefined } // Update active tab if the removed tab was active if (id === store.activeTab) { store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null } }, // Function to remove multiple tabs from the store // this is used for removing tabs from the localstorage state // for handling a manual tab removal with a close action, use handleTabClose() removeTabs: (ids: string[]) => { if (!ids.length) return ids.forEach((id) => store.removeTab(id)) }, reorderTabs: (oldIndex: number, newIndex: number) => { const newOpenTabs = [...store.openTabs] const [removedTab] = newOpenTabs.splice(oldIndex, 1) newOpenTabs.splice(newIndex, 0, removedTab) store.openTabs = newOpenTabs }, makeTabActive: (tabId: string) => { const tab = store.tabsMap[tabId] if (!tab) return store.activeTab = tab.id }, makeTabPermanent: (tabId: string) => { const tab = store.tabsMap[tabId] if (tab?.isPreview) { tab.isPreview = false store.previewTabId = undefined // Add to recent items when preview tab becomes permanent store.addRecentItem(tab) } }, makeActiveTabPermanent: () => { if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) { store.makeTabPermanent(store.activeTab) return true } return false }, // TABS HANDLERS handleTabNavigation: (id: string, router: NextRouter) => { const tab = store.tabsMap[id] if (!tab) return store.activeTab = id // 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': const schema = (router.query.schema as string) || 'public' router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`) break 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 'chat': router.push(`/project/${router.query.ref}/explorer/chat/${tab.metadata?.chatId}`) break case 'explorer-home': router.push(`/project/${router.query.ref}/explorer`) break case 'r': case 'v': case 'm': case 'f': case 'p': router.push( buildTableEditorUrl({ projectRef: router.query.ref as string, tableId: tab.metadata?.tableId!, schema: tab.metadata?.schema, }) ) break } }, // TAB TYPE HANDLER REGISTRY // // Lets a domain own what a tab of its type means — how it closes and what // status it shows — without the layout having to know. Registered per tab // type; returns an unregister function. // // Bumped on every (un)register so components that render per-type UI (the // status indicator) re-render to pick up a handler registered after they // first rendered — handlers register in an effect, which runs after the // tabs first paint. handlerRegistrationVersion: 0, registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => { tabHandlers.set(type, handler) store.handlerRegistrationVersion++ return () => { if (tabHandlers.get(type) === handler) { tabHandlers.delete(type) store.handlerRegistrationVersion++ } } }, // The status-indicator component registered for a tab type, if any. Read // `handlerRegistrationVersion` alongside this in render to stay reactive to // late registration. getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator, // The confirmation to show before closing the given tabs, or null if none // need confirming. Tabs are grouped by type and each type's handler is asked // about its own set (so it can word the copy for one vs. many); the first // handler that asks to confirm wins. The store authors no copy itself — that // stays a concern of the registering domain. getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => { const tabsByType = new Map() for (const id of ids) { const tab = store.tabsMap[id] if (!tab) continue const group = tabsByType.get(tab.type) if (group) group.push(tab) else tabsByType.set(tab.type, [tab]) } for (const [type, tabs] of tabsByType) { const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs) if (confirmation) return confirmation } return null }, // Close multiple tabs as an intentional user action, running each tab type's // close handler afterwards. Distinct from `removeTabs`, the low-level store // mutation used for re-keying (rename/move) and stale cleanup, which must // NOT trigger discard behavior. closeTabs: (ids: string[]) => { const closedTabs = ids .map((id) => store.tabsMap[id]) .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)) }, handleTabClose: ({ id, router, editor, onClose, onClearDashboardHistory, }: { id: string router: NextRouter editor?: EditorType onClose?: (id: string) => void onClearDashboardHistory: () => void }) => { const tabBeingClosed = store.tabsMap[id] const editorTabIds = ( editor ? Object.values(store.tabsMap).filter((tab) => editorEntityTypes[editor]?.includes(tab.type) ) : [] ).map((tab) => tab.id) const tabIndexBeingClosed = editorTabIds.indexOf(id) const isLastTabBeingClosed = tabIndexBeingClosed === editorTabIds.length - 1 const nextTabId = editorTabIds.length === 1 ? undefined : isLastTabBeingClosed ? editorTabIds[tabIndexBeingClosed - 1] : editorTabIds[tabIndexBeingClosed + 1] const { [id]: value, ...otherTabs } = store.tabsMap store.tabsMap = otherTabs if (tabBeingClosed) { const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id) store.openTabs = updatedOpenTabs } // Remove the preview tab if it matches the tab being closed if (store.previewTabId === id) { store.previewTabId = undefined } // [Joshen] Only navigate away if we're closing the tab that's currently in focus if (store.activeTab === id || id === 'new') { if (nextTabId) { store.activeTab = nextTabId store.handleTabNavigation(nextTabId, router) } else { onClearDashboardHistory() // If no tabs of same type, go to the home of the current section switch (tabBeingClosed?.type) { case 'sql': router.push(`/project/${router.query.ref}/sql`) break case 'notebook': case 'query': case 'chat': router.push(`/project/${router.query.ref}/explorer`) break case 'r': case 'v': case 'm': case 'f': case 'p': router.push(`/project/${router.query.ref}/editor`) break default: router.push(`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}`) } } } onClose?.(id) // Run the tab type's registered close behavior (e.g. discard a SQL // snippet's unsaved edits). `tabBeingClosed` is captured before removal. if (tabBeingClosed) { tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed) } }, handleTabCloseAll: ({ editor, router, onClearDashboardHistory, }: { editor: 'sql' | 'table' | 'explorer' router: NextRouter onClearDashboardHistory: () => void }) => { const tabsToClose = store.openTabs.filter((id) => { const tab = store.tabsMap[id] return tab !== undefined && editorEntityTypes[editor].includes(tab.type) }) store.closeTabs(tabsToClose) onClearDashboardHistory() 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 const draggedTab = store.tabsMap[tabId] if (draggedTab?.isPreview) { store.makeTabPermanent(tabId) } // Reorder tabs const newOpenTabs = [...store.openTabs] newOpenTabs.splice(oldIndex, 1) newOpenTabs.splice(newIndex, 0, tabId) store.openTabs = newOpenTabs store.activeTab = tabId // Handle navigation store.handleTabNavigation(tabId, router) }, }) return store } export type TabsState = ReturnType export const TabsStateContext = createContext(createTabsState('')) export const TabsStateContextProvider = ({ children }: PropsWithChildren) => { const { ref: projectRef } = useParams() const [state, setState] = useState(createTabsState(projectRef ?? '')) useEffect(() => { if (typeof window !== 'undefined' && !!projectRef) { setState(createTabsState(projectRef ?? '')) } }, [projectRef]) useEffect(() => { if (typeof window !== 'undefined' && projectRef) { return subscribe(state, () => { safeLocalStorage.setItem( getTabsStorageKey(projectRef), JSON.stringify({ activeTab: state.activeTab, openTabs: state.openTabs, tabsMap: state.tabsMap, previewTabId: state.previewTabId, }) ) safeLocalStorage.setItem( getRecentItemsStorageKey(projectRef), JSON.stringify({ items: state.recentItems, }) ) }) } }, [projectRef, state]) return {children} } export const useTabsStateSnapshot = (options?: Parameters[1]) => { const state = useContext(TabsStateContext) return useSnapshot(state, options) } export function createTabId(type: T, params: CreateTabIdParams[T]): string { switch (type) { case 'r': return `r-${(params as CreateTabIdParams['r']).id}` case 'v': return `v-${(params as CreateTabIdParams['v']).id}` case 'm': return `m-${(params as CreateTabIdParams['m']).id}` case 'f': return `f-${(params as CreateTabIdParams['f']).id}` case 'p': return `p-${(params as CreateTabIdParams['p']).id}` case 'sql': return `sql-${(params as CreateTabIdParams['sql']).id}` case 'notebook': return `notebook-${(params as CreateTabIdParams['notebook']).id}` case 'query': return `query-${(params as CreateTabIdParams['query']).id}` case 'chat': return `chat-${(params as CreateTabIdParams['chat']).id}` default: return '' } }