feat(tabs): implement LRU management and tab hibernation features

- Introduced `TabLRUManager` to manage tab hibernation based on usage patterns, enhancing memory efficiency.
- Added `isDormant` and `savedState` properties to the `Tab` interface for tracking tab states during hibernation.
- Updated `useTabs` hook to include methods for hibernating and waking tabs, along with pinning functionality to exempt tabs from LRU management.
- Enhanced `AppShell` to only render non-dormant tabs, improving user experience by focusing on active content.
- Implemented logging for tab state changes to facilitate debugging and monitoring of tab behavior.

These changes significantly improve the application's performance and user experience by optimizing tab management and memory usage.
This commit is contained in:
MyPrototypeWhat 2025-12-11 17:50:50 +08:00
parent ec3c9db9ff
commit 4f7a14b044
6 changed files with 402 additions and 47 deletions

View File

@ -26,6 +26,14 @@ export type CacheTopic = Topic
*/ */
export type TabType = 'route' | 'webview' export type TabType = 'route' | 'webview'
/**
* Tab saved state for hibernation recovery
*/
export interface TabSavedState {
scrollPosition?: number
// 其他必要草稿字段可在此扩展
}
export interface Tab { export interface Tab {
id: string id: string
type: TabType type: TabType
@ -33,8 +41,11 @@ export interface Tab {
title: string title: string
icon?: string icon?: string
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
// TODO: LRU 优化字段,后续添加 // LRU 字段
// lastAccessTime?: number lastAccessTime?: number // open/switch 时更新
isDormant?: boolean // 是否已休眠
isPinned?: boolean // 是否置顶(豁免 LRU
savedState?: TabSavedState // 休眠前保存的状态
} }
export interface TabsState { export interface TabsState {

View File

@ -43,20 +43,21 @@ export const AppShell = () => {
{/* Zone 1: Sidebar */} {/* Zone 1: Sidebar */}
<Sidebar /> <Sidebar />
<div className="flex h-full min-w-0 flex-1 flex-col"> <div className="flex h-full w-full flex-1 flex-col overflow-hidden">
{/* Zone 2: Tab Bar */} {/* Zone 2: Tab Bar */}
<Tabs value={activeTabId} onValueChange={setActiveTab} variant="line" className="w-full"> <Tabs value={activeTabId} onValueChange={setActiveTab} variant="line" className="w-full">
<header className="flex h-10 w-full items-center border-b bg-muted/5"> <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"> <TabsList className="flex h-full min-w-0 flex-1 justify-start gap-0 overflow-hidden">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger
key={tab.id} key={tab.id}
value={tab.id} value={tab.id}
className={cn( className={cn(
'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', 'group relative flex h-full min-w-0 max-w-[200px] flex-1 items-center justify-between gap-2 rounded-none border-r px-3 text-sm',
tab.id === activeTabId ? 'bg-background' : 'bg-transparent' tab.id === activeTabId ? 'bg-background' : 'bg-transparent'
)}> )}>
<span className="truncate text-xs">{tab.title}</span> {/* TODO: pin功能,形式还未确定 */}
<span className={cn('truncate text-xs', tab.isDormant && 'opacity-60')}>{tab.title}</span>
{tabs.length > 1 && ( {tabs.length > 1 && (
<div <div
role="button" role="button"
@ -84,9 +85,9 @@ export const AppShell = () => {
{/* Zone 3: Content Area - Multi MemoryRouter Architecture */} {/* Zone 3: Content Area - Multi MemoryRouter Architecture */}
<main className="relative flex-1 overflow-hidden bg-background"> <main className="relative flex-1 overflow-hidden bg-background">
{/* Route Tabs: Each has independent MemoryRouter */} {/* Route Tabs: Only render non-dormant tabs */}
{tabs {tabs
.filter((t) => t.type === 'route') .filter((t) => t.type === 'route' && !t.isDormant)
.map((tab) => ( .map((tab) => (
<TabRouter <TabRouter
key={tab.id} key={tab.id}
@ -96,9 +97,9 @@ export const AppShell = () => {
/> />
))} ))}
{/* Webview Tabs */} {/* Webview Tabs: Only render non-dormant tabs */}
{tabs {tabs
.filter((t) => t.type === 'webview') .filter((t) => t.type === 'webview' && !t.isDormant)
.map((tab) => ( .map((tab) => (
<WebviewContainer key={tab.id} url={tab.url} isActive={tab.id === activeTabId} /> <WebviewContainer key={tab.id} url={tab.url} isActive={tab.id === activeTabId} />
))} ))}

View File

@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react' import { loggerService } from '@logger'
import { TabLRUManager } from '@renderer/services/TabLRUManager'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { usePersistCache } from '../data/hooks/useCache' import { usePersistCache } from '../data/hooks/useCache'
import { uuid } from '../utils' import { uuid } from '../utils'
@ -6,13 +8,17 @@ import { getDefaultRouteTitle } from '../utils/routeTitle'
// Re-export types from shared schema // Re-export types from shared schema
export type { Tab, TabsState, TabType } from '@shared/data/cache/cacheValueTypes' export type { Tab, TabsState, TabType } from '@shared/data/cache/cacheValueTypes'
import type { Tab, TabType } from '@shared/data/cache/cacheValueTypes' import type { Tab, TabSavedState, TabType } from '@shared/data/cache/cacheValueTypes'
const logger = loggerService.withContext('useTabs')
const DEFAULT_TAB: Tab = { const DEFAULT_TAB: Tab = {
id: 'home', id: 'home',
type: 'route', type: 'route',
url: '/', url: '/',
title: getDefaultRouteTitle('/') title: getDefaultRouteTitle('/'),
lastAccessTime: Date.now(),
isDormant: false
} }
/** /**
@ -32,6 +38,13 @@ export interface OpenTabOptions {
export function useTabs() { export function useTabs() {
const [tabsState, setTabsState] = usePersistCache('ui.tab.state') const [tabsState, setTabsState] = usePersistCache('ui.tab.state')
// LRU 管理器(单例)
const lruManagerRef = useRef<TabLRUManager | null>(null)
if (!lruManagerRef.current) {
lruManagerRef.current = new TabLRUManager()
}
const lruManager = lruManagerRef.current
// Ensure at least one default tab exists // Ensure at least one default tab exists
useEffect(() => { useEffect(() => {
if (tabsState.tabs.length === 0) { if (tabsState.tabs.length === 0) {
@ -42,17 +55,144 @@ export function useTabs() {
const tabs = useMemo(() => (tabsState.tabs.length > 0 ? tabsState.tabs : [DEFAULT_TAB]), [tabsState.tabs]) const tabs = useMemo(() => (tabsState.tabs.length > 0 ? tabsState.tabs : [DEFAULT_TAB]), [tabsState.tabs])
const activeTabId = tabsState.activeTabId || DEFAULT_TAB.id const activeTabId = tabsState.activeTabId || DEFAULT_TAB.id
/**
*
*/
const performHibernationCheck = useCallback(
(currentTabs: Tab[], newActiveTabId: string) => {
const toHibernate = lruManager.checkAndGetDormantCandidates(currentTabs, newActiveTabId)
if (toHibernate.length === 0) {
return currentTabs
}
// 批量休眠
return currentTabs.map((tab) => {
if (toHibernate.includes(tab.id)) {
logger.info('Tab hibernated', { tabId: tab.id, route: tab.url })
// TODO: 保存滚动位置等状态
const savedState: TabSavedState = { scrollPosition: 0 }
return { ...tab, isDormant: true, savedState }
}
return tab
})
},
[lruManager]
)
/**
*
*
* TODO: 目前 savedState
* -
* - 稿/
*/
const hibernateTab = useCallback(
(tabId: string) => {
const tab = tabsState.tabs.find((t) => t.id === tabId)
if (!tab || tab.isDormant) return
// TODO: 实现真实的状态捕获
const savedState: TabSavedState = { scrollPosition: 0 }
logger.info('Tab hibernated (manual)', { tabId, route: tab.url })
setTabsState({
...tabsState,
tabs: tabsState.tabs.map((t) => (t.id === tabId ? { ...t, isDormant: true, savedState } : t))
})
},
[tabsState, setTabsState]
)
/**
*
*
* TODO: 目前仅清除 isDormant
* - savedState
* - 稿/
*/
const wakeTab = useCallback(
(tabId: string) => {
const tab = tabsState.tabs.find((t) => t.id === tabId)
if (!tab || !tab.isDormant) return
logger.info('Tab awakened', { tabId, route: tab.url })
// TODO: 实现真实的状态恢复(从 tab.savedState
setTabsState({
...tabsState,
tabs: tabsState.tabs.map((t) => (t.id === tabId ? { ...t, isDormant: false, lastAccessTime: Date.now() } : t))
})
},
[tabsState, setTabsState]
)
const updateTab = useCallback(
(id: string, updates: Partial<Tab>) => {
setTabsState({
...tabsState,
tabs: tabsState.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t))
})
},
[tabsState, setTabsState]
)
const setActiveTab = useCallback(
(id: string) => {
if (id === activeTabId) return
const targetTab = tabs.find((t) => t.id === id)
if (!targetTab) return
// 1. 准备更新后的标签列表
let updatedTabs = tabsState.tabs.map((t) =>
t.id === id
? {
...t,
lastAccessTime: Date.now(),
// 如果目标是休眠状态,唤醒它
isDormant: false
}
: t
)
// 2. 如果唤醒了休眠标签,记录日志
if (targetTab.isDormant) {
logger.info('Tab awakened', { tabId: id, route: targetTab.url })
}
// 3. 执行休眠检查(可能需要休眠其他标签)
updatedTabs = performHibernationCheck(updatedTabs, id)
// 4. 更新状态
setTabsState({ tabs: updatedTabs, activeTabId: id })
},
[activeTabId, tabs, tabsState, setTabsState, performHibernationCheck]
)
const addTab = useCallback( const addTab = useCallback(
(tab: Tab) => { (tab: Tab) => {
const exists = tabs.find((t) => t.id === tab.id) const exists = tabs.find((t) => t.id === tab.id)
if (exists) { if (exists) {
setTabsState({ ...tabsState, activeTabId: tab.id }) setActiveTab(tab.id)
return return
} }
const newTabs = [...tabs, tab]
// 添加 LRU 字段
const newTab: Tab = {
...tab,
lastAccessTime: Date.now(),
isDormant: false
}
// 执行休眠检查
let newTabs = [...tabs, newTab]
newTabs = performHibernationCheck(newTabs, tab.id)
setTabsState({ tabs: newTabs, activeTabId: tab.id }) setTabsState({ tabs: newTabs, activeTabId: tab.id })
}, },
[tabs, tabsState, setTabsState] [tabs, setTabsState, setActiveTab, performHibernationCheck]
) )
const closeTab = useCallback( const closeTab = useCallback(
@ -71,23 +211,6 @@ export function useTabs() {
[tabs, activeTabId, setTabsState] [tabs, activeTabId, setTabsState]
) )
const setActiveTab = useCallback(
(id: string) => {
if (id !== activeTabId) {
setTabsState({ ...tabsState, activeTabId: id })
}
},
[activeTabId, tabsState, setTabsState]
)
const updateTab = useCallback(
(id: string, updates: Partial<Tab>) => {
const newTabs = tabs.map((t) => (t.id === id ? { ...t, ...updates } : t))
setTabsState({ ...tabsState, tabs: newTabs })
},
[tabs, tabsState, setTabsState]
)
const setTabs = useCallback( const setTabs = useCallback(
(newTabs: Tab[] | ((prev: Tab[]) => Tab[])) => { (newTabs: Tab[] | ((prev: Tab[]) => Tab[])) => {
const resolvedTabs = typeof newTabs === 'function' ? newTabs(tabs) : newTabs const resolvedTabs = typeof newTabs === 'function' ? newTabs(tabs) : newTabs
@ -128,12 +251,14 @@ export function useTabs() {
} }
} }
// Create new tab with default route title // Create new tab with default route title and LRU fields
const newTab: Tab = { const newTab: Tab = {
id: id || uuid(), id: id || uuid(),
type, type,
url, url,
title: title || getDefaultRouteTitle(url) title: title || getDefaultRouteTitle(url),
lastAccessTime: Date.now(),
isDormant: false
} }
addTab(newTab) addTab(newTab)
@ -142,6 +267,28 @@ export function useTabs() {
[tabs, setActiveTab, addTab] [tabs, setActiveTab, addTab]
) )
/**
* Pin a tab (exempt from LRU hibernation)
*/
const pinTab = useCallback(
(id: string) => {
updateTab(id, { isPinned: true })
logger.info('Tab pinned', { tabId: id })
},
[updateTab]
)
/**
* Unpin a tab
*/
const unpinTab = useCallback(
(id: string) => {
updateTab(id, { isPinned: false })
logger.info('Tab unpinned', { tabId: id })
},
[updateTab]
)
/** /**
* Get the currently active tab * Get the currently active tab
*/ */
@ -162,6 +309,12 @@ export function useTabs() {
setTabs, setTabs,
// High-level Tab operations // High-level Tab operations
openTab openTab,
// LRU operations
hibernateTab,
wakeTab,
pinTab,
unpinTab
} }
} }

View File

@ -34,18 +34,14 @@ const HomePage: FC = () => {
const search = useSearch({ strict: false }) as { assistantId?: string; topicId?: string } const search = useSearch({ strict: false }) as { assistantId?: string; topicId?: string }
// 根据 search params 中的 ID 查找对应的 assistant // 根据 search params 中的 ID 查找对应的 assistant
const assistantFromSearch = search.assistantId const assistantFromSearch = search.assistantId ? assistants.find((a) => a.id === search.assistantId) : undefined
? assistants.find((a) => a.id === search.assistantId)
: undefined
const [activeAssistant, _setActiveAssistant] = useState<Assistant>( const [activeAssistant, _setActiveAssistant] = useState<Assistant>(
assistantFromSearch || _activeAssistant || assistants[0] assistantFromSearch || _activeAssistant || assistants[0]
) )
// 根据 search params 中的 topicId 查找对应的 topic // 根据 search params 中的 topicId 查找对应的 topic
const topicFromSearch = search.topicId const topicFromSearch = search.topicId ? activeAssistant?.topics?.find((t) => t.id === search.topicId) : undefined
? activeAssistant?.topics?.find((t) => t.id === search.topicId)
: undefined
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', topicFromSearch) const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', topicFromSearch)
const [showAssistants] = usePreference('assistant.tab.show') const [showAssistants] = usePreference('assistant.tab.show')

View File

@ -68,11 +68,7 @@ const ErrorMessage: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => {
values={{ provider: getProviderLabel(providerId) }} values={{ provider: getProviderLabel(providerId) }}
components={{ components={{
provider: ( provider: (
<Link <Link style={{ color: 'var(--color-link)' }} to="/settings/provider" search={{ id: providerId }} />
style={{ color: 'var(--color-link)' }}
to="/settings/provider"
search={{ id: providerId }}
/>
) )
}} }}
/> />

View File

@ -0,0 +1,198 @@
import { loggerService } from '@logger'
import type { Tab } from '@shared/data/cache/cacheValueTypes'
const logger = loggerService.withContext('TabLRU')
/**
* Tab LRU limits configuration
*
* Controls when inactive tabs should be hibernated to save memory.
* TODO: 后续可从偏好设置注入
*/
export const TAB_LIMITS = {
/**
* LRU
* 10使
*/
softCap: 10,
/**
* runaway
*
*/
hardCap: 22
}
export type TabLimits = typeof TAB_LIMITS
/**
* TabLRUManager - LRU
*
*
* - LRU
* -
* -
*/
export class TabLRUManager {
private softCap: number
private hardCap: number
constructor(limits: TabLimits = TAB_LIMITS) {
this.softCap = limits.softCap
this.hardCap = limits.hardCap
}
/**
* ID
*
*
* - softCap softCap
* - hardCap softCap+
*
* @param tabs
* @param activeTabId ID
* @returns ID
*/
checkAndGetDormantCandidates(tabs: Tab[], activeTabId: string): string[] {
const activeTabs = tabs.filter((t) => !t.isDormant)
const activeCount = activeTabs.length
// 未超软上限,无需休眠
if (activeCount <= this.softCap) {
return []
}
const isHardCapTriggered = activeCount > this.hardCap
// 获取候选列表
// 硬保险丝触发时,使用更宽松的豁免规则(仅保留当前+首页)
const candidates = isHardCapTriggered
? this.getHardCapCandidates(activeTabs, activeTabId)
: this.getLRUCandidates(activeTabs, activeTabId)
// 计算需要休眠的数量:始终休眠到 softCap
let toHibernateCount = activeCount - this.softCap
if (isHardCapTriggered) {
logger.warn('Hard cap triggered - using relaxed exemption rules', {
activeCount,
hardCap: this.hardCap,
softCap: this.softCap,
toHibernate: toHibernateCount
})
}
// 只能休眠可用的候选数量
toHibernateCount = Math.min(toHibernateCount, candidates.length)
// 检查是否能达到目标
const afterHibernation = activeCount - toHibernateCount
if (isHardCapTriggered && afterHibernation > this.hardCap) {
// 极端情况:即使放宽豁免,仍无法降到 hardCap 以下
logger.error('Cannot guarantee hard cap - insufficient candidates', {
activeCount,
candidatesAvailable: candidates.length,
willHibernate: toHibernateCount,
afterHibernation,
hardCap: this.hardCap
})
} else if (afterHibernation > this.softCap) {
// 一般情况:无法降到 softCap但仍在 hardCap 以下
logger.warn('Cannot reach soft cap - limited by available candidates', {
activeCount,
candidatesAvailable: candidates.length,
willHibernate: toHibernateCount,
afterHibernation,
softCap: this.softCap
})
}
const result = candidates.slice(0, toHibernateCount).map((t) => t.id)
if (result.length > 0) {
logger.info('Tabs selected for hibernation', {
count: result.length,
ids: result,
activeCount,
softCap: this.softCap,
hardCapTriggered: isHardCapTriggered
})
}
return result
}
/**
*
*/
private getHardCapCandidates(tabs: Tab[], activeTabId: string): Tab[] {
return tabs
.filter((tab) => !this.isHardExempt(tab, activeTabId))
.sort((a, b) => (a.lastAccessTime ?? 0) - (b.lastAccessTime ?? 0))
}
/**
* +
*/
private isHardExempt(tab: Tab, activeTabId: string): boolean {
return (
tab.id === activeTabId || // 当前活动标签
tab.id === 'home' || // 首页
tab.isDormant === true // 已休眠的不再参与
)
// 注意isPinned 在硬保险丝触发时不再豁免
}
/**
* LRU 访
*/
private getLRUCandidates(tabs: Tab[], activeTabId: string): Tab[] {
return tabs
.filter((tab) => !this.isExempt(tab, activeTabId))
.sort((a, b) => (a.lastAccessTime ?? 0) - (b.lastAccessTime ?? 0))
}
/**
*
*
*
* -
* - (id === 'home')
* - (isPinned)
* -
*/
private isExempt(tab: Tab, activeTabId: string): boolean {
return (
tab.id === activeTabId || // 当前活动标签
tab.id === 'home' || // 首页
tab.isPinned === true || // 置顶标签
tab.isDormant === true // 已休眠的不再参与
)
}
/**
* 使
*/
updateSoftCap(newSoftCap: number): void {
this.softCap = newSoftCap
logger.info('SoftCap updated', { newSoftCap })
}
/**
* 使
*/
updateHardCap(newHardCap: number): void {
this.hardCap = newHardCap
logger.info('HardCap updated', { newHardCap })
}
/**
*
*/
getLimits(): TabLimits {
return {
softCap: this.softCap,
hardCap: this.hardCap
}
}
}