From 255be7d4d11c68b2a6cd017e87b8eecb5cbe7c84 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Fri, 5 Dec 2025 17:53:21 +0800 Subject: [PATCH] refactor: finalize Multi MemoryRouter architecture and add developer docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- electron.vite.config.ts | 4 +- src/renderer/src/App.tsx | 3 + src/renderer/src/AppRouter.tsx | 17 --- src/renderer/src/hooks/useTabs.ts | 77 +++++++++- src/renderer/src/routeTree.gen.ts | 24 ++- src/renderer/src/routes/README.md | 194 ++++++++++++++++++++++++ src/renderer/src/routes/README.zh-CN.md | 194 ++++++++++++++++++++++++ src/renderer/src/routes/__root.tsx | 6 +- src/renderer/src/routes/index.tsx | 6 +- src/renderer/src/routes/settings.tsx | 16 ++ 10 files changed, 510 insertions(+), 31 deletions(-) delete mode 100644 src/renderer/src/AppRouter.tsx create mode 100644 src/renderer/src/routes/README.md create mode 100644 src/renderer/src/routes/README.zh-CN.md create mode 100644 src/renderer/src/routes/settings.tsx diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4fa3afccf1..444d3c533b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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({ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fb34a13a26..e138a812ab 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + {/* TODO: 迁移完成后切换到 */} diff --git a/src/renderer/src/AppRouter.tsx b/src/renderer/src/AppRouter.tsx deleted file mode 100644 index d3bdcf413c..0000000000 --- a/src/renderer/src/AppRouter.tsx +++ /dev/null @@ -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 -} diff --git a/src/renderer/src/hooks/useTabs.ts b/src/renderer/src/hooks/useTabs.ts index 4a1e95f230..a1ca00308c 100644 --- a/src/renderer/src/hooks/useTabs.ts +++ b/src/renderer/src/hooks/useTabs.ts @@ -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 } } diff --git a/src/renderer/src/routeTree.gen.ts b/src/renderer/src/routeTree.gen.ts index d204c269b3..f9781b3f31 100644 --- a/src/renderer/src/routeTree.gen.ts +++ b/src/renderer/src/routeTree.gen.ts @@ -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) diff --git a/src/renderer/src/routes/README.md b/src/renderer/src/routes/README.md new file mode 100644 index 0000000000..91b82381d0 --- /dev/null +++ b/src/renderer/src/routes/README.md @@ -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
Knowledge Page
+} +``` + +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
Chat: {topicId}
+} +``` + +### 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 ( +
+ +
+
+ ) +} +``` + +## 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) => 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 `` 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. diff --git a/src/renderer/src/routes/README.zh-CN.md b/src/renderer/src/routes/README.zh-CN.md new file mode 100644 index 0000000000..138e4e8a9e --- /dev/null +++ b/src/renderer/src/routes/README.zh-CN.md @@ -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
Knowledge Page
+} +``` + +运行 `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
Chat: {topicId}
+} +``` + +### 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 ( +
+ +
+
+ ) +} +``` + +## 导航 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) => 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 `` 组件控制可见性 +- 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`、滚动位置等 diff --git a/src/renderer/src/routes/__root.tsx b/src/renderer/src/routes/__root.tsx index 60ee43b324..d1a273acb7 100644 --- a/src/renderer/src/routes/__root.tsx +++ b/src/renderer/src/routes/__root.tsx @@ -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: () => + component: () => }) diff --git a/src/renderer/src/routes/index.tsx b/src/renderer/src/routes/index.tsx index 89bf801e57..4fc1c3e43b 100644 --- a/src/renderer/src/routes/index.tsx +++ b/src/renderer/src/routes/index.tsx @@ -6,9 +6,9 @@ export const Route = createFileRoute('/')({ function Index() { return ( -
-

Welcome to Cherry Studio!

-

Select a tab to start.

+
+

Home

+

TODO: Migrate HomePage

) } diff --git a/src/renderer/src/routes/settings.tsx b/src/renderer/src/routes/settings.tsx new file mode 100644 index 0000000000..d94bea303d --- /dev/null +++ b/src/renderer/src/routes/settings.tsx @@ -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 ( +
+ +

Settings

+

TODO: Migrate SettingsPage

+
+ ) +}