Files
supabase/apps/studio/components/layouts/Tabs.tsx
T
143f49414b Feat/tabs (#31071)
* init

* Update inner-side-menu.mdx

* chore: update SQL sidebar to use ui pattern components

* mor

* Update

* Update index.tsx

* init: merge table editor and sql editor and schema visualization together

* more

* move to valtio

* fix issue with Command+B shortcut

* now shows in treeviews if item is opened in tab

* Update ProjectLayout.tsx

* fix sidebar

* fix schema selector for non explorer version

* show schema name in tabs

* added schema names to tabs

* tabs have been updated to support preview tabs

* fix URL issue

* add empty state stuff

* Update SQLEditorNav.tsx

* preview tab works now

* more tabs stuff. 'new' tab also added

* new tab concept

* updates

* fix type errors

* remove unused files

* update test

* move back button, fix width issues on sidebar

* update sidebar logic

* Update ProjectLayout.tsx

* lots of updates. layouts now streamlined. localstorage for tabs in use

* moar

* bunch of new tab logic

* fix empty tab issue

* Update tabs.ts

* layouts switched

* new pages now have fixed layouts

* fix tabs

* fix code bg

* add tabs support for multiple project refs

* intialization issue

* update ID handling

* fixed isOpened state for SQL snippets

* remove old assistant because its bugging up panels

* preview style works in sql editor

* fix border

* removes preview tab if there is one

* fix background of loading skeleton

* lots of issues with types/icons/redirect

* new tab cards

* snippets in empty state now work

* moar stuff

* tabs now in feature flags

* Update tabs.ts

* Update tabs.ts

* moar

* add feature previews

* remove code not needed

* Update next-env.d.ts

* Delete FeaturePreviewModal.tsx

* fix typescript errors. remove more explorer stuff

* remove explorer files

* fixed issues with templates and quickstarts tab

* fixed active state when tabs are not opted in

* logic error

* fix open/highlight issue when opted out of tabs

* templates/quickstarts now displayed with new cards

* Update recent-items.tsx

* Update new-tab.tsx

* add icon back in

* add old empty state back in

* recent items updated to respect project ref

* localstorage cleanup on deletion

* moar

* overflow tabs now working

* correct tab names used for new sql templates/quickstarts

* ongoing queries fix

* cleanup

* update images

* Update RouteValidationWrapper.tsx

* Update AppLayout.tsx

* Update NavigationBar.tsx

* add headers back into side panels

* improve writing

* tabs now drag and drop a billion times better

* Update tabs.tsx

* Update tabs.tsx

* init issues on stores, which caused a race condition.

* fix hydration error

* fix new tab issue in sql

* Update ProjectLayout.tsx

* Update pnpm-lock.yaml

* Update new-tab.tsx

* move EditorMenuListSkeleton

* Fix type issues

* fixes: DESIGN-87

fixes: DESIGN-87

* refactor sort/filter components

* Update rules-set-button-text.tsx

* remove discussions for now

* small styling fixes

* Update FeaturePreviewModal.tsx

* Update FeaturePreviewModal.tsx

* Update RouteValidationWrapper.tsx

* revert

* revert

* revert

* revert

* revert

* more revert

* Update collapse-button.tsx

* Update SQLEditorTreeViewItem.tsx

* revert

* Update SchemaGraph.tsx

* Delete new-upcoming.tsx

* revert

* Update ProjectLayout.tsx

* fix home link

* Update table-editor.spec.ts

* test update

* Update table-editor.spec.ts

* Fix the playwright tests.

* layout fixes

* layout fix

* revert sort/filter

* Update LastSignInWrapper.tsx

* revert

* revert

* remove

* update file names

* revert

* revert

* revert

* Fix TreeView console error props

* Add guards in SQL Editor to ensure that feature preview tabs changes do not affect existing UI when flag is off

* Fix missing DefaultLayout in SQL editor templates + fix New tab

* Remove console log

* Remove DatabaseSelector for SQL editor on local

* Fix SQL editor shared favorites for local

* Fix test

* Ensure NewTab doesn't show up if flag is not toggled for SQL editor

* Decouple UI state changes from content-query and entity-types-infinite-query

* Fix tab closing unnecessary rerouting

* Beef up feature previews

* Fix create new table from table editor new tab

* Fix tabs getting incorrectly reset when going between table and SQL editors

* Fix last visited SQL snippet for both tabs and not tabs

* Fix last visited table for table editor tabs

* Clear dashboard history when closing last tab

* Fix loading dashboard history

* Add comment to refactor stores

* Ensure we only save up to 8 items for recent items for each type

* Remove unneccesary logic in tabs

* Smol style fix for DeleteAccountButton

* Smol fix

* Fix inability to close New tab

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Alaister Young <a@alaisteryoung.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2025-03-27 17:46:57 +08:00

325 lines
11 KiB
TypeScript

import {
DndContext,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { useParams } from 'common'
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
import { useAppStateSnapshot } from 'state/app-state'
import {
editorEntityTypes,
getTabsStore,
handleTabClose,
handleTabDragEnd,
handleTabNavigation,
makeTabPermanent,
type Tab,
} from 'state/tabs'
import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { useEditorType } from './editors/EditorsLayout.hooks'
import { CollapseButton } from './Tabs/CollapseButton'
/**
* Individual draggable tab component that handles:
* - Drag
* - Drop functionality
* - Dynamic schema name display
* - Tab label animations
* - Close button interactions
*/
const SortableTab = ({
tab,
index,
openTabs,
onClose,
}: {
tab: Tab
index: number
openTabs: Tab[]
onClose: (id: string) => void
}) => {
const router = useRouter()
const { ref } = useParams()
const currentSchema = (router.query.schema as string) || 'public'
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0,
}
// Update schema visibility check to include URL param comparison
const shouldShowSchema = useMemo(() => {
// For both table and schema tabs, show schema if:
// Any tab has a different schema than the current schema parameter
if (tab.type === 'r') {
const anyTabHasDifferentSchema = openTabs
.filter((t) => t.type === 'r')
.some((t) => t.metadata?.schema !== currentSchema)
return anyTabHasDifferentSchema
}
return false
}, [openTabs, currentSchema, tab.type])
// Create a motion version of TabsTrigger while preserving all functionality
// const MotionTabsTrigger = motion(TabsTrigger_Shadcn_)
return (
<motion.div
ref={setNodeRef}
style={style}
{...attributes}
layoutId={tab.id}
transition={{ duration: 0.045 }}
animate={{ opacity: isDragging ? 0 : 1 }}
className={cn('flex items-center h-10 first-of-type:border-l')}
>
<TabsTrigger_Shadcn_
value={tab.id}
onDoubleClick={() => makeTabPermanent(ref, tab.id)}
className={cn(
'flex items-center gap-2 px-3 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'border-b border-default',
// bottom border active rule
'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100',
'relative group h-full',
'hover:bg-surface-300 dark:hover:bg-surface-100',
tab.isPreview && 'italic font-light' // Optional: style preview tabs differently
)}
{...listeners}
>
<EntityTypeIcon type={tab.type} />
<div className="flex items-center gap-0">
<AnimatePresence mode="popLayout" initial>
{shouldShowSchema && (
<motion.span
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.15 }}
className="text-foreground-muted group-data-[state=active]:text-foreground-lighter"
>
{tab?.metadata?.schema}.
</motion.span>
)}
</AnimatePresence>
<span>{tab.label || 'Untitled'}</span>
</div>
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X size={12} className="text-foreground-light" />
</span>
<div className="absolute w-full top-0 left-0 right-0 h-px bg-foreground opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger_Shadcn_>
{index < openTabs.length && (
<div role="separator" className="h-full w-px bg-border" key={`separator-${tab.id}`} />
)}
</motion.div>
)
}
const TabPreview = ({ tab }: { tab: string }) => {
const { ref } = useParams()
const store = getTabsStore(ref)
const tabs = useSnapshot(store)
const tabData = tabs.tabsMap[tab]
if (!tabData) return null
return (
<motion.div
layoutId={tab}
transition={{ duration: 0.045 }}
animate={{ opacity: 0.7 }}
className="flex relative items-center gap-2 px-3 text-xs bg-dash-sidebar dark:bg-surface-100 shadow-lg rounded-sm h-10"
>
<EntityTypeIcon type={tabData.type} />
<span>{tabData.label || 'Untitled'}</span>
<div className="absolute w-full top-0 left-0 right-0 h-px bg-foreground-muted" />
</motion.div>
)
}
export function Tabs() {
const { ref } = useParams()
const router = useRouter()
const appSnap = useAppStateSnapshot()
const editor = useEditorType()
const store = getTabsStore(ref)
const tabs = useSnapshot(store)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 1, // Start with a very small distance
},
})
)
const openTabs = tabs.openTabs
.map((id) => tabs.tabsMap[id])
.filter((tab) => tab !== undefined) as Tab[]
const hasNewTab = router.asPath.includes('/new')
// Filter by editor type - only show SQL tabs for SQL editor and table tabs for table editor
const editorTabs = !!editor
? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type))
: []
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = tabs.openTabs.indexOf(active.id.toString())
const newIndex = tabs.openTabs.indexOf(over.id.toString())
if (oldIndex !== newIndex) {
handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router)
}
}
const handleClose = (id: string) => {
const onClearDashboardHistory = () => {
if (ref && editor) {
appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined)
}
}
handleTabClose({ ref, id, router, editor, onClearDashboardHistory })
}
const handleTabChange = (id: string) => {
handleTabNavigation(ref, id, router)
}
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<Tabs_Shadcn_
className="w-full flex"
value={hasNewTab ? 'new' : tabs.activeTab ?? undefined}
onValueChange={handleTabChange}
>
<CollapseButton hideTabs={false} />
<TabsList_Shadcn_ className="bg-surface-200 dark:bg-alternative rounded-b-none gap-0 h-10 flex items-center w-full z-[1] border-none overflow-clip overflow-x-auto ">
<SortableContext
items={editorTabs.map((tab) => tab.id)}
strategy={horizontalListSortingStrategy}
>
{editorTabs.map((tab, index) => (
<SortableTab
key={tab.id}
tab={tab}
index={index}
openTabs={openTabs}
onClose={() => handleClose(tab.id)}
/>
))}
</SortableContext>
{/* Non-draggable new tab */}
{hasNewTab && (
<>
<TabsTrigger_Shadcn_
value="new"
className={cn(
'flex items-center gap-2 px-3 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'relative group h-full border-t-2 !border-b-0',
'hover:bg-surface-300 dark:hover:bg-surface-100'
)}
>
<Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
<div className="flex items-center gap-0">
<span>New</span>
</div>
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleClose('new')
}}
>
<X size={12} className="text-foreground-light" />
</span>{' '}
<div className="absolute w-full -bottom-[1px] left-0 right-0 h-px bg-dash-sidebar dark:bg-surface-100 opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger_Shadcn_>
</>
)}
<AnimatePresence initial={false}>
{!hasNewTab && (
<motion.button
className="flex items-center justify-center w-10 h-10 hover:bg-surface-100 shrink-0 border-b"
onClick={() =>
router.push(
`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true`
)
}
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<Plus
size={16}
strokeWidth={1.5}
className="text-foreground-lighter hover:text-foreground-light"
/>
</motion.button>
)}
</AnimatePresence>
<div className="grow h-full border-b pr-6" />
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<DragOverlay dropAnimation={null}>
{tabs.activeTab ? <TabPreview tab={tabs.activeTab} /> : null}
</DragOverlay>
</DndContext>
)
}