Files
Saxon FletcherandJoshen Lim d9742d707f chore(studio): refine Explorer sidebar breadcrumbs (#50188)
Moves Explorer’s back navigation and create actions into a reusable
sidebar breadcrumb header. Reduces product-menu headings globally to
`text-sm` and keeps breadcrumb links free of padding, borders, and
backgrounds.

### How to test

1. Open `/project/<ref>/explorer` and confirm the header shows Explorer
and the SQL Editor switch action.
2. Open Notebooks and Chats. Confirm the header shows `Explorer >
Notebooks/Chats` and the corresponding create action works.
3. Return using the Explorer breadcrumb with a click or Tab + Enter.
Check that the label stays aligned and has no hover background.
4. Open another product, such as Database, and confirm its sidebar
heading uses the smaller font size.

5. At a mobile viewport, open the menu and repeat the notebook/chat
actions and back navigation without closing the sheet. Confirm the
header stays current and disappears when returning to the main menu or
opening another product.

Validation: 18 focused tests, typecheck, formatting, and lint ratchet
passed.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a shared Explorer sidebar header with breadcrumbs and contextual
actions for creating notebooks and chats.
- Added keyboard-accessible navigation between the Explorer overview and
notebook or chat sections.
- Added support for customized product menu headers across project
layouts.

- **Improvements**
- Centralized Explorer navigation and actions in the shared sidebar
layout.
- Improved mobile menu updates when navigating between Explorer
resources.
- Refined Explorer home layout and drag-handle behavior across screen
sizes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-10 15:38:19 +08:00

543 lines
19 KiB
TypeScript

import { LOCAL_STORAGE_KEYS, mergeRefs, useFlag, useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon } from 'lucide-react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import {
forwardRef,
Fragment,
isValidElement,
useEffect,
useLayoutEffect,
useRef,
type PropsWithChildren,
type ReactNode,
} from 'react'
import {
Alert,
AlertDescription,
AlertTitle,
cn,
LogoLoader,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useIsMobile,
usePanelRef,
} from 'ui'
import { useEditorType } from '../editors/EditorsLayout.hooks'
import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext'
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
import { ProductMenuBar } from '../Navigation/ProductMenuBar'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
import { PausingState } from './PausingState'
import { ResizingState } from './ResizingState'
import RestartingState from './RestartingState'
import { RestoreFailedState } from './RestoreFailedState'
import { RestoringState } from './RestoringState'
import { UnhealthyState } from './UnhealthyState'
import { UpgradingState } from './UpgradingState'
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { BannerExplorer } from '@/components/ui/BannerStack/Banners/BannerExplorer'
import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { withAuth } from '@/hooks/misc/withAuth'
import { PROJECT_STATUS } from '@/lib/constants'
import { MANAGED_BY } from '@/lib/constants/infrastructure'
import { buildStudioPageTitle } from '@/lib/page-title'
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
import { useAppStateSnapshot } from '@/state/app-state'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
// [Joshen] This is temporary while we unblock users from managing their project
// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
const routesToIgnoreProjectDetailsRequest = [
'/project/[ref]/settings/infrastructure',
'/project/[ref]/settings/addons',
'/project/[ref]/settings/general',
'/project/[ref]/database/settings',
'/project/[ref]/storage/settings',
]
const routesToIgnoreDBConnection = [
'/project/[ref]/branches',
'/project/[ref]/database/backups',
'/project/[ref]/settings',
'/project/[ref]/functions',
'/project/[ref]/logs',
]
const routesToIgnorePostgrestConnection = [
'/project/[ref]/settings/general',
'/project/[ref]/settings/infrastructure',
'/project/[ref]/settings/addons',
'/project/[ref]/database/settings',
'/project/[ref]/reports',
]
const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
function getProjectIntegrationBannerDismissKey({
projectRef,
integrationSource,
}: {
projectRef?: string
integrationSource?: string | null
}) {
if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
}
export interface ProjectLayoutProps {
isLoading?: boolean
isBlocking?: boolean
product?: string
productMenuBadge?: ReactNode
productMenuHeader?: ReactNode
productMenu?: ReactNode
browserTitle?: {
entity?: string
section?: string
override?: string
}
// Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
selectedTable?: string
resizableSidebar?: boolean
productMenuClassName?: string
}
export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
(
{
isLoading = false,
isBlocking = true,
product = '',
productMenuBadge,
productMenuHeader,
productMenu,
browserTitle,
children,
selectedTable,
resizableSidebar = false,
productMenuClassName,
},
ref
) => {
const router = useRouter()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: selectedProject } = useSelectedProjectQuery()
const { addBanner, dismissBanner } = useBannerStack()
const { data: resourceWarnings } = useResourceWarningsQuery({
slug: selectedOrganization?.slug,
})
const projectResourceWarnings = (Array.isArray(resourceWarnings) ? resourceWarnings : []).find(
(w) => w.project === selectedProject?.ref
)
const isComputeNearExhaustion =
!!projectResourceWarnings?.cpu_exhaustion ||
!!projectResourceWarnings?.memory_and_swap_exhaustion ||
!!projectResourceWarnings?.disk_space_exhaustion ||
!!projectResourceWarnings?.disk_io_exhaustion
const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
const isExplorerEnabled = useFlag('explorer')
const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
false
)
const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
useLocalStorageQuery(
getProjectIntegrationBannerDismissKey({
projectRef: selectedProject?.ref,
integrationSource: selectedProject?.integration_source,
}),
false
)
const [isExplorerBannerDismissed, , { isSuccess: isLocalStorageReady }] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.EXPLORER_BANNER_DISMISSED,
false
)
const { showSidebar } = useAppStateSnapshot()
const {
content: mobileSheetContent,
setContent: setMobileSheetContent,
registerOpenMenu,
} = useMobileSheet()
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
const currentSectionKey = getSectionKeyFromPathname(pathname)
const mainScrollContainer = useMainScrollContainer()
const setMainScrollContainer = useSetMainScrollContainer()
const combinedRef = mergeRefs(ref, setMainScrollContainer)
const { appTitle } = useCustomContent(['app:title'])
const brandTitle = appTitle || 'Supabase'
const isMobile = useIsMobile()
const editor = useEditorType()
const forceShowProductMenu = editor === undefined
const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
const panelRef = usePanelRef()
const projectName = selectedProject?.name
const organizationName = selectedOrganization?.name
const pageTitle =
browserTitle?.override ||
buildStudioPageTitle({
entity: browserTitle?.entity ?? selectedTable,
section: browserTitle?.section,
surface: product,
project: projectName,
org: organizationName,
brand: brandTitle,
}) ||
brandTitle
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
const ignorePausedState =
router.pathname === '/project/[ref]' ||
router.pathname.includes('/project/[ref]/settings') ||
router.pathname.includes('/project/[ref]/functions') ||
router.pathname.includes('/project/[ref]/logs')
const showPausedState = isPaused && !ignorePausedState
const showStripeProjectBanner =
selectedProject?.integration_source === 'stripe_projects' &&
!isProjectIntegrationBannerDismissed
useEffect(() => {
if (!selectedProject?.ref) return
const isProjectHomepage = router.pathname === '/project/[ref]'
if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
addBanner({
id: BANNER_ID.FREE_MICRO_UPGRADE,
isDismissed: false,
content: <BannerFreeMicroUpgrade />,
priority: 2,
})
} else {
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
}
}, [
router.pathname,
selectedProject?.ref,
showUpgradeBanner,
isFreeMicroUpgradeBannerDismissed,
addBanner,
dismissBanner,
])
useEffect(() => {
if (!isExplorerEnabled || !isLocalStorageReady || isExplorerBannerDismissed) return
addBanner({
id: 'explorer-banner',
priority: 2,
isDismissed: false,
content: <BannerExplorer />,
})
}, [addBanner, isExplorerEnabled, isExplorerBannerDismissed, isLocalStorageReady])
const mobileSheetContentRef = useRef(mobileSheetContent)
useLayoutEffect(() => {
mobileSheetContentRef.current = mobileSheetContent
}, [mobileSheetContent])
useLayoutEffect(() => {
const menu = (
<MobileMenuContent
currentProductMenu={productMenu ?? null}
currentProductMenuHeader={productMenuHeader}
currentProduct={product}
currentSectionKey={currentSectionKey}
onCloseSheet={() => setMobileSheetContent(null)}
/>
)
const unregister = registerOpenMenu(() => setMobileSheetContent(menu))
// Keep resource navigation in an already open sheet in sync with the layout.
const currentContent = mobileSheetContentRef.current
if (isValidElement(currentContent) && currentContent.type === MobileMenuContent) {
setMobileSheetContent(menu)
}
return unregister
}, [
registerOpenMenu,
productMenu,
productMenuHeader,
product,
currentSectionKey,
setMobileSheetContent,
])
useLayoutEffect(() => {
mainScrollContainer?.scrollTo({ top: 0, left: 0 })
}, [pathname, mainScrollContainer])
return (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content="Supabase Studio" />
</Head>
<div className="flex flex-row h-full w-full">
<ResizablePanelGroup orientation="horizontal">
{productMenu && sideBarIsOpen && (
<ResizablePanel
panelRef={panelRef}
minSize={256}
maxSize={resizableSidebar ? 512 : 256}
defaultSize={256}
id="panel-left"
disabled={!resizableSidebar}
>
<AnimatePresence initial={false}>
<motion.div
initial={{ width: 0, opacity: 0, height: '100%' }}
animate={{ width: 'auto', opacity: 1, height: '100%' }}
exit={{ width: 0, opacity: 0, height: '100%' }}
className="h-full"
transition={{ duration: 0.12 }}
>
<MenuBarWrapper
isLoading={isLoading}
isBlocking={isBlocking}
productMenu={productMenu}
>
<ProductMenuBar
title={product}
titleBadge={productMenuBadge}
header={productMenuHeader}
className={productMenuClassName}
>
{productMenu}
</ProductMenuBar>
</MenuBarWrapper>
</motion.div>
</AnimatePresence>
</ResizablePanel>
)}
{productMenu && sideBarIsOpen && (
<ResizableHandle
withHandle
disabled={resizableSidebar ? false : true}
className="hidden md:flex"
/>
)}
<ResizablePanel
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
id="panel-project-content"
>
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={combinedRef}
>
{showStripeProjectBanner && (
<Alert
variant="default"
className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
>
<PartnerIcon
organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
showTooltip={false}
size="medium"
/>
<div className="flex-1">
<AlertTitle>This project is connected to Stripe</AlertTitle>
<AlertDescription>
Changes made here may affect your connected Stripe project.
</AlertDescription>
</div>
<ButtonTooltip
variant="text"
icon={<XIcon size={14} />}
className="h-7 w-7 p-0"
onClick={() => setIsProjectIntegrationBannerDismissed(true)}
aria-label="Dismiss project integration banner"
tooltip={{ content: { text: 'Dismiss' } }}
/>
</Alert>
)}
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<CreateBranchModal />
<ProjectAPIDocs />
</>
)
}
)
ProjectLayout.displayName = 'ProjectLayout'
export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
interface MenuBarWrapperProps {
isLoading: boolean
isBlocking?: boolean
productMenu?: ReactNode
children: ReactNode
}
const MenuBarWrapper = ({
isLoading,
isBlocking = true,
productMenu,
children,
}: MenuBarWrapperProps) => {
const router = useRouter()
const { data: selectedProject } = useSelectedProjectQuery()
const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
if (!isBlocking) {
return children
}
const showMenuBar =
!requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
return !isLoading && productMenu && showMenuBar ? children : null
}
interface ContentWrapperProps {
children: ReactNode
}
/**
* Check project.status to show building state or error state
*
* [Joshen] As of 210422: Current testing connection by pinging postgres
* Ideally we'd have a more specific monitoring of the project such as during restarts
* But that will come later: https://supabase.slack.com/archives/C01D6TWFFFW/p1650427619665549
*
* Just note that this logic does not differentiate between a "restarting" state and
* a "something is wrong and can't connect to project" state.
*
* [TODO] Next iteration should scrape long polling and just listen to the project's status
*/
const ContentWrapper = ({ children }: ContentWrapperProps) => {
const router = useRouter()
const { ref } = useParams()
const state = useDatabaseSelectorStateSnapshot()
const { data: selectedProject } = useSelectedProjectQuery()
const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
const isHomePage = router.pathname === '/project/[ref]'
const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
const isProjectBuilding =
selectedProject?.status === PROJECT_STATUS.COMING_UP ||
selectedProject?.status === PROJECT_STATUS.UNKNOWN
const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
const ignoreUnhealthyState =
isHomePage ||
router.pathname.includes('/project/[ref]/settings') ||
router.pathname.includes('/project/[ref]/logs')
const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
// Don't show building state on the home page — it handles building state inline
const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
useEffect(() => {
if (shouldRedirectToHomeForBuilding && ref) {
router.replace(`/project/${ref}`)
}
}, [shouldRedirectToHomeForBuilding, ref, router])
useEffect(() => {
if (ref) state.setSelectedDatabaseId(ref)
}, [ref])
if (isRestarting && !isBackupsPage) {
return <RestartingState />
}
if (isResizing && !isBackupsPage) {
return <ResizingState />
}
if (isProjectUpgrading && !isBackupsPage) {
return <UpgradingState />
}
if (isProjectPausing) {
return <PausingState project={selectedProject} />
}
if (isProjectPauseFailed) {
return <PauseFailedState />
}
if (isProjectUnhealthy && !ignoreUnhealthyState) {
return <UnhealthyState />
}
if (requiresPostgrestConnection && isProjectOffline) {
return <ConnectingState project={selectedProject} />
}
if (requiresDbConnection && isProjectRestoring) {
return <RestoringState />
}
if (requiresDbConnection && isProjectRestoreFailed) {
return <RestoreFailedState />
}
if (shouldRedirectToHomeForBuilding) {
return <LogoLoader />
}
if (shouldShowBuildingState) {
return <BuildingState />
}
return <Fragment key={selectedProject?.ref}>{children}</Fragment>
}