feat: implement tab management with independent MemoryRouter instances

- Introduced a new TabRouter component to manage routing for each tab independently, enhancing the tab management system.
- Updated the AppShell component to utilize the new TabRouter, allowing for true KeepAlive behavior with isolated history.
- Refactored the Sidebar component for improved navigation and tab creation, now supporting internal app routes.
- Enhanced the useTabs hook to ensure at least one default tab exists, improving user experience on initial load.
- Updated cacheSchemas to reflect changes in tab types and metadata structure.

These changes significantly improve the architecture and functionality of the tab system, providing a more robust user experience.
This commit is contained in:
MyPrototypeWhat 2025-12-05 16:21:37 +08:00
parent 4149fca3a7
commit 37766d5685
4 changed files with 148 additions and 141 deletions

View File

@ -94,8 +94,11 @@ export const DefaultUseSharedCache: UseSharedCacheSchema = {
/**
* Tab type for browser-like tabs
*
* - 'route': Internal app routes rendered via MemoryRouter
* - 'webview': External web content rendered via Electron webview
*/
export type TabType = 'webview' | 'url' | 'browser'
export type TabType = 'route' | 'webview'
export interface Tab {
id: string
@ -103,8 +106,9 @@ export interface Tab {
url: string
title: string
icon?: string
isKeepAlive?: boolean
metadata?: Record<string, any>
metadata?: Record<string, unknown>
// TODO: LRU 优化字段,后续添加
// lastAccessTime?: number
}
export interface TabsState {

View File

@ -1,185 +1,130 @@
// TODO demo component
import { cn } from '@cherrystudio/ui'
import { Link, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
import { cn, Tabs, TabsList, TabsTrigger } from '@cherrystudio/ui'
import { X } from 'lucide-react'
import { useEffect } from 'react'
import { Activity } from 'react'
import { v4 as uuid } from 'uuid'
import { useTabs } from '../../hooks/useTabs'
import { TabRouter } from './TabRouter'
// Mock Sidebar component (Replace with actual one later)
const Sidebar = ({ onNavigate }: { onNavigate: (id: string) => void }) => {
// Helper to render a Sidebar Link that acts as a Tab Switcher
const SidebarItem = ({ to, title, id }: { to: string; title: string; id: string }) => (
<Link
to={to}
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-accent data-[status=active]:bg-primary/20 data-[status=active]:font-bold"
activeProps={{
'data-status': 'active'
}}
onClick={(e) => {
// Intercept the router navigation!
// We want to switch tabs, not just navigate within the current tab.
e.preventDefault()
onNavigate(id)
}}>
{title.slice(0, 1).toUpperCase() + title.slice(1, 3)}
</Link>
)
// Mock Sidebar component (TODO: Replace with actual Sidebar)
const Sidebar = ({ onNavigate }: { onNavigate: (path: string, title: string) => void }) => {
const menuItems = [
{ path: '/', title: 'Home', icon: 'H' },
{ path: '/settings', title: 'Settings', icon: 'S' }
]
return (
<aside className="flex h-full w-16 flex-col items-center gap-4 border-r bg-muted/10 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/20 font-bold text-xs">Logo</div>
<SidebarItem to="/" title="Home" id="home" />
<SidebarItem to="/settings" title="Settings" id="settings" />
{menuItems.map((item) => (
<button
key={item.path}
type="button"
onClick={() => onNavigate(item.path, item.title)}
className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-accent">
{item.icon}
</button>
))}
<div className="flex-1" />
<button type="button" className="flex h-10 w-10 items-center justify-center rounded-md hover:bg-accent">
User
U
</button>
</aside>
)
}
// Mock MinApp component (Replace with actual implementation)
const MinApp = ({ url }: { url: string }) => (
<div className="flex h-full w-full flex-col items-center justify-center bg-background">
<div className="mb-2 font-bold text-lg">Webview App</div>
<code className="rounded bg-muted p-2">{url}</code>
</div>
// Mock Webview component (TODO: Replace with actual MinApp/Webview)
const WebviewContainer = ({ url, isActive }: { url: string; isActive: boolean }) => (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<div className="flex h-full w-full flex-col items-center justify-center bg-background">
<div className="mb-2 font-bold text-lg">Webview App</div>
<code className="rounded bg-muted p-2">{url}</code>
</div>
</Activity>
)
export const AppShell = () => {
const { tabs, activeTabId, setActiveTab, closeTab, addTab, updateTab } = useTabs()
const navigate = useNavigate()
const location = useLocation()
// 1. Sync Route -> Tab (Handle internal navigation & deep links)
useEffect(() => {
const currentPath = location.pathname
const activeTab = tabs.find((t) => t.id === activeTabId)
if (activeTab?.type === 'url' && activeTab.url !== currentPath) {
const existingTab = tabs.find((t) => t.type === 'url' && t.url === currentPath && t.id !== activeTabId)
if (existingTab) {
setActiveTab(existingTab.id)
} else {
// Sync URL changes back to DB
updateTab(activeTabId, { url: currentPath })
}
}
}, [location.pathname, tabs, activeTabId, setActiveTab, updateTab])
// 2. Sync Tab -> Route (Handle tab switching)
useEffect(() => {
const activeTab = tabs.find((t) => t.id === activeTabId)
if (!activeTab) return
if (activeTab.type === 'url') {
if (location.pathname !== activeTab.url) {
navigate({ to: activeTab.url })
}
}
}, [activeTabId, tabs, navigate, location.pathname])
const handleSidebarClick = (menuId: string) => {
let targetUrl = ''
let targetTitle = ''
switch (menuId) {
case 'home':
targetUrl = '/'
targetTitle = 'Home'
break
case 'settings':
targetUrl = '/settings'
targetTitle = 'Settings'
break
default:
return
}
const existingTab = tabs.find((t) => t.type === 'url' && t.url === targetUrl)
// Sidebar navigation: find existing tab or create new one
const handleSidebarNavigate = (path: string, title: string) => {
const existingTab = tabs.find((t) => t.type === 'route' && t.url === path)
if (existingTab) {
setActiveTab(existingTab.id)
} else {
addTab({
id: `${menuId}-${Date.now()}`,
type: 'url',
url: targetUrl,
title: targetTitle
id: uuid(),
type: 'route',
url: path,
title
})
}
}
const activeTab = tabs.find((t) => t.id === activeTabId)
const isWebviewActive = activeTab?.type === 'webview'
// Sync internal navigation back to tab state
const handleUrlChange = (tabId: string, url: string) => {
updateTab(tabId, { url })
}
return (
<div className="flex h-screen w-screen flex-row overflow-hidden bg-background text-foreground">
{/* Zone 1: Sidebar */}
<Sidebar onNavigate={handleSidebarClick} />
<Sidebar onNavigate={handleSidebarNavigate} />
<div className="flex h-full min-w-0 flex-1 flex-col">
{/* Zone 2: Tab Bar */}
<header className="flex h-10 w-full items-center border-b bg-muted/5">
<div className="hide-scrollbar flex-1 overflow-x-auto">
<div className="flex h-full w-full items-center justify-start">
<Tabs value={activeTabId} onValueChange={setActiveTab} variant="line" className="w-full">
<header className="flex h-10 w-full items-center border-b bg-muted/5">
<TabsList className="hide-scrollbar h-full flex-1 justify-start gap-0 overflow-x-auto">
{tabs.map((tab) => (
<Link
<TabsTrigger
key={tab.id}
to={tab.url}
onClick={() => setActiveTab(tab.id)}
value={tab.id}
className={cn(
'relative flex h-full min-w-[120px] max-w-[200px] items-center justify-between gap-2 border-border/40 border-r px-3 py-2 text-sm transition-colors hover:bg-muted/50',
tab.id === activeTabId ? 'bg-background shadow-sm' : 'bg-transparent opacity-70 hover:opacity-100'
'group relative flex h-full min-w-[120px] max-w-[200px] items-center justify-between gap-2 rounded-none border-r px-3 text-sm',
tab.id === activeTabId ? 'bg-background' : 'bg-transparent'
)}>
<span className="truncate text-xs">{tab.title}</span>
<div
role="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
closeTab(tab.id)
}}
className="ml-1 cursor-pointer rounded-sm p-0.5 opacity-50 hover:bg-muted-foreground/20 hover:opacity-100">
<X className="size-3" />
</div>
</Link>
{tabs.length > 1 && (
<div
role="button"
onClick={(e) => {
e.stopPropagation()
closeTab(tab.id)
}}
className="ml-1 cursor-pointer rounded-sm p-0.5 opacity-0 hover:bg-muted-foreground/20 hover:opacity-100 group-hover:opacity-50">
<X className="size-3" />
</div>
)}
</TabsTrigger>
))}
</div>
</div>
</header>
</TabsList>
</header>
</Tabs>
{/* Zone 3: Content Area (Simplified Hybrid Architecture) */}
{/* Zone 3: Content Area - Multi MemoryRouter Architecture */}
<main className="relative flex-1 overflow-hidden bg-background">
{/* Layer A: Standard Router Outlet */}
{/* Always rendered, but hidden if a webview is active. This keeps the Router alive. */}
<div
style={{
display: isWebviewActive ? 'none' : 'block',
height: '100%',
width: '100%'
}}>
<Outlet />
</div>
{/* Layer B: Webview Apps (Overlay) */}
{tabs.map((tab) => {
if (tab.type !== 'webview') return null
return (
<div
{/* Route Tabs: Each has independent MemoryRouter */}
{tabs
.filter((t) => t.type === 'route')
.map((tab) => (
<TabRouter
key={tab.id}
style={{
display: tab.id === activeTabId ? 'block' : 'none',
height: '100%',
width: '100%'
}}>
<MinApp url={tab.url} />
</div>
)
})}
tab={tab}
isActive={tab.id === activeTabId}
onUrlChange={(url) => handleUrlChange(tab.id, url)}
/>
))}
{/* Webview Tabs */}
{tabs
.filter((t) => t.type === 'webview')
.map((tab) => (
<WebviewContainer key={tab.id} url={tab.url} isActive={tab.id === activeTabId} />
))}
</main>
</div>
</div>

View File

@ -0,0 +1,44 @@
import type { Tab } from '@shared/data/cache/cacheSchemas'
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'
import { Activity } from 'react'
import { useEffect, useMemo } from 'react'
import { routeTree } from '../../routeTree.gen'
interface TabRouterProps {
tab: Tab
isActive: boolean
onUrlChange: (url: string) => void
}
/**
* TabRouter - Independent MemoryRouter for each Tab
*
* Each tab maintains its own router instance with isolated history,
* enabling true KeepAlive behavior via React 19's Activity component.
*/
export const TabRouter = ({ tab, isActive, onUrlChange }: TabRouterProps) => {
// Create independent router instance per tab (only once)
const router = useMemo(() => {
const history = createMemoryHistory({ initialEntries: [tab.url] })
return createRouter({ routeTree, history })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab.id])
// Sync internal navigation back to tab state
useEffect(() => {
return router.subscribe('onResolved', ({ toLocation }) => {
if (toLocation.pathname !== tab.url) {
onUrlChange(toLocation.pathname)
}
})
}, [router, tab.url, onUrlChange])
return (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<div className="h-full w-full">
<RouterProvider router={router} />
</div>
</Activity>
)
}

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { usePersistCache } from '../data/hooks/useCache'
@ -6,11 +6,25 @@ import { usePersistCache } from '../data/hooks/useCache'
export type { Tab, TabsState, TabType } from '@shared/data/cache/cacheSchemas'
import type { Tab } from '@shared/data/cache/cacheSchemas'
const DEFAULT_TAB: Tab = {
id: 'home',
type: 'route',
url: '/',
title: 'Home'
}
export function useTabs() {
const [tabsState, setTabsState] = usePersistCache('ui.tab.state')
const tabs = useMemo(() => tabsState.tabs, [tabsState.tabs])
const activeTabId = tabsState.activeTabId
// Ensure at least one default tab exists
useEffect(() => {
if (tabsState.tabs.length === 0) {
setTabsState({ tabs: [DEFAULT_TAB], activeTabId: DEFAULT_TAB.id })
}
}, [tabsState.tabs.length, setTabsState])
const tabs = useMemo(() => (tabsState.tabs.length > 0 ? tabsState.tabs : [DEFAULT_TAB]), [tabsState.tabs])
const activeTabId = tabsState.activeTabId || DEFAULT_TAB.id
const addTab = useCallback(
(tab: Tab) => {