Files
supabase/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx
T
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

210 lines
7.0 KiB
TypeScript

import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Home, MessageCirclePlus, NotebookText, Plus, SquareCode } from 'lucide-react'
import Link from 'next/link'
import { ComponentProps, ReactNode, useEffect, useEffectEvent, useState } from 'react'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
TabsTrigger,
} from 'ui'
import { EditorNavigationButton } from '../EditorNavigationButton'
import { ProjectLayoutWithAuth } from '../ProjectLayout'
import { EditorTabs } from '../Tabs/Tabs'
import { type ExplorerResourceType } from './ExplorerLayout.constants'
import { ExplorerNavChats } from './ExplorerNavChats'
import { ExplorerNavHeader } from './ExplorerNavHeader'
import { ExplorerNavHome } from './ExplorerNavHome'
import { ExplorerNavNotebooks } from './ExplorerNavNotebooks'
import { ExplorerNotebookTabCoordinator } from '@/components/interfaces/Explorer/ExplorerNotebookTabCoordinator'
import { ExplorerQueryTabCoordinator } from '@/components/interfaces/Explorer/ExplorerQueryTabCoordinator'
import {
useCreateChat,
useCreateNotebook,
useCreateQuery,
} from '@/components/interfaces/Explorer/hooks'
import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit'
import { useTrack } from '@/lib/telemetry/track'
import {
editorEntityTypes,
EXPLORER_HOME_TAB,
EXPLORER_HOME_TAB_ID,
useTabsStateSnapshot,
type Tab,
} from '@/state/tabs'
export interface ExplorerLayoutProps extends ComponentProps<typeof ProjectLayoutWithAuth> {
children: ReactNode
title?: string
}
export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayoutProps) => {
const { ref } = useParams()
const tabs = useTabsStateSnapshot()
const [section, setSection] = useState<ExplorerResourceType>()
const { setIsTemporary: setIsTemporarySqlEditorVisit } = useIsTemporarySqlEditorVisit(ref)
useEffect(() => {
if (ref) setIsTemporarySqlEditorVisit(false)
}, [ref, setIsTemporarySqlEditorVisit])
const activeTab = tabs.activeTab ? tabs.tabsMap[tabs.activeTab] : undefined
const isActiveExplorerTab =
activeTab !== undefined && editorEntityTypes.explorer.includes(activeTab.type)
const activeTabLabel = isActiveExplorerTab ? activeTab.label || 'Untitled' : 'Explorer'
const mergedBrowserTitle = {
...browserTitle,
section: title ?? browserTitle?.section,
entity: browserTitle?.entity ?? activeTabLabel,
}
return (
<ProjectLayoutWithAuth
product="Explorer"
browserTitle={mergedBrowserTitle}
productMenuHeader={
<ExplorerNavHeader
section={section}
onBack={() => setSection(undefined)}
rootAction={<BackToSqlEditorButton />}
/>
}
productMenu={
<div className="relative h-full overflow-hidden">
<AnimatePresence mode="wait">
{section === undefined && <ExplorerNavHome key="home" onSelectSection={setSection} />}
{section === 'notebook' && <ExplorerNavNotebooks key="notebooks" />}
{section === 'chat' && <ExplorerNavChats key="chats" />}
</AnimatePresence>
</div>
}
>
<ExplorerQueryTabCoordinator />
<ExplorerNotebookTabCoordinator />
<div className="flex flex-col h-full">
<div className={cn('h-10 md:min-h-(--header-height) flex items-center bg-surface-100')}>
<EditorTabs
isCollapseButtonHidden
customTabs={<HomeTabButton />}
newTabButton={<NewTabButton />}
/>
</div>
<div className="flex-grow min-h-0">{children}</div>
</div>
</ProjectLayoutWithAuth>
)
}
const BackToSqlEditorButton = () => {
const { ref } = useParams()
const track = useTrack()
const { setIsTemporary } = useIsTemporarySqlEditorVisit(ref)
if (!ref) return null
return (
<EditorNavigationButton
asChild
tooltip="Temporarily switch to SQL Editor to access your snippets"
>
<Link
href={`/project/${ref}/sql`}
aria-label="Switch to SQL Editor"
onClick={() => {
setIsTemporary(true)
track('explorer_temp_access_sql_editor_clicked')
}}
/>
</EditorNavigationButton>
)
}
const TabClassName =
'flex items-center justify-center min-w-(--header-height) min-h-(--header-height) hover:bg-surface-100 shrink-0'
const HomeTabButton = () => {
const tabs = useTabsStateSnapshot()
const openTabs = tabs.openTabs
.map((id) => tabs.tabsMap[id])
.filter((tab) => tab !== undefined) as Tab[]
const explorerTabs = openTabs.filter((tab) => editorEntityTypes['explorer']?.includes(tab.type))
const ensureHomeTab = useEffectEvent(() => {
tabs.ensurePinnedTab(EXPLORER_HOME_TAB)
})
useEffect(() => ensureHomeTab(), [])
return (
<TabsTrigger
value={EXPLORER_HOME_TAB_ID}
className={cn(
TabClassName,
'relative group border-b border-default shadow-none!',
explorerTabs.length === 0 && 'border-r border-r-default!',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'data-[state=active]:border-b-background-dash-sidebar dark:data-[state=active]:border-b-background-surface-100'
)}
>
<Home
size={14}
strokeWidth={1.5}
className="text-foreground-lighter transition-colors group-hover:text-foreground-light group-data-[state=active]:text-foreground"
/>
<span className="sr-only">Open Explorer home</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>
)
}
const NewTabButton = () => {
const { createNotebook } = useCreateNotebook()
const { createQuery } = useCreateQuery()
const { createChat } = useCreateChat()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<motion.button
className={cn(TabClassName, 'border-b')}
onClick={() => {}}
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>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuItem className="gap-x-2" onClick={() => createQuery()}>
<SquareCode size={14} />
<span>Run SQL</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => createNotebook()}>
<NotebookText size={14} />
<span>New notebook</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => createChat()}>
<MessageCirclePlus size={14} />
<span>New chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}