mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 12:51:26 +08:00
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:
parent
4149fca3a7
commit
37766d5685
10
packages/shared/data/cache/cacheSchemas.ts
vendored
10
packages/shared/data/cache/cacheSchemas.ts
vendored
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
44
src/renderer/src/components/layout/TabRouter.tsx
Normal file
44
src/renderer/src/components/layout/TabRouter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user