重写ssh前端组件,通用化

This commit is contained in:
dengqichen 2025-12-06 22:32:57 +08:00
parent 1fd72bf007
commit 004e1adcf5
5 changed files with 344 additions and 187 deletions

View File

@ -1,16 +1,21 @@
/**
*
*
* Terminal
*
*/
import React, { useRef, useState, useEffect } from 'react';
import type { LayoutOrientation } from './types';
interface SplitDividerProps {
interface TerminalSplitDividerProps {
orientation: LayoutOrientation;
onResize: (delta: number) => void;
}
export const SplitDivider: React.FC<SplitDividerProps> = ({ orientation, onResize }) => {
/**
*
* -
* -
*/
export const TerminalSplitDivider: React.FC<TerminalSplitDividerProps> = ({ orientation, onResize }) => {
const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef(0);

View File

@ -0,0 +1,191 @@
/**
* Terminal
* Group Container
*/
import React from 'react';
import { Terminal } from './Terminal';
import { TerminalSplitDivider } from './TerminalSplitDivider';
import { X, Plus } from 'lucide-react';
import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types';
/**
* Props
*/
export interface TerminalSplitNodeProps {
node: SplitNode;
activeGroupId: string;
onTabClick: (groupId: string, tabId: string) => void;
onTabClose: (groupId: string, tabId: string) => void;
onNewTab: (groupId?: string) => void;
onSplitUp: () => void;
onSplitDown: () => void;
onSplitLeft: () => void;
onSplitRight: () => void;
onFocus: (groupId: string) => void;
onResize: (nodeId: string, delta: number) => void;
getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig;
getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig;
}
/**
*
* - Group Tab Terminal
* - Container
*/
const TerminalSplitNodeComponent: React.FC<TerminalSplitNodeProps> = ({
node,
activeGroupId,
onTabClick,
onTabClose,
onNewTab,
onSplitUp,
onSplitDown,
onSplitLeft,
onSplitRight,
onFocus,
onResize,
getConnectionConfig,
getAuditConfig,
getToolbarConfig,
}) => {
// 渲染 Group 节点(包含 Tabs 和 Terminal
if (node.type === 'group') {
const isActive = node.id === activeGroupId;
const connectionConfigs: Record<string, TerminalConnectionConfig> = {};
node.tabs.forEach(tab => {
connectionConfigs[tab.id] = getConnectionConfig(tab);
});
const auditConfig = getAuditConfig();
const toolbarConfig = getToolbarConfig();
return (
<div
className="h-full w-full flex flex-col bg-white dark:bg-gray-900"
onClick={() => {
console.log(`[Group Click] Activating group: ${node.id}`);
onFocus(node.id);
}}
>
{/* Tab栏 */}
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center px-2 py-1 gap-1 overflow-x-auto">
{node.tabs.map((tab) => (
<div
key={tab.id}
className={`flex items-center gap-1 px-3 py-1.5 rounded-t cursor-pointer transition-colors ${
tab.isActive
? 'bg-gray-100 dark:bg-gray-800 border-b-2 border-blue-500'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
}`}
onClick={(e) => {
e.stopPropagation();
console.log(`[Tab Click] groupId: ${node.id}, tabId: ${tab.id}`);
onTabClick(node.id, tab.id);
}}
>
<span className="text-xs truncate max-w-[150px]" title={tab.title}>
{tab.serverName}
</span>
<button
className="hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-0.5"
onClick={(e) => {
e.stopPropagation();
onTabClose(node.id, tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
className="flex items-center justify-center w-8 h-8 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
onClick={(e) => {
e.stopPropagation();
console.log(`[+ Button Click] Creating tab in group: ${node.id}`);
onNewTab(node.id); // 直接传递groupId
}}
title="新建终端 (Cmd+T)"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* 终端内容 */}
<div className="flex-1 overflow-hidden relative">
{node.tabs.map((tab) => (
<div
key={tab.id}
className="absolute inset-0"
style={{ display: tab.isActive ? 'block' : 'none' }}
>
<Terminal
id={tab.id}
connection={connectionConfigs[tab.id]}
audit={auditConfig}
toolbar={toolbarConfig}
isActive={tab.isActive}
onSplitUp={onSplitUp}
onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft}
onSplitRight={onSplitRight}
onSplitInGroup={onNewTab}
/>
</div>
))}
</div>
</div>
);
}
// 渲染 Container 节点(递归渲染子节点)
const isHorizontal = node.orientation === 'horizontal';
return (
<div className={`h-full w-full flex ${isHorizontal ? 'flex-row' : 'flex-col'}`}>
{node.children.map((child, index) => (
<React.Fragment key={child.id}>
<div
className="flex-shrink-0 flex-grow-0"
style={{
[isHorizontal ? 'width' : 'height']: `${child.size}%`,
}}
>
{/* 递归渲染子节点 */}
<TerminalSplitNodeComponent
node={child}
activeGroupId={activeGroupId}
onTabClick={onTabClick}
onTabClose={onTabClose}
onNewTab={onNewTab}
onSplitUp={onSplitUp}
onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft}
onSplitRight={onSplitRight}
onFocus={onFocus}
onResize={onResize}
getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig}
/>
</div>
{/* 分隔条 - 不在最后一个节点之后显示 */}
{index < node.children.length - 1 && (
<TerminalSplitDivider
orientation={node.orientation}
onResize={(delta) => onResize(child.id, delta)}
/>
)}
</React.Fragment>
))}
</div>
);
};
/**
* 使 React.memo
* props
*/
export const TerminalSplitNode = React.memo(TerminalSplitNodeComponent);

View File

@ -1,13 +1,11 @@
/**
* Terminal
* VS Code
* Terminal -
* TerminalSplitNode
*/
import React, { useEffect } from 'react';
import { Terminal } from './Terminal';
import { SplitDivider } from './SplitDivider';
import { X, Plus } from 'lucide-react';
import { useSplitView } from './useSplitView';
import type { SplitNode, TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types';
import { TerminalSplitNode } from './TerminalSplitNode';
import type { TerminalTab, TerminalConnectionConfig, TerminalAuditConfig, TerminalToolbarConfig } from './types';
// 对外暴露的主组件Props
export interface TerminalSplitViewProps {
@ -15,179 +13,23 @@ export interface TerminalSplitViewProps {
getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig;
getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig;
onCloseAllReady?: (closeAllFn: () => void) => void; // 暴露closeAll函数给外部
}
// 内部递归组件Props
interface SplitNodeRendererProps {
node: SplitNode;
activeGroupId: string;
onTabClick: (groupId: string, tabId: string) => void;
onTabClose: (groupId: string, tabId: string) => void;
onNewTab: () => void;
onSplitUp: () => void;
onSplitDown: () => void;
onSplitLeft: () => void;
onSplitRight: () => void;
onFocus: (groupId: string) => void;
onResize: (nodeId: string, delta: number) => void;
getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig;
getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig;
}
const SplitNodeRendererComponent: React.FC<SplitNodeRendererProps> = ({
node,
activeGroupId,
onTabClick,
onTabClose,
onNewTab,
onSplitUp,
onSplitDown,
onSplitLeft,
onSplitRight,
onFocus,
onResize,
getConnectionConfig,
getAuditConfig,
getToolbarConfig,
}) => {
// 渲染编辑器组(叶子节点)
if (node.type === 'group') {
const isActive = node.id === activeGroupId;
const connectionConfigs: Record<string, TerminalConnectionConfig> = {};
node.tabs.forEach(tab => {
connectionConfigs[tab.id] = getConnectionConfig(tab);
});
const auditConfig = getAuditConfig();
const toolbarConfig = getToolbarConfig();
return (
<div className="h-full w-full flex flex-col bg-white dark:bg-gray-900">
{/* Tab栏 */}
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center px-2 py-1 gap-1 overflow-x-auto">
{node.tabs.map((tab) => (
<div
key={tab.id}
className={`flex items-center gap-1 px-3 py-1.5 rounded-t cursor-pointer transition-colors ${
tab.isActive
? 'bg-gray-100 dark:bg-gray-800 border-b-2 border-blue-500'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
}`}
onClick={() => onTabClick(node.id, tab.id)}
>
<span className="text-xs truncate max-w-[150px]" title={tab.title}>
{tab.serverName}
</span>
<button
className="hover:bg-gray-300 dark:hover:bg-gray-600 rounded p-0.5"
onClick={(e) => {
e.stopPropagation();
onTabClose(node.id, tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
className="flex items-center justify-center w-8 h-8 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
onClick={(e) => {
e.stopPropagation();
onNewTab();
}}
title="新建终端 (Cmd+T)"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* 终端内容 */}
<div className="flex-1 overflow-hidden relative">
{node.tabs.map((tab) => (
<div
key={tab.id}
className="absolute inset-0"
style={{ display: tab.isActive ? 'block' : 'none' }}
>
<Terminal
id={tab.id}
connection={connectionConfigs[tab.id]}
audit={auditConfig}
toolbar={toolbarConfig}
isActive={tab.isActive}
onSplitUp={onSplitUp}
onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft}
onSplitRight={onSplitRight}
onSplitInGroup={onNewTab}
/>
</div>
))}
</div>
</div>
);
}
// 渲染容器(内部节点)- 递归渲染子节点
const isHorizontal = node.orientation === 'horizontal';
return (
<div className={`h-full w-full flex ${isHorizontal ? 'flex-row' : 'flex-col'}`}>
{node.children.map((child, index) => (
<React.Fragment key={child.id}>
<div
key={child.id}
className="flex-shrink-0 flex-grow-0"
style={{
[isHorizontal ? 'width' : 'height']: `${child.size}%`,
}}
>
<SplitNodeRendererComponent
key={child.id}
node={child}
activeGroupId={activeGroupId}
onTabClick={onTabClick}
onTabClose={onTabClose}
onNewTab={onNewTab}
onSplitUp={onSplitUp}
onSplitDown={onSplitDown}
onSplitLeft={onSplitLeft}
onSplitRight={onSplitRight}
onFocus={onFocus}
onResize={onResize}
getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig}
/>
</div>
{/* 分隔条 - 不在最后一个节点之后显示 */}
{index < node.children.length - 1 && (
<SplitDivider
orientation={node.orientation}
onResize={(delta) => onResize(child.id, delta)}
/>
)}
</React.Fragment>
))}
</div>
);
};
// 使用 React.memo 优化,避免不必要的重新渲染
const SplitNodeRenderer = React.memo(SplitNodeRendererComponent);
/**
* Terminal -
*
*
* 1. useSplitView hook
* 2.
* 3. closeAll
* 4. TerminalSplitNode
*/
export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
initialTab,
getConnectionConfig,
getAuditConfig,
getToolbarConfig,
onCloseAllReady,
}) => {
const {
layout,
@ -199,10 +41,18 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
splitInGroup,
switchTab,
closeTab,
closeAll,
resizeGroups,
setActiveGroupId,
} = useSplitView({ initialTab });
// 暴露closeAll方法给外部
useEffect(() => {
if (onCloseAllReady) {
onCloseAllReady(closeAll);
}
}, [onCloseAllReady, closeAll]);
// 快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -235,7 +85,7 @@ export const TerminalSplitView: React.FC<TerminalSplitViewProps> = ({
return (
<div className="terminal-split-container h-full w-full">
<SplitNodeRenderer
<TerminalSplitNode
node={layout.root}
activeGroupId={activeGroupId}
onTabClick={switchTab}

View File

@ -201,12 +201,26 @@ export const useSplitView = ({ initialTab }: UseSplitViewOptions) => {
};
// 在组中拆分(创建新标签页)
const splitInGroup = useCallback(() => {
const activeGroup = getActiveGroup();
if (!activeGroup) return;
const splitInGroup = useCallback((groupId?: string) => {
// 如果提供了groupId就用它否则用activeGroupId
const targetGroupId = groupId || activeGroupId;
console.log(`[splitInGroup] Target groupId: ${targetGroupId}`);
const targetGroup = findGroup(layout.root, targetGroupId);
if (!targetGroup) {
console.log('[splitInGroup] Target group not found');
return;
}
const activeTab = activeGroup.tabs.find(t => t.id === activeGroup.activeTabId);
if (!activeTab) return;
console.log(`[splitInGroup] Target group: ${targetGroup.id}, tabs count: ${targetGroup.tabs.length}`);
const activeTab = targetGroup.tabs.find(t => t.id === targetGroup.activeTabId);
if (!activeTab) {
console.log('[splitInGroup] No active tab in target group');
return;
}
console.log(`[splitInGroup] Creating new tab based on: ${activeTab.serverName}`);
const newTab: TerminalTab = {
id: `tab-${Date.now()}`,
@ -217,19 +231,32 @@ export const useSplitView = ({ initialTab }: UseSplitViewOptions) => {
};
setLayout(prev => ({
root: updateNode(prev.root, activeGroup.id, {
...activeGroup,
tabs: [...activeGroup.tabs.map(t => ({ ...t, isActive: false })), newTab],
root: updateNode(prev.root, targetGroup.id, {
...targetGroup,
tabs: [...targetGroup.tabs.map(t => ({ ...t, isActive: false })), newTab],
activeTabId: newTab.id,
}),
}));
}, [activeGroupId, getActiveGroup]);
// 更新activeGroupId
setActiveGroupId(targetGroupId);
console.log(`[splitInGroup] New tab created: ${newTab.id} in group: ${targetGroup.id}`);
}, [activeGroupId, findGroup, layout.root]);
// 切换标签页
const switchTab = useCallback((groupId: string, tabId: string) => {
console.log(`[switchTab] Switching to group: ${groupId}, tab: ${tabId}`);
// 先设置activeGroupId确保后续操作使用正确的组
setActiveGroupId(groupId);
setLayout(prev => {
const group = findGroup(prev.root, groupId);
if (!group) return prev;
if (!group) {
console.log(`[switchTab] Group not found: ${groupId}`);
return prev;
}
return {
root: updateNode(prev.root, groupId, {
@ -239,7 +266,6 @@ export const useSplitView = ({ initialTab }: UseSplitViewOptions) => {
}),
};
});
setActiveGroupId(groupId);
}, [findGroup]);
// 关闭标签页
@ -368,6 +394,31 @@ export const useSplitView = ({ initialTab }: UseSplitViewOptions) => {
});
}, [findParent]);
// 关闭所有Tab窗口关闭时调用
const closeAll = useCallback(() => {
console.log('[useSplitView] 关闭所有Tab断开所有连接');
const manager = TerminalInstanceManager.getInstance();
// 收集所有Tab ID
const allTabIds: string[] = [];
const collectTabIds = (node: SplitNode) => {
if (node.type === 'group') {
allTabIds.push(...node.tabs.map(t => t.id));
} else if (node.type === 'container') {
node.children.forEach(collectTabIds);
}
};
collectTabIds(layout.root);
// 销毁所有Terminal实例
allTabIds.forEach(tabId => {
manager.destroy(tabId);
console.log(`[useSplitView] 已销毁Terminal实例: ${tabId}`);
});
console.log(`[useSplitView] 共关闭 ${allTabIds.length} 个Tab`);
}, [layout.root]);
return {
layout,
activeGroupId,
@ -378,6 +429,7 @@ export const useSplitView = ({ initialTab }: UseSplitViewOptions) => {
splitInGroup,
switchTab,
closeTab,
closeAll,
resizeGroups,
setActiveGroupId,
};

View File

@ -2,7 +2,7 @@
* SSH
* 使 TerminalSplitView
*/
import React from 'react';
import React, { useEffect, useRef } from 'react';
import {
TerminalWindowManager,
TerminalSplitView,
@ -13,6 +13,63 @@ import {
} from '@/components/Terminal';
import type { ServerResponse } from '../types';
/**
* SSH Terminal
* window对象
*/
interface SSHTerminalSplitViewWrapperProps {
windowId: string;
initialTab: TerminalTab;
getConnectionConfig: (tab: TerminalTab) => TerminalConnectionConfig;
getAuditConfig: () => TerminalAuditConfig;
getToolbarConfig: () => TerminalToolbarConfig;
onCloseReady: () => void;
}
const SSHTerminalSplitViewWrapper: React.FC<SSHTerminalSplitViewWrapperProps> = ({
windowId,
initialTab,
getConnectionConfig,
getAuditConfig,
getToolbarConfig,
onCloseReady,
}) => {
const closeAllRef = useRef<(() => void) | null>(null);
// 注册优雅关闭方法到window对象
useEffect(() => {
const closeMethodName = `__closeSSH_${windowId}`;
(window as any)[closeMethodName] = () => {
console.log(`[SSHTerminalSplitViewWrapper] 调用优雅关闭方法: ${windowId}`);
// 先关闭所有连接
if (closeAllRef.current) {
closeAllRef.current();
}
// 再通知窗口可以关闭
onCloseReady();
};
console.log(`[SSHTerminalSplitViewWrapper] 注册优雅关闭方法: ${closeMethodName}`);
return () => {
delete (window as any)[closeMethodName];
console.log(`[SSHTerminalSplitViewWrapper] 注销优雅关闭方法: ${closeMethodName}`);
};
}, [windowId, onCloseReady]);
return (
<TerminalSplitView
initialTab={initialTab}
getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig}
onCloseAllReady={(closeAllFn) => {
// 保存closeAll函数到ref
closeAllRef.current = closeAllFn;
}}
/>
);
};
interface SSHWindowManagerProps {
onOpenWindow?: (windowId: string) => void;
}
@ -65,11 +122,13 @@ export const SSHWindowManager: React.FC<SSHWindowManagerProps> = ({ onOpenWindow
});
return (
<TerminalSplitView
<SSHTerminalSplitViewWrapper
windowId={windowId}
initialTab={initialTab}
getConnectionConfig={getConnectionConfig}
getAuditConfig={getAuditConfig}
getToolbarConfig={getToolbarConfig}
onCloseReady={onCloseReady}
/>
);
}}