chore: refactor tabs store (#35551)

* chore: refactor tabs store

* remove tabs from local storage when disabling preview
This commit is contained in:
Alaister Young authored and GitHub committed 2025-05-15 11:42:18 +08:00
1 parent aa9070c908
commit 22db2c323e
25 files changed
+667 -783

No files matched your search

@@ -5,7 +5,6 @@ import { useEffect } from 'react'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { removeTabsByEditor } from 'state/tabs'
@@ -48,11 +47,11 @@ const FeaturePreviewModal = () => {
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
})
if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) {
removeTabsByEditor(ref as string | undefined, 'table')
if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) {
removeTabsByEditor(ref, 'table')
}
if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) {
removeTabsByEditor(ref as string | undefined, 'sql')
if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) {
removeTabsByEditor(ref, 'sql')
}
}
@@ -9,7 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { makeActiveTabPermanent } from 'state/tabs'
import { useTabsStateSnapshot } from 'state/tabs'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns'
import { untitledSnippetTitle } from './SQLEditor.constants'
@@ -50,6 +50,7 @@ const MonacoEditor = ({
const { ref, content } = useParams()
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
@@ -152,7 +153,7 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
// make active tab permanent
makeActiveTabPermanent(ref)
tabsSnap.makeActiveTabPermanent()
const snippetCheck = snapV2.snippets[id]
@@ -13,7 +13,7 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
@@ -35,6 +35,7 @@ const RenameQueryModal = ({
const organization = useSelectedOrganization()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const { data: subscription } = useOrgSubscriptionQuery(
{ orgSlug: organization?.slug },
{ enabled: visible }
@@ -111,7 +112,7 @@ const RenameQueryModal = ({
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { label: nameInput })
tabsSnap.updateTab(tabId, { label: nameInput })
}
toast.success('Successfully renamed snippet!')
@@ -36,7 +36,7 @@ import {
useGetImpersonatedRoleState,
} from 'state/role-impersonation-state'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, getTabsStore, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
DropdownMenu,
@@ -52,7 +52,6 @@ import {
TooltipTrigger,
cn,
} from 'ui'
import { useSnapshot } from 'valtio'
import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
@@ -90,8 +89,7 @@ export const SQLEditor = () => {
const org = useSelectedOrganization()
const queryClient = useQueryClient()
const store = getTabsStore(ref)
const tabs = useSnapshot(store)
const tabs = useTabsStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
@@ -222,7 +220,7 @@ export const SQLEditor = () => {
snapV2.renameSnippet({ id, name })
if (isSQLEditorTabsEnabled && ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { label: name })
tabs.updateTab(tabId, { label: name })
}
} catch (error) {
// [Joshen] No error handler required as this happens in the background and not necessary to ping the user
@@ -529,7 +527,7 @@ export const SQLEditor = () => {
return () => {
if (ref) {
const tabId = createTabId('sql', { id })
updateTab(ref, tabId, { scrollTop: scrollTopRef.current })
tabs.updateTab(tabId, { scrollTop: scrollTopRef.current })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -25,7 +25,7 @@ import { getTables } from 'data/tables/tables-query'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, updateTab } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { Dictionary } from 'types'
import { SonnerProgress } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
@@ -69,6 +69,7 @@ const SidePanelEditor = ({
}: SidePanelEditorProps) => {
const { ref } = useParams()
const snap = useTableEditorStateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
@@ -494,7 +495,7 @@ const SidePanelEditor = ({
if (isTableEditorTabsEnabled && ref && payload.name) {
// [Joshen] Only table entities can be updated via the dashboard
const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id })
updateTab(ref, tabId, { label: payload.name })
tabsSnap.updateTab(tabId, { label: payload.name })
}
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
}
@@ -21,7 +21,7 @@ import { useUrlState } from 'hooks/ui/useUrlState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { Button } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
@@ -44,7 +44,7 @@ export const TableGridEditor = ({
const appSnap = useAppStateSnapshot()
const { ref: projectRef, id } = useParams()
const tabs = useTabsStore(projectRef)
const tabs = useTabsStateSnapshot()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema } = useQuerySchemaState()
@@ -83,7 +83,7 @@ export const TableGridEditor = ({
if (isTableEditorTabsEnabled && selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory })
tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
} else {
const tables = await getTables(selectedSchema)
if (tables.length > 0) {
@@ -122,8 +122,7 @@ export const TableGridEditor = ({
className="mt-2"
onClick={() => {
if (tabId) {
handleTabClose({
ref: projectRef,
tabs.handleTabClose({
id: tabId,
router,
editor,
@@ -156,7 +155,7 @@ export const TableGridEditor = ({
return (
// When any click happens in a table tab, the tab becomes permanent
<div className="h-full" onClick={() => makeActiveTabPermanent(project?.ref)}>
<div className="h-full" onClick={() => tabs.makeActiveTabPermanent()}>
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTable.id}`}
projectRef={projectRef}
@@ -7,6 +7,7 @@ import { DatabaseSelectorStateContextProvider } from 'state/database-selector'
import { RoleImpersonationStateContextProvider } from 'state/role-impersonation-state'
import { StorageExplorerStateContextProvider } from 'state/storage-explorer'
import { TableEditorStateContextProvider } from 'state/table-editor'
import { TabsStateContextProvider } from 'state/tabs'
export interface ProjectContextType {
project?: Project
@@ -42,18 +43,22 @@ export const ProjectContextProvider = ({
return (
<ProjectContext.Provider value={value}>
<TableEditorStateContextProvider key={`table-editor-state-${projectRef}`}>
<AiAssistantStateContextProvider
key={`ai-assistant-state-${projectRef}`}
projectRef={projectRef}
>
<StorageExplorerStateContextProvider key={`storage-explorer-state-${projectRef}`}>
<DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}>
<RoleImpersonationStateContextProvider key={`role-impersonation-state-${projectRef}`}>
{children}
</RoleImpersonationStateContextProvider>
</DatabaseSelectorStateContextProvider>
</StorageExplorerStateContextProvider>
</AiAssistantStateContextProvider>
<TabsStateContextProvider key={`tabs-state-${projectRef}`} projectRef={projectRef}>
<AiAssistantStateContextProvider
key={`ai-assistant-state-${projectRef}`}
projectRef={projectRef}
>
<StorageExplorerStateContextProvider key={`storage-explorer-state-${projectRef}`}>
<DatabaseSelectorStateContextProvider key={`database-selector-state-${projectRef}`}>
<RoleImpersonationStateContextProvider
key={`role-impersonation-state-${projectRef}`}
>
{children}
</RoleImpersonationStateContextProvider>
</DatabaseSelectorStateContextProvider>
</StorageExplorerStateContextProvider>
</AiAssistantStateContextProvider>
</TabsStateContextProvider>
</TableEditorStateContextProvider>
</ProjectContext.Provider>
)
@@ -7,7 +7,7 @@ import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty'
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
import { sqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
import { useSqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils'
import { useContentCountQuery } from 'data/content/content-count-query'
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
import { getContentById } from 'data/content/content-id-query'
@@ -28,7 +28,7 @@ import {
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
import { createTabId, getTabsStore, makeTabPermanent, removeTabs, useTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { SqlSnippets } from 'types'
import { Separator, TreeView } from 'ui'
import {
@@ -58,8 +58,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const project = useSelectedProject()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabStore = getTabsStore(projectRef)
const tabs = useTabsStore(projectRef)
const tabs = useTabsStateSnapshot()
const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const [sectionVisibility, setSectionVisibility] = useLocalStorage<SectionState>(
@@ -274,10 +273,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
[projectSnippetsTreeState]
)
const allSnippetsInView = [
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
]
const allSnippetsInView = useMemo(
() => [
...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
],
[privateSnippetsPages, sharedSqlSnippetsData]
)
// ==========================
// Snippet mutations from RQ
@@ -295,7 +297,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
// Update Tabs state - currently unknown how to differentiate between sql and non-sql content
// so we're just deleting all tabs for with matching IDs
const tabIds = data.map((id) => createTabId('sql', { id }))
removeTabs(projectRef, tabIds)
tabs.removeTabs(tabIds)
}
},
onError: (error, data) => {
@@ -537,11 +539,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
})
}, [projectRef, sharedSqlSnippetsData?.pages])
const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
useEffect(() => {
if (projectRef && isSuccess && isSQLEditorTabsEnabled) {
sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any })
if (isSuccess && isSQLEditorTabsEnabled) {
sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
}
}, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView, isSQLEditorTabsEnabled])
}, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup])
return (
<>
@@ -577,7 +580,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -589,7 +592,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -661,7 +664,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -673,7 +676,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
isPreview={isPreview}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
element={element}
onSelectDelete={() => {
@@ -751,7 +754,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId
const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -829,7 +832,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}}
onDoubleClick={(e) => {
e.preventDefault()
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
/>
)
@@ -3,7 +3,6 @@ import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock }
import Link from 'next/link'
import Papa from 'papaparse'
import { toast } from 'sonner'
import { useSnapshot } from 'valtio'
import { IS_PLATFORM } from 'common'
import {
@@ -28,7 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { copyToClipboard } from 'lib/helpers'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { createTabId, getTabsStore, makeTabPermanent } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
cn,
DropdownMenu,
@@ -66,10 +65,9 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
// For tabs preview flag logic
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const tabId = createTabId(entity.type, { id: entity.id })
const tabStore = getTabsStore(projectRef)
const isPreview = isTableEditorTabsEnabled ? tabStore.previewTabId === tabId : false
const tabs = useTabsStateSnapshot()
const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false
const tabs = useSnapshot(tabStore)
const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id)
const isActive = Number(id) === entity.id
const canEdit = isActive && !isLocked
@@ -233,7 +231,7 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
onDoubleClick={(e) => {
e.preventDefault()
const tabId = createTabId(entity.type, { id: entity.id })
makeTabPermanent(projectRef, tabId)
tabs.makeTabPermanent(tabId)
}}
>
<>
@@ -40,7 +40,7 @@ import {
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
@@ -115,12 +115,13 @@ const TableEditorMenu = () => {
}
}, [selectedTable?.schema])
const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
if (isTableEditorTabsEnabled && ref && entityTypes && !searchText) {
tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes })
if (isTableEditorTabsEnabled && entityTypes && !searchText) {
tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
}
}, [entityTypes])
}, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp])
return (
<>
@@ -16,7 +16,7 @@ import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { getTabsStore } from 'state/tabs'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import {
Button,
cn,
@@ -41,12 +41,12 @@ export function NewTab() {
const snap = useTableEditorStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const [templates] = partition(SQL_TEMPLATES, { type: 'template' })
const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' })
const { mutate: sendEvent } = useSendEventMutation()
const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
@@ -96,16 +96,14 @@ export function NewTab() {
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
const store = getTabsStore(ref)
const tabId = `sql-${snippet.id}`
store.openTabs = [...store.openTabs, tabId]
store.tabsMap[tabId] = {
const tabId = createTabId('sql', { id: snippet.id })
tabs.addTab({
id: tabId,
type: 'sql',
label: name,
metadata: { sqlId: snippet.id },
}
store.activeTab = tabId
})
router.push(`/project/${ref}/sql/${snippet.id}`)
} catch (error: any) {
@@ -2,30 +2,26 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import type { RecentItem } from 'state/recent-items'
import { getRecentItemsStore } from 'state/recent-items'
import { editorEntityTypes } from 'state/tabs'
import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs'
import { useEditorType } from '../editors/EditorsLayout.hooks'
dayjs.extend(relativeTime)
export function RecentItems() {
const { ref } = useParams()
const store = getRecentItemsStore(ref)
const recentItemsSnap = useSnapshot(store)
const tabs = useTabsStateSnapshot()
const editor = useEditorType()
if (!recentItemsSnap?.items || !ref) return null
if (!tabs?.recentItems || !ref) return null
// Filter items based on editor type
const filteredItems = !editor
? [...recentItemsSnap.items]
: recentItemsSnap.items.filter((item) => editorEntityTypes[editor].includes(item.type))
? [...tabs.recentItems]
: tabs.recentItems.filter((item) => editorEntityTypes[editor].includes(item.type))
const sortedItems = filteredItems.sort((a, b) => b.timestamp - a.timestamp)
@@ -50,7 +46,7 @@ export function RecentItems() {
) : (
<AnimatePresence>
<div className="grid grid-cols-1 gap-12 gap-y-0">
{sortedItems.map((item: RecentItem, index: number) => (
{sortedItems.map((item, index: number) => (
<motion.div
key={item.id}
initial={{ opacity: 0 }}
@@ -5,9 +5,8 @@ import { X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { makeTabPermanent, type Tab } from 'state/tabs'
import { useTabsStateSnapshot, type Tab } from 'state/tabs'
import { cn, TabsTrigger_Shadcn_ } from 'ui'
/**
@@ -30,9 +29,8 @@ export const SortableTab = ({
onClose: (id: string) => 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 = ({
>
<TabsTrigger_Shadcn_
value={tab.id}
onDoubleClick={() => 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',
@@ -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]
+5 -12
View File
@@ -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 (
+88 -103
View File
@@ -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 })
})
}, [])
}
@@ -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])
@@ -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()
+3 -2
View File
@@ -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',
@@ -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()
@@ -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',
@@ -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',
-160
View File
@@ -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<RecentItemsStore>({})
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)
}
-404
View File
@@ -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<TabsStateMap>({})
// 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<T extends TabType>(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)
}
+471
View File
@@ -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<typeof createTabsState>
export const TabsStateContext = createContext<TabsState>(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 <TabsStateContext.Provider value={state}>{children}</TabsStateContext.Provider>
}
export const useTabsStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
const state = useContext(TabsStateContext)
return useSnapshot(state, options)
}
export function createTabId<T extends TabType>(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,
})
)
}