refactor: finalize Multi MemoryRouter architecture and add developer docs

- Fix TanStack Router path config using resolve() for absolute paths
- Add openTab API to useTabs hook for Tab-level navigation
- Remove AppRouter.tsx (merged into AppShell)
- Add developer documentation (README.md, README.zh-CN.md)
- Add settings route placeholder
- Update App.tsx with TODO comments for migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MyPrototypeWhat 2025-12-05 17:53:21 +08:00
parent 37766d5685
commit 255be7d4d1
10 changed files with 510 additions and 31 deletions

View File

@ -84,8 +84,8 @@ export default defineConfig({
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routesDirectory: './src/renderer/src/routes',
generatedRouteTree: './src/renderer/src/routeTree.gen.ts'
routesDirectory: resolve('src/renderer/src/routes'),
generatedRouteTree: resolve('src/renderer/src/routeTree.gen.ts')
}),
(async () => (await import('@tailwindcss/vite')).default())(),
react({

View File

@ -7,6 +7,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
// TODO: 新路由系统入口,迁移完成后启用
// import { AppShell } from './components/layout/AppShell'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
@ -42,6 +44,7 @@ function App(): React.ReactElement {
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
{/* TODO: 迁移完成后切换到 <AppShell /> */}
<Router />
</TopViewContainer>
</PersistGate>

View File

@ -1,17 +0,0 @@
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
// Create a new router instance
const router = createRouter({ routeTree })
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
export const AppRouter = () => {
return <RouterProvider router={router} />
}

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react'
import { usePersistCache } from '../data/hooks/useCache'
import { uuid } from '../utils'
// Re-export types from shared schema
export type { Tab, TabsState, TabType } from '@shared/data/cache/cacheSchemas'
import type { Tab } from '@shared/data/cache/cacheSchemas'
import type { Tab, TabType } from '@shared/data/cache/cacheSchemas'
const DEFAULT_TAB: Tab = {
id: 'home',
@ -13,6 +14,20 @@ const DEFAULT_TAB: Tab = {
title: 'Home'
}
/**
* Options for opening a tab
*/
export interface OpenTabOptions {
/** Force open a new tab even if one with the same URL exists */
forceNew?: boolean
/** Tab title (defaults to URL path) */
title?: string
/** Tab type (defaults to 'route') */
type?: TabType
/** Custom tab ID (auto-generated if not provided) */
id?: string
}
export function useTabs() {
const [tabsState, setTabsState] = usePersistCache('ui.tab.state')
@ -80,14 +95,72 @@ export function useTabs() {
[tabs, tabsState, setTabsState]
)
/**
* Open a Tab - reuses existing tab or creates new one
*
* @example
* // Basic usage - reuses existing tab if URL matches
* openTab('/settings')
*
* @example
* // With custom title
* openTab('/chat/123', { title: 'Chat with Alice' })
*
* @example
* // Force open new tab (e.g., Cmd+Click)
* openTab('/settings', { forceNew: true })
*
* @example
* // Open webview tab
* openTab('https://example.com', { type: 'webview', title: 'Example' })
*/
const openTab = useCallback(
(url: string, options: OpenTabOptions = {}) => {
const { forceNew = false, title, type = 'route', id } = options
// Try to find existing tab with same URL (unless forceNew)
if (!forceNew) {
const existingTab = tabs.find((t) => t.type === type && t.url === url)
if (existingTab) {
setActiveTab(existingTab.id)
return existingTab.id
}
}
// Create new tab
const newTab: Tab = {
id: id || uuid(),
type,
url,
title: title || url.split('/').pop() || url
}
addTab(newTab)
return newTab.id
},
[tabs, setActiveTab, addTab]
)
/**
* Get the currently active tab
*/
const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId])
return {
// State
tabs,
activeTabId,
activeTab,
isLoading: false,
// Basic operations
addTab,
closeTab,
setActiveTab,
updateTab,
setTabs
setTabs,
// High-level Tab operations
openTab
}
}

View File

@ -9,8 +9,14 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as IndexRouteImport } from './routes/index'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/settings'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/settings'
id: '__root__' | '/' | '/settings'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SettingsRoute: typeof SettingsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SettingsRoute: SettingsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -0,0 +1,194 @@
# Routing System Developer Guide
This project uses **TanStack Router + Multi MemoryRouter** architecture, where each Tab has its own independent router instance, enabling native KeepAlive behavior.
## Quick Start
### 1. Adding a New Page
Create a file in the `src/renderer/src/routes/` directory:
```typescript
// routes/knowledge.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/knowledge')({
component: KnowledgePage
})
function KnowledgePage() {
return <div>Knowledge Page</div>
}
```
After running `yarn dev`, TanStack Router will automatically update `routeTree.gen.ts`.
### 2. Routes with Parameters
```typescript
// routes/chat/$topicId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/chat/$topicId')({
component: ChatPage
})
function ChatPage() {
const { topicId } = Route.useParams()
return <div>Chat: {topicId}</div>
}
```
### 3. Nested Routes
```text
routes/
├── settings.tsx # /settings (layout)
├── settings/
│ ├── general.tsx # /settings/general
│ └── provider.tsx # /settings/provider
```
```typescript
// routes/settings.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/settings')({
component: SettingsLayout
})
function SettingsLayout() {
return (
<div className="flex">
<aside>Settings Menu</aside>
<main><Outlet /></main>
</div>
)
}
```
## Navigation API
This project provides two navigation methods:
### 1. Tab-Level Navigation - `openTab`
Open a new Tab or switch to an existing Tab using the `useTabs` hook:
```typescript
import { useTabs } from '@renderer/hooks/useTabs'
function MyComponent() {
const { openTab, closeTab } = useTabs()
// Basic usage - reuse existing Tab or create new one
openTab('/settings')
// With title
openTab('/chat/123', { title: 'Chat with Alice' })
// Force new Tab (even if same URL exists)
openTab('/settings', { forceNew: true })
// Open Webview Tab
openTab('https://example.com', {
type: 'webview',
title: 'Example Site'
})
// Close Tab
closeTab(tabId)
}
```
### 2. In-Tab Navigation - `useNavigate`
Navigate within the same Tab (won't create a new Tab) using TanStack Router's `useNavigate`:
```typescript
import { useNavigate } from '@tanstack/react-router'
function SettingsPage() {
const navigate = useNavigate()
// Navigate to sub-page within current Tab
navigate({ to: '/settings/provider' })
// Navigate with parameters
navigate({ to: '/chat/$topicId', params: { topicId: '123' } })
}
```
### Comparison
| Scenario | Method | Result |
|----------|--------|--------|
| Open new feature module | `openTab('/knowledge')` | Creates new Tab |
| Switch sub-page in settings | `navigate({ to: '/settings/provider' })` | Navigates within current Tab |
| Open detail from list | `openTab('/chat/123', { title: '...' })` | Creates new Tab |
| Go back to previous page | `navigate({ to: '..' })` | Goes back within current Tab |
### API Reference
#### `useTabs()` Return Value
| Property/Method | Type | Description |
|-----------------|------|-------------|
| `tabs` | `Tab[]` | List of all Tabs |
| `activeTabId` | `string` | Currently active Tab ID |
| `activeTab` | `Tab \| undefined` | Currently active Tab object |
| `openTab(url, options?)` | `(url: string, options?: OpenTabOptions) => string` | Open Tab, returns Tab ID |
| `closeTab(id)` | `(id: string) => void` | Close specified Tab |
| `setActiveTab(id)` | `(id: string) => void` | Switch to specified Tab |
| `updateTab(id, updates)` | `(id: string, updates: Partial<Tab>) => void` | Update Tab properties |
#### `OpenTabOptions`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `forceNew` | `boolean` | `false` | Force create new Tab |
| `title` | `string` | URL path | Tab title |
| `type` | `'route' \| 'webview'` | `'route'` | Tab type |
| `id` | `string` | Auto-generated | Custom Tab ID |
## Architecture Overview
```text
AppShell
├── Sidebar
├── TabBar
└── Content Area
├── TabRouter #1 (Home)
│ └── Activity(visible) → MemoryRouter → RouterProvider
├── TabRouter #2 (Settings)
│ └── Activity(hidden) → MemoryRouter → RouterProvider
└── WebviewContainer (for webview tabs)
```
- Each Tab has its own independent `MemoryRouter` instance
- Uses React 19 `<Activity>` component to control visibility
- Components are not unmounted on Tab switch, state is fully preserved (KeepAlive)
## File Structure
```text
src/renderer/src/
├── routes/ # Route pages (TanStack Router file-based routing)
│ ├── __root.tsx # Root route (renders Outlet)
│ ├── index.tsx # / Home page
│ ├── settings.tsx # /settings
│ └── README.md # This document
├── components/layout/
│ ├── AppShell.tsx # Main layout (Sidebar + TabBar + Content)
│ └── TabRouter.tsx # Tab router container (MemoryRouter + Activity)
├── hooks/
│ └── useTabs.ts # Tab state management hook
└── routeTree.gen.ts # Auto-generated route tree (do not edit manually)
```
## Important Notes
1. **Do not manually edit `routeTree.gen.ts`** - It is automatically generated by TanStack Router
2. **File name determines route path** - `routes/settings.tsx``/settings`
3. **Dynamic parameters use `$`** - `routes/chat/$topicId.tsx``/chat/:topicId`
4. **Page state is automatically preserved** - Tab switching won't lose `useState`, scroll position, etc.

View File

@ -0,0 +1,194 @@
# 路由系统开发指南
本项目使用 **TanStack Router + Multi MemoryRouter** 架构,每个 Tab 拥有独立的路由实例,实现原生 KeepAlive。
## 快速开始
### 1. 添加新页面
`src/renderer/src/routes/` 目录下创建文件:
```typescript
// routes/knowledge.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/knowledge')({
component: KnowledgePage
})
function KnowledgePage() {
return <div>Knowledge Page</div>
}
```
运行 `yarn dev`TanStack Router 会自动更新 `routeTree.gen.ts`
### 2. 带参数的路由
```typescript
// routes/chat/$topicId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/chat/$topicId')({
component: ChatPage
})
function ChatPage() {
const { topicId } = Route.useParams()
return <div>Chat: {topicId}</div>
}
```
### 3. 嵌套路由
```text
routes/
├── settings.tsx # /settings (布局)
├── settings/
│ ├── general.tsx # /settings/general
│ └── provider.tsx # /settings/provider
```
```typescript
// routes/settings.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/settings')({
component: SettingsLayout
})
function SettingsLayout() {
return (
<div className="flex">
<aside>Settings Menu</aside>
<main><Outlet /></main>
</div>
)
}
```
## 导航 API
本项目有两种导航方式:
### 1. Tab 级别导航 - `openTab`
打开新 Tab 或切换到已有 Tab使用 `useTabs` hook
```typescript
import { useTabs } from '@renderer/hooks/useTabs'
function MyComponent() {
const { openTab, closeTab } = useTabs()
// 基础用法 - 复用已有 Tab 或新建
openTab('/settings')
// 带标题
openTab('/chat/123', { title: 'Chat with Alice' })
// 强制新开 Tab即使已有相同 URL
openTab('/settings', { forceNew: true })
// 打开 Webview Tab
openTab('https://example.com', {
type: 'webview',
title: 'Example Site'
})
// 关闭 Tab
closeTab(tabId)
}
```
### 2. Tab 内部导航 - `useNavigate`
在同一个 Tab 内跳转路由(不会新开 Tab使用 TanStack Router 的 `useNavigate`
```typescript
import { useNavigate } from '@tanstack/react-router'
function SettingsPage() {
const navigate = useNavigate()
// 在当前 Tab 内跳转到子页面
navigate({ to: '/settings/provider' })
// 带参数跳转
navigate({ to: '/chat/$topicId', params: { topicId: '123' } })
}
```
### 两者区别
| 场景 | 使用 | 效果 |
|-----|------|------|
| 打开新功能模块 | `openTab('/knowledge')` | 新建 Tab |
| 设置页内切换子页 | `navigate({ to: '/settings/provider' })` | 当前 Tab 内跳转 |
| 从列表打开详情 | `openTab('/chat/123', { title: '...' })` | 新建 Tab |
| 返回上一页 | `navigate({ to: '..' })` | 当前 Tab 内返回 |
### API 参考
#### `useTabs()` 返回值
| 属性/方法 | 类型 | 说明 |
|----------|------|------|
| `tabs` | `Tab[]` | 所有 Tab 列表 |
| `activeTabId` | `string` | 当前激活的 Tab ID |
| `activeTab` | `Tab \| undefined` | 当前激活的 Tab 对象 |
| `openTab(url, options?)` | `(url: string, options?: OpenTabOptions) => string` | 打开 Tab返回 Tab ID |
| `closeTab(id)` | `(id: string) => void` | 关闭指定 Tab |
| `setActiveTab(id)` | `(id: string) => void` | 切换到指定 Tab |
| `updateTab(id, updates)` | `(id: string, updates: Partial<Tab>) => void` | 更新 Tab 属性 |
#### `OpenTabOptions`
| 选项 | 类型 | 默认值 | 说明 |
|-----|------|-------|------|
| `forceNew` | `boolean` | `false` | 强制新开 Tab |
| `title` | `string` | URL 路径 | Tab 标题 |
| `type` | `'route' \| 'webview'` | `'route'` | Tab 类型 |
| `id` | `string` | 自动生成 | 自定义 Tab ID |
## 架构说明
```text
AppShell
├── Sidebar
├── TabBar
└── Content Area
├── TabRouter #1 (Home)
│ └── Activity(visible) → MemoryRouter → RouterProvider
├── TabRouter #2 (Settings)
│ └── Activity(hidden) → MemoryRouter → RouterProvider
└── WebviewContainer (for webview tabs)
```
- 每个 Tab 拥有独立的 `MemoryRouter` 实例
- 使用 React 19 `<Activity>` 组件控制可见性
- Tab 切换时组件不卸载状态完全保持KeepAlive
## 文件结构
```text
src/renderer/src/
├── routes/ # 路由页面TanStack Router 文件路由)
│ ├── __root.tsx # 根路由(渲染 Outlet
│ ├── index.tsx # / 首页
│ ├── settings.tsx # /settings
│ └── README.md # 本文档
├── components/layout/
│ ├── AppShell.tsx # 主布局Sidebar + TabBar + Content
│ └── TabRouter.tsx # Tab 路由容器MemoryRouter + Activity
├── hooks/
│ └── useTabs.ts # Tab 状态管理 Hook
└── routeTree.gen.ts # 自动生成的路由树(勿手动编辑)
```
## 注意事项
1. **不要手动编辑 `routeTree.gen.ts`** - 它由 TanStack Router 自动生成
2. **路由文件命名即路径** - `routes/settings.tsx``/settings`
3. **动态参数使用 `$`** - `routes/chat/$topicId.tsx``/chat/:topicId`
4. **页面状态自动保持** - Tab 切换不会丢失 `useState`、滚动位置等

View File

@ -1,7 +1,5 @@
import { createRootRoute } from '@tanstack/react-router'
import { AppShell } from '../components/layout/AppShell'
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <AppShell />
component: () => <Outlet />
})

View File

@ -6,9 +6,9 @@ export const Route = createFileRoute('/')({
function Index() {
return (
<div style={{ padding: 20 }}>
<h3>Welcome to Cherry Studio!</h3>
<p>Select a tab to start.</p>
<div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<h3 className="font-semibold text-xl">Home</h3>
<p className="text-muted-foreground text-sm">TODO: Migrate HomePage</p>
</div>
)
}

View File

@ -0,0 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import { Settings } from 'lucide-react'
export const Route = createFileRoute('/settings')({
component: SettingsPage
})
function SettingsPage() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-background p-8">
<Settings className="size-12 text-muted-foreground" />
<h2 className="font-semibold text-xl">Settings</h2>
<p className="text-muted-foreground text-sm">TODO: Migrate SettingsPage</p>
</div>
)
}