From 37766d568539f6ae14f7cba70789ea89c92c1bbe Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Fri, 5 Dec 2025 16:21:37 +0800 Subject: [PATCH] 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. --- packages/shared/data/cache/cacheSchemas.ts | 10 +- .../src/components/layout/AppShell.tsx | 215 +++++++----------- .../src/components/layout/TabRouter.tsx | 44 ++++ src/renderer/src/hooks/useTabs.ts | 20 +- 4 files changed, 148 insertions(+), 141 deletions(-) create mode 100644 src/renderer/src/components/layout/TabRouter.tsx diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index fc75bcfcd4..6baa5940f2 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -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 + metadata?: Record + // TODO: LRU 优化字段,后续添加 + // lastAccessTime?: number } export interface TabsState { diff --git a/src/renderer/src/components/layout/AppShell.tsx b/src/renderer/src/components/layout/AppShell.tsx index c5a881ca76..a7cbcf2a9d 100644 --- a/src/renderer/src/components/layout/AppShell.tsx +++ b/src/renderer/src/components/layout/AppShell.tsx @@ -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 }) => ( - { - // 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)} - - ) +// 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 ( ) } -// Mock MinApp component (Replace with actual implementation) -const MinApp = ({ url }: { url: string }) => ( -
-
Webview App
- {url} -
+// Mock Webview component (TODO: Replace with actual MinApp/Webview) +const WebviewContainer = ({ url, isActive }: { url: string; isActive: boolean }) => ( + +
+
Webview App
+ {url} +
+
) 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 (
{/* Zone 1: Sidebar */} - +
{/* Zone 2: Tab Bar */} -
-
-
+ +
+ {tabs.map((tab) => ( - 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' )}> {tab.title} -
{ - 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"> - -
- + {tabs.length > 1 && ( +
{ + 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"> + +
+ )} + ))} -
-
-
+ + + - {/* Zone 3: Content Area (Simplified Hybrid Architecture) */} + {/* Zone 3: Content Area - Multi MemoryRouter Architecture */}
- {/* Layer A: Standard Router Outlet */} - {/* Always rendered, but hidden if a webview is active. This keeps the Router alive. */} -
- -
- - {/* Layer B: Webview Apps (Overlay) */} - {tabs.map((tab) => { - if (tab.type !== 'webview') return null - return ( -
t.type === 'route') + .map((tab) => ( + - -
- ) - })} + tab={tab} + isActive={tab.id === activeTabId} + onUrlChange={(url) => handleUrlChange(tab.id, url)} + /> + ))} + + {/* Webview Tabs */} + {tabs + .filter((t) => t.type === 'webview') + .map((tab) => ( + + ))}
diff --git a/src/renderer/src/components/layout/TabRouter.tsx b/src/renderer/src/components/layout/TabRouter.tsx new file mode 100644 index 0000000000..cebeeff605 --- /dev/null +++ b/src/renderer/src/components/layout/TabRouter.tsx @@ -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 ( + +
+ +
+
+ ) +} diff --git a/src/renderer/src/hooks/useTabs.ts b/src/renderer/src/hooks/useTabs.ts index e907c1349e..4a1e95f230 100644 --- a/src/renderer/src/hooks/useTabs.ts +++ b/src/renderer/src/hooks/useTabs.ts @@ -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) => {