Files
supabase/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx
T
Saxon Fletcher 0043e6f53b feat(studio): add Explorer onboarding and startup preference (#50493)
<img width="1454" height="920" alt="image"
src="https://github.com/user-attachments/assets/a289b618-2bd2-4957-ac49-71d4e372d2cc"
/>


## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

Yes.

## What kind of change does this PR introduce?

Feature.

## What is the current behavior?

Explorer always opens on its start page, without onboarding or a startup
preference.

## What is the new behavior?

Adds one-time onboarding with wireframe option cards and a collapsed
Learn more section. Users can start on the Explorer start page or in a
new SQL query tab, and change that choice in Account preferences →
Dashboard. Preferences persist per account in the browser.

## Additional context

How to test:
1. With Explorer enabled and fresh browser storage, open Explorer and
select either startup option. Confirm Open Explorer follows the
selection and onboarding stays dismissed after reload.
2. Change Explorer startup in Account preferences → Dashboard, then
reopen Explorer. SQL query should create one normal query tab; Start
page should restore the pinned home tab.
3. Use the keyboard to select an option and toggle Learn more. Expand it
in a short viewport and check that the page scrolls normally.

Validation: 235 tests pass, including 20 new cases; Studio typecheck and
formatting pass.

The local production build was stopped during compilation and was not
verified locally.



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

- **New Features**
- Added an Explorer onboarding experience with startup-view selection,
guidance, and a Learn more section.
- Added Explorer settings to choose between the Start page and SQL query
views.
  - Explorer preferences now persist across sessions and accounts.
  - Explorer can open directly to a new SQL query when selected.
- The Explorer Home tab is shown based on the selected startup
preference.
- **Accessibility**
  - Reduced-motion settings now disable the Explorer loading animation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-17 15:53:18 +10:00

213 lines
7.3 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 { useExplorerPreferences } from '@/components/interfaces/Account/Preferences/useExplorerPreferences'
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 { home, hasCompletedOnboarding, isReady } = useExplorerPreferences()
const shouldShowHomeTab = isReady && (!hasCompletedOnboarding || home === 'home')
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={shouldShowHomeTab ? <HomeTabButton /> : undefined}
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>
)
}