- {sortedItems.map((item: RecentItem, index: number) => (
+ {sortedItems.map((item, index: number) => (
void
}) => {
const router = useRouter()
- const { ref } = useParams()
const currentSchema = (router.query.schema as string) || 'public'
-
+ const tabs = useTabsStateSnapshot()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
})
@@ -73,7 +71,7 @@ export const SortableTab = ({
>
makeTabPermanent(ref, tab.id)}
+ onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
className={cn(
'flex items-center gap-2 pl-3 pr-2.5 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx
index 1b271bb3401..44e608427dc 100644
--- a/apps/studio/components/layouts/Tabs/TabPreview.tsx
+++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx
@@ -1,14 +1,12 @@
import { motion } from 'framer-motion'
-import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
-import { getTabsStore } from 'state/tabs'
+import { useTabsStateSnapshot } from 'state/tabs'
export const TabPreview = ({ tab }: { tab: string }) => {
const { ref } = useParams()
- const store = getTabsStore(ref)
- const tabs = useSnapshot(store)
+ const tabs = useTabsStateSnapshot()
const tabData = tabs.tabsMap[tab]
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index ef65b70fce2..471d3496424 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -15,14 +15,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useAppStateSnapshot } from 'state/app-state'
-import {
- editorEntityTypes,
- handleTabClose,
- handleTabDragEnd,
- handleTabNavigation,
- useTabsStore,
- type Tab,
-} from 'state/tabs'
+import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs'
import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { CollapseButton } from './CollapseButton'
@@ -35,7 +28,7 @@ export const EditorTabs = () => {
const appSnap = useAppStateSnapshot()
const editor = useEditorType()
- const tabs = useTabsStore(ref)
+ const tabs = useTabsStateSnapshot()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -68,7 +61,7 @@ export const EditorTabs = () => {
const newIndex = tabs.openTabs.indexOf(over.id.toString())
if (oldIndex !== newIndex) {
- handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router)
+ tabs.handleTabDragEnd(oldIndex, newIndex, active.id.toString(), router)
}
}
@@ -78,11 +71,11 @@ export const EditorTabs = () => {
appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined)
}
}
- handleTabClose({ ref, id, router, editor, onClearDashboardHistory })
+ tabs.handleTabClose({ id, router, editor, onClearDashboardHistory })
}
const handleTabChange = (id: string) => {
- handleTabNavigation(ref, id, router)
+ tabs.handleTabNavigation(id, router)
}
return (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts
index 9fc1e1225c3..de63834fda0 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts
+++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts
@@ -1,122 +1,107 @@
import { Entity } from 'data/entity-types/entity-types-infinite-query'
-import {
- getRecentItemsByType,
- getRecentItemsStore,
- RecentItem,
- removeRecentItems,
-} from 'state/recent-items'
-import { createTabId, editorEntityTypes, getTabsStore, removeTabs, updateTab } from 'state/tabs'
+import useLatest from 'hooks/misc/useLatest'
+import { useCallback } from 'react'
+import { createTabId, editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
-export const tableEditorTabsCleanUp = ({
- ref,
- schemas,
- entities,
-}: {
- ref: string
- schemas: string[]
- entities: Entity[]
-}) => {
- // Identity all entities by tab ID
- const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
+export function useTableEditorTabsCleanUp() {
+ const tabs = useTabsStateSnapshot()
+ const tabMapRef = useLatest(tabs.tabsMap)
+ const recentItemsRef = useLatest(tabs.recentItems)
+ const openTabsRef = useLatest(tabs.openTabs)
- // Remove any recent items that no longer exists in the database
- const recentItemsStore = getRecentItemsStore(ref)
+ return useCallback(({ schemas, entities }: { schemas: string[]; entities: Entity[] }) => {
+ // Identity all entities by tab ID
+ const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id }))
- const recentItemsFilteredToSchemas: string[] = []
- for (const schema of schemas) {
- recentItemsFilteredToSchemas.push(
- ...recentItemsStore?.items
- ?.filter((x: RecentItem) => x.metadata?.schema === schema)
- .map((x) => x.id)
- )
- }
+ const recentItemsFilteredToSchemas: string[] = []
+ for (const schema of schemas) {
+ recentItemsFilteredToSchemas.push(
+ ...recentItemsRef.current.filter((x) => x.metadata?.schema === schema).map((x) => x.id)
+ )
+ }
- const recentItemsToRemove = [
- ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
- ]
+ const recentItemsToRemove = [
+ ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)),
+ ]
- removeRecentItems(ref, recentItemsToRemove)
+ tabs.removeRecentItems(recentItemsToRemove)
- // Close any tabs which might have been opened but no longer exists in the database
- const tabsStore = getTabsStore(ref)
+ const tabsFilteredToSchemas: string[] = []
+ for (const schema of schemas) {
+ tabsFilteredToSchemas.push(
+ ...openTabsRef.current.filter((tabId) => {
+ const tab = tabMapRef.current[tabId]
+ return tab.metadata?.schema === schema
+ })
+ )
+ }
- const tabsFilteredToSchemas: string[] = []
- for (const schema of schemas) {
- tabsFilteredToSchemas.push(
- ...tabsStore?.openTabs?.filter((tabId) => {
- const tab = tabsStore.tabsMap[tabId]
- return tab.metadata?.schema === schema
- })
- )
- }
+ const tableEditorTabsToBeCleaned = [
+ ...tabsFilteredToSchemas.filter(
+ (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
+ ),
+ ]
- const tableEditorTabsToBeCleaned = [
- ...tabsFilteredToSchemas.filter(
- (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id)
- ),
- ]
+ // perform tabs cleanup
+ tabs.removeTabs(tableEditorTabsToBeCleaned)
- // perform tabs cleanup
- removeTabs(ref, tableEditorTabsToBeCleaned)
+ // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
+ // As the entity could've been renamed outside of the table editor
+ // e.g Using the SQL editor to rename the entity
+ const openTabs = openTabsRef.current
+ .map((id) => tabMapRef.current[id])
+ .filter((tab) => editorEntityTypes['table']?.includes(tab.type))
- // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
- // As the entity could've been renamed outside of the table editor
- // e.g Using the SQL editor to rename the entity
- const openTabs = tabsStore.openTabs
- .map((id) => tabsStore.tabsMap[id])
- .filter((tab) => editorEntityTypes['table']?.includes(tab.type))
-
- openTabs.forEach((tab) => {
- const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
- if (!!entity && entity.name !== tab.label) updateTab(ref, tab.id, { label: entity.name })
- })
+ openTabs.forEach((tab) => {
+ const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
+ if (!!entity && entity.name !== tab.label) tabs.updateTab(tab.id, { label: entity.name })
+ })
+ }, [])
}
-export const sqlEditorTabsCleanup = ({
- ref,
- snippets,
-}: {
- ref: string
- snippets: { id: string; type: string; name: string }[]
-}) => {
- // these are tabs that are static content
- // these canot be removed from localstorage based on this query request
- const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
+export function useSqlEditorTabsCleanup() {
+ const tabs = useTabsStateSnapshot()
+ const tabMapRef = useLatest(tabs.tabsMap)
+ const openTabsRef = useLatest(tabs.openTabs)
- // Identify all SQL snippets / content by their tab ids
- const currentContentIds = [
- ...snippets
- .filter((content) => content.type === 'sql')
- .map((content) => createTabId('sql', { id: content.id })),
- // append ignored tab IDs
- ...IGNORED_TAB_IDS,
- ]
+ return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => {
+ // these are tabs that are static content
+ // these canot be removed from localstorage based on this query request
+ const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts']
- // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
- const tabsStore = getTabsStore(ref)
- const snippetTabsToBeCleaned = tabsStore.openTabs.filter(
- (id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
- )
- removeTabs(ref, snippetTabsToBeCleaned)
+ // Identify all SQL snippets / content by their tab ids
+ const currentContentIds = [
+ ...snippets
+ .filter((content) => content.type === 'sql')
+ .map((content) => createTabId('sql', { id: content.id })),
+ // append ignored tab IDs
+ ...IGNORED_TAB_IDS,
+ ]
- // Remove any recent items that might no longer be existing (removed outside of the dashboard session)
- const recentItems = getRecentItemsByType(ref, 'sql')
- removeRecentItems(
- ref,
- recentItems
- ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
- : []
- )
+ // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session)
+ const snippetTabsToBeCleaned = openTabsRef.current.filter(
+ (id: string) => id.startsWith('sql') && !currentContentIds.includes(id)
+ )
+ tabs.removeTabs(snippetTabsToBeCleaned)
- // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
- // As the snippets name could've been updated outside of the SQL Editor session
- // e.g for a shared snippet, the owner could've updated the name of the snippet
- const openSqlTabs = tabsStore.openTabs
- .map((id) => tabsStore.tabsMap[id])
- .filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
+ // Remove any recent items that might no longer be existing (removed outside of the dashboard session)
+ const recentItems = tabs.getRecentItemsByType('sql')
+ tabs.removeRecentItems(
+ recentItems
+ ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
+ : []
+ )
- openSqlTabs.forEach((tab) => {
- const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
- if (!!snippet && snippet.name !== tab.label) updateTab(ref, tab.id, { label: snippet.name })
- })
+ // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
+ // As the snippets name could've been updated outside of the SQL Editor session
+ // e.g for a shared snippet, the owner could've updated the name of the snippet
+ const openSqlTabs = openTabsRef.current
+ .map((id) => tabMapRef.current[id])
+ .filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
+
+ openSqlTabs.forEach((tab) => {
+ const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
+ if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name })
+ })
+ }, [])
}
diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx
index da2077944bf..8e4bab3f158 100644
--- a/apps/studio/pages/project/[ref]/editor/[id].tsx
+++ b/apps/studio/pages/project/[ref]/editor/[id].tsx
@@ -9,13 +9,13 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout'
import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
-import { addTab, createTabId, getTabsStore } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const { id: _id, ref: projectRef } = useParams()
const id = _id ? Number(_id) : undefined
- const store = getTabsStore(projectRef)
+ const store = useTabsStateSnapshot()
const { project } = useProjectContext()
const { data: selectedTable, isLoading } = useTableEditorQuery({
@@ -37,7 +37,7 @@ const TableEditorPage: NextPageWithLayout = () => {
if (isTableEditorTabsEnabled && selectedTable && projectRef) {
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
if (!store.tabsMap[tabId]) {
- addTab(projectRef, {
+ store.addTab({
id: tabId,
type: selectedTable.entity_type,
label: selectedTable.name,
@@ -49,7 +49,7 @@ const TableEditorPage: NextPageWithLayout = () => {
})
} else {
// If tab already exists, just make it active
- store.activeTab = tabId
+ store.makeTabActive(tabId)
}
}
}, [selectedTable, id, projectRef, isTableEditorTabsEnabled])
diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx
index e1a129c3808..b970f2e90a1 100644
--- a/apps/studio/pages/project/[ref]/editor/index.tsx
+++ b/apps/studio/pages/project/[ref]/editor/index.tsx
@@ -10,13 +10,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
-import { editorEntityTypes, getTabsStore } from 'state/tabs'
+import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
- const tabStore = getTabsStore(projectRef)
+ const tabStore = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx
index 8b37d86603f..97b26a634a1 100644
--- a/apps/studio/pages/project/[ref]/sql/[id].tsx
+++ b/apps/studio/pages/project/[ref]/sql/[id].tsx
@@ -11,7 +11,7 @@ import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
import { useContentIdQuery } from 'data/content/content-id-query'
import { useAppStateSnapshot } from 'state/app-state'
import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlEditor: NextPageWithLayout = () => {
@@ -20,6 +20,7 @@ const SqlEditor: NextPageWithLayout = () => {
const appSnap = useAppStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
+ const tabs = useTabsStateSnapshot()
const allSnippets = useSnippets(ref!)
@@ -65,7 +66,7 @@ const SqlEditor: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id })
const snippet = allSnippets.find((x) => x.id === id)
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: snippet?.name || 'Untitled Query',
diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx
index 44e2019cf0a..633f87e8738 100644
--- a/apps/studio/pages/project/[ref]/sql/index.tsx
+++ b/apps/studio/pages/project/[ref]/sql/index.tsx
@@ -8,13 +8,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAppStateSnapshot } from 'state/app-state'
-import { getTabsStore } from 'state/tabs'
+import { useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const TableEditorPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
- const store = getTabsStore(projectRef)
+ const store = useTabsStateSnapshot()
const appSnap = useAppStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
index 71e9e91bf1d..bdeffe57588 100644
--- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
+++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlQuickstarts: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
+ const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlQuickstarts: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'quickstarts' })
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: 'Quickstarts',
diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx
index e481286527e..45920a90c96 100644
--- a/apps/studio/pages/project/[ref]/sql/templates.tsx
+++ b/apps/studio/pages/project/[ref]/sql/templates.tsx
@@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout'
import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu'
-import { addTab, createTabId } from 'state/tabs'
+import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
const SqlTemplates: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams<{ ref: string }>()
+ const tabs = useTabsStateSnapshot()
const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
@@ -23,7 +24,7 @@ const SqlTemplates: NextPageWithLayout = () => {
const tabId = createTabId('sql', { id: 'templates' })
- addTab(ref, {
+ tabs.addTab({
id: tabId,
type: 'sql',
label: 'Templates',
diff --git a/apps/studio/state/recent-items.ts b/apps/studio/state/recent-items.ts
deleted file mode 100644
index a6272e3d3b4..00000000000
--- a/apps/studio/state/recent-items.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { partition } from 'lodash'
-import { proxy, subscribe } from 'valtio'
-import { editorEntityTypes, Tab, TabType } from './tabs'
-
-/**
- * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
- * where we have contexts and such - ideally we need to refactor this for consistency
- * and the main benefit from our usual way of writing stores is that we do not need to pass
- * in the project ref to all the functions, as the stores are written to be scoped to the project
- *
- * So do not use this as reference for writing new valtio stores - refer to database-selector instead
- */
-
-// Define the key for storing recent items in localStorage
-const RECENT_ITEMS_KEY = 'supabase_recent_items'
-// Define the maximum number of recent items to store
-const MAX_RECENT_ITEMS = 8
-
-const getStorageKey = (ref: string) => `${RECENT_ITEMS_KEY}_${ref}`
-
-// Define the structure of a recent item
-export interface RecentItem {
- id: string // Unique identifier for the item
- type: TabType // Type of the tab (e.g., sql, table)
- label: string // Label for the item
- timestamp: number // Timestamp of when the item was added
- metadata?: {
- // Optional metadata associated with the item
- schema?: string // Schema name (if applicable)
- name?: string // Name of the item (if applicable)
- tableId?: number // Table ID (if applicable)
- sqlId?: string // SQL ID (if applicable)
- }
-}
-
-// Define the structure of the recent items state
-interface RecentItems {
- items: RecentItem[] // Each project just has an array of items
-}
-
-interface RecentItemsStore {
- [key: string]: RecentItems // Removed | undefined since we always initialize it
-}
-
-const defaultState: RecentItems = {
- items: [],
-}
-
-export const recentItemsStore = proxy({})
-
-export const getRecentItemsStore = (ref: string | undefined): RecentItems => {
- if (!ref) return proxy(defaultState)
- if (!recentItemsStore[ref]) {
- const stored = localStorage.getItem(getStorageKey(ref))
- console.debug('[Recent Items] Loading stored items:', stored)
-
- try {
- const parsed = stored ? JSON.parse(stored) : defaultState
- // Validate the shape of the data
- if (!parsed.items || !Array.isArray(parsed.items)) {
- console.warn('[Recent Items] Invalid stored data, using default')
- recentItemsStore[ref] = proxy({ ...defaultState })
- } else {
- recentItemsStore[ref] = proxy(parsed)
- }
- } catch (error) {
- console.error('[Recent Items] Failed to parse stored items:', error)
- recentItemsStore[ref] = proxy({ ...defaultState })
- }
- }
-
- return recentItemsStore[ref]
-}
-
-// Subscribe to changes for each ref and save to localStorage
-if (typeof window !== 'undefined') {
- subscribe(recentItemsStore, () => {
- Object.entries(recentItemsStore).forEach(([ref, state]) => {
- try {
- localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
- } catch (error) {
- console.error('Failed to save recent items to localStorage:', error)
- }
- })
- })
-}
-
-// Function to add a recent item
-export const addRecentItem = (ref: string | undefined, tab: Tab) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- // Check if an item with the same ID already exists
- const existingItem = store.items.find((item) => item.id === tab.id)
-
- if (existingItem) {
- // If it exists, update its timestamp
- existingItem.timestamp = Date.now()
- 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: tab.label || 'Untitled', // 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.items.unshift(recentItem)
-
- // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type
- const [itemsOfSameType, itemsOfDifferentType] = partition(store.items, (item) => {
- if (editorEntityTypes.table.includes(item.type)) return item
- })
- store.items = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType]
-}
-
-// Function to clear all recent items
-export const clearRecentItems = (ref: string | undefined) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = [] // Set items to an empty array
-}
-
-// Function to remove a specific recent item by its ID
-export const removeRecentItem = (ref: string | undefined, itemId: string) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => item.id !== itemId)
-}
-
-// Function to remove multiple recent items by their IDs
-export const removeRecentItems = (ref: string | undefined, itemIds: string[]) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => !itemIds.includes(item.id))
-}
-
-// Function to remove recent items by their type
-export const removeRecentItemsByType = (ref: string | undefined, type: TabType) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- store.items = store.items.filter((item) => item.type !== type)
-}
-
-// Function to get all recent items
-export const getRecentItems = (ref: string | undefined) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- return store.items
-}
-
-// Function to get recent items by their type
-export const getRecentItemsByType = (ref: string | undefined, type: TabType) => {
- if (!ref) return
- const store = getRecentItemsStore(ref)
- return store.items.filter((item) => item.type === type)
-}
diff --git a/apps/studio/state/tabs.ts b/apps/studio/state/tabs.ts
deleted file mode 100644
index c04c828116a..00000000000
--- a/apps/studio/state/tabs.ts
+++ /dev/null
@@ -1,404 +0,0 @@
-import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
-import { NextRouter } from 'next/router'
-import { ReactNode } from 'react'
-import { proxy, subscribe, useSnapshot } from 'valtio'
-import { addRecentItem } from './recent-items'
-
-/**
- * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores
- * where we have contexts and such - ideally we need to refactor this for consistency
- * and the main benefit from our usual way of writing stores is that we do not need to pass
- * in the project ref to all the functions, as the stores are written to be scoped to the project
- *
- * So do not use this as reference for writing new valtio stores - refer to database-selector instead
- */
-
-// Object mapping editor types to their corresponding tab types
-export const editorEntityTypes = {
- table: ['r', 'v', 'm', 'f', 'p'],
- sql: ['sql'],
-}
-
-// Define the type of tabs available in the application
-export type TabType = ENTITY_TYPE | 'sql'
-
-type CreateTabIdParams = {
- r: { id: number }
- v: { id: number }
- m: { id: number }
- f: { id: number }
- p: { id: number }
- sql: { id: string }
- schema: { schema: string }
- view: never
- function: never
- new: never
-}
-
-export interface Tab {
- id: string // Unique identifier for the tab
- type: TabType // Type of the tab, which can be an entity type or specific string values
- label?: string // Optional label for the tab, displayed to the user
- icon?: ReactNode // Optional icon associated with the tab
- metadata?: {
- // Optional metadata related to the tab
- schema?: string // Optional schema name associated with the tab
- name?: string // Optional name of the entity represented by the tab
- tableId?: number // Optional ID of the table associated with the tab
- sqlId?: string // Optional ID of the SQL query associated with the tab
- scrollTop?: number // Optional scroll top of the tab (Currently just for SQL query)
- }
- isPreview?: boolean // Optional flag indicating if the tab is in preview mode
- createdAt?: Date // Optional timestamp for when the tab was created
- updatedAt?: Date // Optional timestamp for when the tab was last updated
-}
-
-// Interface representing the state of tabs
-interface TabsState {
- activeTab: string | null
- openTabs: string[]
- tabsMap: { [key: string]: Tab }
- previewTabId?: string
-}
-
-// Map of tab states for different references
-interface TabsStateMap {
- [ref: string]: TabsState
-}
-
-const STORAGE_KEY = 'supabase_studio_tabs'
-const getStorageKey = (ref: string) => `${STORAGE_KEY}_${ref}`
-
-const defaultState: TabsState = {
- openTabs: [],
- activeTab: null,
- tabsMap: {},
-}
-
-// Store now holds states for multiple refs
-export const tabsStore = proxy({})
-
-// Helper to get/create state for a specific ref
-export const getTabsStore = (ref?: string): TabsState => {
- if (!ref) return proxy(defaultState)
- if (!tabsStore[ref]) {
- const stored = localStorage.getItem(getStorageKey(ref))
- console.debug('[Tabs] Loading stored tabs:', stored)
- try {
- const parsed = stored ? JSON.parse(stored) : defaultState
- if (
- !parsed.openTabs ||
- !Array.isArray(parsed.openTabs) ||
- !parsed.tabsMap ||
- typeof parsed.tabsMap !== 'object'
- ) {
- console.warn('[Tabs] Invalid stored data, using default')
- tabsStore[ref] = proxy({ ...defaultState })
- } else {
- tabsStore[ref] = proxy(parsed)
- }
- } catch (error) {
- console.error('[Tabs] Failed to parse stored tabs:', error)
- tabsStore[ref] = proxy({ ...defaultState })
- }
- }
-
- return tabsStore[ref]
-}
-
-export const useTabsStore = (ref?: string) => {
- const store = getTabsStore(ref)
- return useSnapshot(store)
-}
-
-// Subscribe to changes for each ref and save to localStorage
-if (typeof window !== 'undefined') {
- subscribe(tabsStore, () => {
- Object.entries(tabsStore).forEach(([ref, state]) => {
- try {
- localStorage.setItem(getStorageKey(ref), JSON.stringify(state))
- } catch (error) {
- console.error('Failed to save tabs to localStorage:', error)
- }
- })
- })
-}
-
-// Function to add a new tab to the store
-export const addTab = (ref: string | undefined, tab: Tab) => {
- if (!ref) return
- const store = getTabsStore(ref)
-
- // 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) addRecentItem(ref, 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
- addRecentItem(ref, 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
-}
-
-// 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()
-export const removeTab = (ref: string | undefined, id: string) => {
- const store = getTabsStore(ref)
- const idx = store.openTabs.indexOf(id)
- store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
- delete store.tabsMap[id]
-
- // 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()
-export const removeTabs = (ref: string | undefined, ids: string[]) => {
- if (!ref) return
- if (!ids.length) return
-
- ids.forEach((id) => removeTab(ref, id))
-}
-
-export const updateTab = (
- ref: string,
- id: string,
- updates: { label?: string; scrollTop?: number }
-) => {
- const store = getTabsStore(ref)
-
- if (!!store.tabsMap[id]) {
- if ('label' in updates) {
- store.tabsMap[id].label = updates.label
- }
- if ('scrollTop' in updates && store.tabsMap[id].metadata) {
- store.tabsMap[id].metadata.scrollTop = updates.scrollTop
- }
- }
-}
-
-// Function to reorder tabs in the store
-export const reorderTabs = (ref: string | undefined, oldIndex: number, newIndex: number) => {
- if (!ref) return
- const store = getTabsStore(ref)
- const newOpenTabs = [...store.openTabs]
- const [removedTab] = newOpenTabs.splice(oldIndex, 1)
- newOpenTabs.splice(newIndex, 0, removedTab)
- store.openTabs = newOpenTabs
-}
-
-// Function to make a tab permanent
-export const makeTabPermanent = (ref: string | undefined, tabId: string) => {
- if (!ref) return
- const store = getTabsStore(ref)
- const tab = store.tabsMap[tabId]
-
- if (tab?.isPreview) {
- tab.isPreview = false
- store.previewTabId = undefined
- // Add to recent items when preview tab becomes permanent
- addRecentItem(ref, tab)
- }
-}
-
-// make the active tab permanent if it is a preview
-export const makeActiveTabPermanent = (ref?: string) => {
- if (!ref) return false
- const store = getTabsStore(ref)
-
- if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) {
- makeTabPermanent(ref, store.activeTab)
- return true
- }
- return false
-}
-
-// handle navigation to a specific tab
-export const handleTabNavigation = (ref: string | undefined, id: string, router: NextRouter) => {
- if (!ref) return
-
- const store = getTabsStore(ref)
- const tab = store.tabsMap[id]
- if (!tab) return
-
- store.activeTab = id
-
- // Add to recent items when navigating to a non-preview, non-new tab
- if (!tab.isPreview) addRecentItem(ref, 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 'r':
- case 'v':
- case 'm':
- case 'f':
- case 'p':
- router.push(
- `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
- )
- break
- }
-}
-
-// Function to handle closing a tab
-export const handleTabClose = ({
- ref,
- id,
- router,
- editor,
- onClose,
- onClearDashboardHistory,
-}: {
- ref: string | undefined
- id: string
- router: NextRouter
- editor?: 'sql' | 'table'
- onClose?: (id: string) => void
- onClearDashboardHistory: () => void
-}) => {
- if (!ref) return
-
- const store = getTabsStore(ref)
- const tabBeingClosed = store.tabsMap[id]
- const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
-
- const nextTabId = !editor
- ? undefined
- : tabsAfterClosing.filter((tab) => {
- return editorEntityTypes[editor]?.includes(tab.type)
- })[0]?.id
-
- 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
- handleTabNavigation(ref, 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 '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}`)
- }
- }
- }
-
- onClose?.(id)
-}
-
-// Function to handle the end of a tab drag event
-export const handleTabDragEnd = (
- ref: string | undefined,
- oldIndex: number,
- newIndex: number,
- tabId: string,
- router: any
-) => {
- if (!ref) return
- const store = getTabsStore(ref)
- // Make permanent if needed
- const draggedTab = store.tabsMap[tabId]
- if (draggedTab?.isPreview) {
- makeTabPermanent(ref, tabId)
- }
-
- // Reorder tabs
- const newOpenTabs = [...store.openTabs]
- newOpenTabs.splice(oldIndex, 1)
- newOpenTabs.splice(newIndex, 0, tabId)
-
- store.openTabs = newOpenTabs
- store.activeTab = tabId
-
- // Handle navigation
- handleTabNavigation(ref, tabId, router)
-}
-
-// Function to create a unique tab ID based on type and parameters
-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}`
- default:
- return ''
- }
-}
-
-// Function to remove tabs based on their editor type
-export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') {
- if (!ref) return
- const store = getTabsStore(ref)
-
- // Get tab IDs to remove
- const tabIdsToRemove = store.openTabs.filter((id) => {
- return editorEntityTypes[type].some((entityType) => id.startsWith(`${entityType}-`))
- })
-
- removeTabs(ref, tabIdsToRemove)
-}
diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx
new file mode 100644
index 00000000000..b6567fca5a0
--- /dev/null
+++ b/apps/studio/state/tabs.tsx
@@ -0,0 +1,471 @@
+import { useConstant } from 'common'
+import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
+import { partition } from 'lodash'
+import { NextRouter } from 'next/router'
+import { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react'
+import { proxy, subscribe, useSnapshot } from 'valtio'
+
+export const editorEntityTypes = {
+ table: ['r', 'v', 'm', 'f', 'p'],
+ sql: ['sql'],
+}
+
+export type TabType = ENTITY_TYPE | 'sql'
+
+type CreateTabIdParams = {
+ r: { id: number }
+ v: { id: number }
+ m: { id: number }
+ f: { id: number }
+ p: { id: number }
+ sql: { id: string }
+ schema: { schema: string }
+ view: never
+ function: never
+ new: never
+}
+
+export interface Tab {
+ id: string
+ type: TabType
+ label?: string
+ icon?: ReactNode
+ metadata?: {
+ schema?: string
+ name?: string
+ tableId?: number
+ sqlId?: string
+ scrollTop?: number
+ }
+ isPreview?: boolean
+ createdAt?: Date
+ updatedAt?: Date
+}
+
+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
+ }
+}
+
+const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items'
+const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}`
+
+function getSavedRecentItems(ref: string): RecentItem[] {
+ if (typeof window === 'undefined' || !ref) return []
+
+ const stored = localStorage.getItem(getRecentItemsStorageKey(ref))
+
+ try {
+ return JSON.parse(stored ?? '{"items": []}').items
+ } catch (error) {
+ return []
+ }
+}
+
+const DEFAULT_TABS_STATE = {
+ activeTab: null as string | null,
+ openTabs: [] as string[],
+ tabsMap: {} as { [key: string]: Tab },
+ previewTabId: undefined as string | undefined,
+}
+const TABS_STORAGE_KEY = 'supabase_studio_tabs'
+const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}`
+
+function getSavedTabs(ref: string) {
+ if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE
+
+ const stored = localStorage.getItem(getTabsStorageKey(ref))
+
+ if (!stored) return DEFAULT_TABS_STATE
+
+ try {
+ const parsed = JSON.parse(
+ stored ?? JSON.stringify(DEFAULT_TABS_STATE)
+ ) as typeof DEFAULT_TABS_STATE
+
+ if (
+ !parsed.openTabs ||
+ !Array.isArray(parsed.openTabs) ||
+ !parsed.tabsMap ||
+ typeof parsed.tabsMap !== 'object'
+ ) {
+ return DEFAULT_TABS_STATE
+ }
+
+ return parsed
+ } catch (error) {
+ return DEFAULT_TABS_STATE
+ }
+}
+
+function createTabsState(projectRef: string) {
+ const recentItems = getSavedRecentItems(projectRef)
+ const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
+
+ 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()
+ 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: tab.label || 'Untitled', // 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,
+
+ 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
+ },
+ updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => {
+ if (!!store.tabsMap[id]) {
+ if ('label' in updates) {
+ store.tabsMap[id].label = updates.label
+ }
+ if ('scrollTop' in updates && store.tabsMap[id].metadata) {
+ store.tabsMap[id].metadata.scrollTop = updates.scrollTop
+ }
+ }
+ },
+ // 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) => {
+ const idx = store.openTabs.indexOf(id)
+ store.openTabs = store.openTabs.filter((tabId) => tabId !== id)
+ delete store.tabsMap[id]
+
+ // 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
+ if (!tab.isPreview) 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 'r':
+ case 'v':
+ case 'm':
+ case 'f':
+ case 'p':
+ router.push(
+ `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}`
+ )
+ break
+ }
+ },
+ handleTabClose: ({
+ id,
+ router,
+ editor,
+ onClose,
+ onClearDashboardHistory,
+ }: {
+ id: string
+ router: NextRouter
+ editor?: 'sql' | 'table'
+ onClose?: (id: string) => void
+ onClearDashboardHistory: () => void
+ }) => {
+ const tabBeingClosed = store.tabsMap[id]
+ const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id)
+
+ const nextTabId = !editor
+ ? undefined
+ : tabsAfterClosing.filter((tab) => {
+ return editorEntityTypes[editor]?.includes(tab.type)
+ })[0]?.id
+
+ 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 '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}`)
+ }
+ }
+ }
+
+ onClose?.(id)
+ },
+ handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: any) => {
+ // 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 = ({
+ projectRef,
+ children,
+}: PropsWithChildren<{ projectRef?: string }>) => {
+ const state = useConstant(() => createTabsState(projectRef ?? ''))
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && projectRef) {
+ return subscribe(state, () => {
+ localStorage.setItem(
+ getTabsStorageKey(projectRef),
+ JSON.stringify({
+ activeTab: state.activeTab,
+ openTabs: state.openTabs,
+ tabsMap: state.tabsMap,
+ previewTabId: state.previewTabId,
+ })
+ )
+ localStorage.setItem(
+ getRecentItemsStorageKey(projectRef),
+ JSON.stringify({
+ items: state.recentItems,
+ })
+ )
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ 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}`
+ default:
+ return ''
+ }
+}
+
+// Remove from local storage when feature flag is disabled
+export function removeTabsByEditor(ref: string, editor: 'table' | 'sql') {
+ // Recent items
+ const recentItems = getSavedRecentItems(ref)
+ const filteredRecentItems = recentItems.filter((item) =>
+ editor === 'sql' ? item.type !== 'sql' : item.type === 'sql'
+ )
+ localStorage.setItem(
+ getRecentItemsStorageKey(ref),
+ JSON.stringify({ items: filteredRecentItems })
+ )
+
+ // Tabs
+ const tabs = getSavedTabs(ref)
+ const filteredTabsMap = Object.fromEntries(
+ Object.entries(tabs.tabsMap).filter(([, tab]) =>
+ editor === 'sql' ? tab.type !== 'sql' : tab.type === 'sql'
+ )
+ )
+
+ const filteredOpenTabs = tabs.openTabs.filter((tabId) => filteredTabsMap[tabId])
+ localStorage.setItem(
+ getTabsStorageKey(ref),
+ JSON.stringify({
+ activeTab: filteredOpenTabs.includes(tabs.activeTab ?? '')
+ ? tabs.activeTab
+ : filteredOpenTabs[0] ?? null,
+ openTabs: filteredOpenTabs,
+ tabsMap: filteredTabsMap,
+ previewTabId: tabs.previewTabId,
+ })
+ )
+}