mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
chore: refactor tabs store (#35551)
* chore: refactor tabs store * remove tabs from local storage when disabling preview
This commit is contained in:
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
|
||||
|
||||
+3
-2
@@ -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]
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user