Merge pull request #91 from Chiron-Brahm/fix/terminal-state-persistence

fix: 终端和页面切换时保持状态
This commit is contained in:
coso
2026-01-10 13:06:49 +08:00
committed by GitHub
3 changed files with 321 additions and 265 deletions
+101 -97
View File
@@ -52,21 +52,22 @@ const MainContent = styled.main`
min-height: 0;
`;
const PageWrapper = styled.div`
const PageWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
padding: 24px;
overflow: auto;
display: ${(props) => (props.$isActive ? "block" : "none")};
`;
/**
* 全屏页面容器(无 padding)
* 用于终端等需要全屏显示的插件
*/
const FullscreenWrapper = styled.div`
const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
display: ${(props) => (props.$isActive ? "flex" : "none")};
flex-direction: column;
position: relative;
`;
@@ -124,106 +125,109 @@ function AppContent() {
}, []);
/**
* 渲染当前页面
* 渲染所有页面(保持挂载状态)
*
* 根据 currentPage 状态渲染对应的页面组件
* - 静态页面: 直接渲染对应组件
* - 动态插件页面: 使用 PluginUIRenderer 渲染
* 所有页面组件都会被渲染,但只有当前页面可见
* 这样可以保持页面状态,避免切换时重置
*
* _需求: 2.2, 3.2_
*/
const renderPage = () => {
// 检查是否为动态插件页面 (plugin:xxx 格式)
if (currentPage.startsWith("plugin:")) {
const pluginId = currentPage.slice(7); // 移除 "plugin:" 前缀
// 需要全屏显示的插件列表(终端已内置,不再需要插件)
const fullscreenPlugins: string[] = [];
const isFullscreen = fullscreenPlugins.includes(pluginId);
if (isFullscreen) {
return (
<FullscreenWrapper>
<PluginUIRenderer pluginId={pluginId} onNavigate={setCurrentPage} />
</FullscreenWrapper>
);
}
return (
<PageWrapper>
<PluginUIRenderer pluginId={pluginId} onNavigate={setCurrentPage} />
const renderAllPages = () => {
return (
<>
{/* Provider Pool 页面 */}
<PageWrapper $isActive={currentPage === "provider-pool"}>
<ProviderPoolPage />
</PageWrapper>
);
}
// 静态页面路由
switch (currentPage) {
case "provider-pool":
return (
<PageWrapper>
<ProviderPoolPage />
</PageWrapper>
);
case "api-server":
return (
<PageWrapper>
<ApiServerPage />
</PageWrapper>
);
case "agent":
// Agent 页面有自己的布局,不需要 PageWrapper
return (
{/* API Server 页面 */}
<PageWrapper $isActive={currentPage === "api-server"}>
<ApiServerPage />
</PageWrapper>
{/* Agent 页面 - 使用 div 包装以支持显示/隐藏 */}
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "agent" ? "flex" : "none",
flexDirection: "column",
}}
>
<AgentChatPage onNavigate={(page) => setCurrentPage(page as Page)} />
);
case "terminal":
// 终端工作区 - 分块布局(对齐 Waveterm TileLayout)
return <TerminalWorkspace onNavigate={setCurrentPage} />;
case "sysinfo":
// 系统监控页面
return (
<FullscreenWrapper>
<SysinfoView />
</FullscreenWrapper>
);
case "files":
// 文件浏览器页面
return (
<FullscreenWrapper>
<FileBrowserView />
</FullscreenWrapper>
);
case "web":
// 内嵌浏览器页面
return (
<FullscreenWrapper>
<WebView />
</FullscreenWrapper>
);
case "tools":
return (
<PageWrapper>
<ToolsPage onNavigate={setCurrentPage} />
</PageWrapper>
);
case "plugins":
return (
<PageWrapper>
<PluginsPage />
</PageWrapper>
);
case "settings":
return (
<PageWrapper>
<SettingsPage />
</PageWrapper>
);
default:
return (
<PageWrapper>
<ApiServerPage />
</PageWrapper>
);
}
</div>
{/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "terminal" ? "flex" : "none",
flexDirection: "column",
}}
>
<TerminalWorkspace onNavigate={setCurrentPage} />
</div>
{/* 系统监控页面 */}
<FullscreenWrapper $isActive={currentPage === "sysinfo"}>
<SysinfoView />
</FullscreenWrapper>
{/* 文件浏览器页面 */}
<FullscreenWrapper $isActive={currentPage === "files"}>
<FileBrowserView />
</FullscreenWrapper>
{/* 内嵌浏览器页面 */}
<FullscreenWrapper $isActive={currentPage === "web"}>
<WebView />
</FullscreenWrapper>
{/* Tools 页面 */}
<PageWrapper $isActive={currentPage === "tools"}>
<ToolsPage onNavigate={setCurrentPage} />
</PageWrapper>
{/* Plugins 页面 */}
<PageWrapper $isActive={currentPage === "plugins"}>
<PluginsPage />
</PageWrapper>
{/* Settings 页面 */}
<PageWrapper $isActive={currentPage === "settings"}>
<SettingsPage />
</PageWrapper>
{/* 动态插件页面 */}
{currentPage.startsWith("plugin:") &&
(() => {
const pluginId = currentPage.slice(7);
const fullscreenPlugins: string[] = [];
const isFullscreen = fullscreenPlugins.includes(pluginId);
if (isFullscreen) {
return (
<FullscreenWrapper $isActive={true}>
<PluginUIRenderer
pluginId={pluginId}
onNavigate={setCurrentPage}
/>
</FullscreenWrapper>
);
}
return (
<PageWrapper $isActive={true}>
<PluginUIRenderer
pluginId={pluginId}
onNavigate={setCurrentPage}
/>
</PageWrapper>
);
})()}
</>
);
};
// 引导完成回调
@@ -251,7 +255,7 @@ function AppContent() {
<ComponentDebugProvider>
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<MainContent>{renderPage()}</MainContent>
<MainContent>{renderAllPages()}</MainContent>
{/* ProxyCast Connect 确认弹窗 */}
{/* _Requirements: 5.2_ */}
<ConnectConfirmDialog
+201 -168
View File
@@ -1,12 +1,12 @@
/**
* @file TerminalPage.tsx
* @description 内置终端页面组件 - 对齐 waveterm 单容器架构
* @description 内置终端页面组件 - 多容器架构保持状态
* @module components/terminal/TerminalPage
*
* ## 架构说明(对齐 waveterm)
* - 只有一个 term-connectelem(终端容器)
* - 切换标签页时,销毁旧的 TermWrap,创建新的 TermWrap
* - 这样可以避免多个终端容器导致的布局问题
* ## 架构说明
* - 每个标签页有独立的终端容器和 TermWrap 实例
* - 切换标签页时,只是显示/隐藏对应的容器
* - 保持所有终端的状态,不销毁 TermWrap
*
* _Requirements: 8.7, 8.8, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
*/
@@ -43,6 +43,7 @@ interface Tab {
title: string;
status: SessionStatus;
isSSH?: boolean;
termWrap?: TermWrap; // 保持 TermWrap 实例
}
// ============================================================================
@@ -375,7 +376,7 @@ const EmptyTabsPlaceholder: React.FC<{
);
// ============================================================================
// 主组件 - 对齐 waveterm 单容器架构
// 主组件 - 多容器架构保持状态
// ============================================================================
export function TerminalPage() {
@@ -392,18 +393,16 @@ export function TerminalPage() {
const [fontSize, setFontSize] = useState<number>(loadFontSizePreference());
const tabIdCounter = useRef(0);
// 单一终端容器引用(对齐 waveterm 的 connectElemRef)
const connectElemRef = useRef<HTMLDivElement>(null);
// 当前 TermWrap 实例引用(对齐 waveterm 的 model.termRef)
const termWrapRef = useRef<TermWrap | null>(null);
// 终端容器的父容器引用
const terminalContainerRef = useRef<HTMLDivElement>(null);
// 获取当前活动的标签页
const activeTab = tabs.find((t) => t.id === activeTabId);
// 获取当前活动的 TermWrap
const getActiveTermWrap = useCallback(() => {
return termWrapRef.current;
}, []);
return activeTab?.termWrap || null;
}, [activeTab]);
// 创建新终端
const handleNewTerminal = useCallback(async () => {
@@ -453,6 +452,11 @@ export function TerminalPage() {
const tab = tabs.find((t) => t.id === tabId);
if (tab) {
try {
// 销毁 TermWrap
if (tab.termWrap) {
tab.termWrap.dispose();
}
// 关闭后端会话
await closeTerminal(tab.sessionId);
} catch (err) {
console.error("[TerminalPage] 关闭终端会话失败:", err);
@@ -477,10 +481,15 @@ export function TerminalPage() {
const handleThemeChange = useCallback((theme: ThemeName) => {
setCurrentTheme(theme);
saveThemePreference(theme);
// 更新当前终端的主题
if (termWrapRef.current) {
termWrapRef.current.setTheme(theme);
}
// 更新所有终端的主题
setTabs((prev) =>
prev.map((tab) => {
if (tab.termWrap) {
tab.termWrap.setTheme(theme);
}
return tab;
}),
);
}, []);
// 字体大小变化
@@ -488,10 +497,15 @@ export function TerminalPage() {
const handleFontSizeChange = useCallback((size: number) => {
setFontSize(size);
saveFontSizePreference(size);
// 更新当前终端的字体大小
if (termWrapRef.current) {
termWrapRef.current.setFontSize(size);
}
// 更新所有终端的字体大小
setTabs((prev) =>
prev.map((tab) => {
if (tab.termWrap) {
tab.termWrap.setFontSize(size);
}
return tab;
}),
);
}, []);
// 搜索功能
@@ -578,162 +592,181 @@ export function TerminalPage() {
}, []);
// ============================================================================
// 核心:当活动标签页变化时,重新创建 TermWrap(对齐 waveterm)
// 核心:为每个标签页创建独立的终端容器和 TermWrap
// ============================================================================
// 键盘事件处理器(对齐 waveterm 的 handleTerminalKeydown)
// 键盘事件处理器
// 返回 true = 允许事件传递到终端
// 返回 false = 阻止事件传递到终端(已处理)
const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => {
// 只处理 keydown 事件
if (e.type !== "keydown") {
return true;
}
const termWrap = termWrapRef.current;
if (!termWrap) return true;
const isMac = /mac/i.test(navigator.userAgent);
// Shift+End - 滚动到底部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+Home - 滚动到顶部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+End (macOS) - 滚动到底部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+Home (macOS) - 滚动到顶部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+PageDown - 向下滚动一页
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "PageDown"
) {
termWrap.terminal.scrollPages(1);
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+PageUp - 向上滚动一页
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "PageUp"
) {
termWrap.terminal.scrollPages(-1);
e.preventDefault();
e.stopPropagation();
return false;
}
// 未处理的事件,允许传递到终端
return true;
}, []);
useEffect(() => {
const container = connectElemRef.current;
if (!container || !activeTab) {
// 没有活动标签页,清理旧的 TermWrap
if (termWrapRef.current) {
termWrapRef.current.dispose();
termWrapRef.current = null;
const handleTerminalKeydown = useCallback(
(e: KeyboardEvent): boolean => {
// 只处理 keydown 事件
if (e.type !== "keydown") {
return true;
}
return;
}
// 销毁旧的 TermWrap
if (termWrapRef.current) {
termWrapRef.current.dispose();
termWrapRef.current = null;
}
const termWrap = getActiveTermWrap();
if (!termWrap) return true;
// 清空容器
container.innerHTML = "";
const isMac = /mac/i.test(navigator.userAgent);
// 创建新的 TermWrap(对齐 waveterm)
const termWrap = new TermWrap(activeTab.sessionId, container, {
onStatusChange: (status) => handleStatusChange(activeTab.id, status),
themeName: currentTheme,
fontSize: fontSize,
keydownHandler: handleTerminalKeydown,
// Shift+End - 滚动到底部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+Home - 滚动到顶部
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+End (macOS) - 滚动到底部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "End"
) {
termWrap.terminal.scrollToBottom();
e.preventDefault();
e.stopPropagation();
return false;
}
// Cmd+Home (macOS) - 滚动到顶部
if (
isMac &&
e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
e.key === "Home"
) {
termWrap.terminal.scrollToLine(0);
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+PageDown - 向下滚动一页
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "PageDown"
) {
termWrap.terminal.scrollPages(1);
e.preventDefault();
e.stopPropagation();
return false;
}
// Shift+PageUp - 向上滚动一页
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "PageUp"
) {
termWrap.terminal.scrollPages(-1);
e.preventDefault();
e.stopPropagation();
return false;
}
// 未处理的事件,允许传递到终端
return true;
},
[getActiveTermWrap],
);
// 为新标签页创建终端
useEffect(() => {
const container = terminalContainerRef.current;
if (!container) return;
// 为每个没有 termWrap 的标签页创建终端
tabs.forEach((tab) => {
if (tab.termWrap) return; // 已有 TermWrap,跳过
// 创建独立的容器
const termContainer = document.createElement("div");
termContainer.className = "term-connectelem";
termContainer.dataset.tabId = tab.id;
termContainer.style.display = tab.id === activeTabId ? "block" : "none";
container.appendChild(termContainer);
// 创建 TermWrap
const termWrap = new TermWrap(tab.sessionId, termContainer, {
onStatusChange: (status) => handleStatusChange(tab.id, status),
themeName: currentTheme,
fontSize: fontSize,
keydownHandler: handleTerminalKeydown,
});
// 保存到标签页
tab.termWrap = termWrap;
// 设置 ResizeObserver
const rszObs = new ResizeObserver(() => {
termWrap.handleResize_debounced();
});
rszObs.observe(termContainer);
// 异步初始化终端
termWrap.initTerminal().catch(console.error);
// 如果是当前活动标签,自动聚焦
if (tab.id === activeTabId) {
setTimeout(() => termWrap.focus(), 10);
}
// 清理函数会在标签页关闭时由 handleCloseTab 调用
});
termWrapRef.current = termWrap;
// 设置 ResizeObserver(对齐 waveterm)
const rszObs = new ResizeObserver(() => {
termWrap.handleResize_debounced();
// 更新容器显示状态
const allContainers = container.querySelectorAll(".term-connectelem");
allContainers.forEach((elem) => {
const htmlElem = elem as HTMLElement;
const tabId = htmlElem.dataset.tabId;
htmlElem.style.display = tabId === activeTabId ? "block" : "none";
});
rszObs.observe(container);
// 异步初始化终端(对齐 waveterm 的 fireAndForget)
termWrap.initTerminal().catch(console.error);
// 自动聚焦
setTimeout(() => termWrap.focus(), 10);
return () => {
termWrap.dispose();
rszObs.disconnect();
};
// 注意:handleTerminalKeydown 使用 useCallback 且无依赖,不会导致重新创建
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab?.sessionId, currentTheme, fontSize, handleStatusChange]);
// 聚焦当前活动终端
if (activeTab?.termWrap) {
setTimeout(() => activeTab.termWrap?.focus(), 10);
}
}, [
tabs,
activeTabId,
currentTheme,
fontSize,
handleStatusChange,
handleTerminalKeydown,
activeTab,
]);
// 没有标签页时显示空状态
if (tabs.length === 0) {
@@ -794,11 +827,11 @@ export function TerminalPage() {
onClearSearch={handleClearSearch}
/>
{/* 单一终端容器(对齐 waveterm 的 term-connectelem) */}
{/* 多终端容器(每个标签页一个容器) */}
<div
ref={connectElemRef}
className="term-connectelem"
onClick={() => termWrapRef.current?.focus()}
ref={terminalContainerRef}
className="term-container-wrapper"
onClick={() => activeTab?.termWrap?.focus()}
/>
{/* 错误提示 */}
+19
View File
@@ -49,6 +49,25 @@
margin-left: 4px;
}
/* 多终端容器包装器 */
.term-container-wrapper {
flex-grow: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
/* 每个终端容器独立定位 */
.term-container-wrapper .term-connectelem {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 5px;
margin-left: 4px;
}
/* 关键:xterm 容器必须填满父元素 */
.term-connectelem .xterm {
height: 100%;