454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
/**
|
||
* Terminal 分屏管理 Hook
|
||
* 支持树形嵌套布局,参考 VS Code 的分屏逻辑
|
||
*/
|
||
import { useState, useCallback } from 'react';
|
||
import type { EditorGroup, TerminalTab, SplitDirection, SplitLayout, SplitNode, SplitContainer, LayoutOrientation } from './types';
|
||
import { TerminalInstanceManager } from './core/TerminalInstanceManager';
|
||
import { getMinSplitSize, getMinSplitPercent } from './splitUtils';
|
||
|
||
interface UseSplitViewOptions {
|
||
initialTab: TerminalTab;
|
||
onWindowClose?: () => void; // 最后一个Tab关闭时的回调
|
||
}
|
||
|
||
export const useSplitView = ({ initialTab, onWindowClose }: UseSplitViewOptions) => {
|
||
const [layout, setLayout] = useState<SplitLayout>(() => ({
|
||
root: {
|
||
type: 'group',
|
||
id: 'group-1',
|
||
tabs: [initialTab],
|
||
activeTabId: initialTab.id,
|
||
size: 100,
|
||
} as EditorGroup,
|
||
}));
|
||
|
||
const [activeGroupId, setActiveGroupId] = useState('group-1');
|
||
|
||
// 在树中查找组
|
||
const findGroup = useCallback((node: SplitNode, groupId: string): EditorGroup | null => {
|
||
if (node.type === 'group') {
|
||
return node.id === groupId ? node : null;
|
||
}
|
||
for (const child of node.children) {
|
||
const found = findGroup(child, groupId);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}, []);
|
||
|
||
// 查找父容器和节点索引
|
||
const findParent = useCallback((
|
||
node: SplitNode,
|
||
targetId: string,
|
||
parent: SplitContainer | null = null,
|
||
index: number = 0
|
||
): { parent: SplitContainer | null; index: number; node: SplitNode } | null => {
|
||
if (node.id === targetId) {
|
||
return { parent, index, node };
|
||
}
|
||
if (node.type === 'container') {
|
||
for (let i = 0; i < node.children.length; i++) {
|
||
const result = findParent(node.children[i], targetId, node, i);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
return null;
|
||
}, []);
|
||
|
||
// 获取当前激活的组
|
||
const getActiveGroup = useCallback(() => {
|
||
return findGroup(layout.root, activeGroupId);
|
||
}, [layout.root, activeGroupId, findGroup]);
|
||
|
||
// 向指定方向拆分
|
||
// 辅助函数:安全地更新节点的 size,保持其他属性引用不变
|
||
const updateNodeSize = (node: SplitNode, newSize: number): SplitNode => {
|
||
if (node.size === newSize) {
|
||
return node; // size 没变,返回原对象
|
||
}
|
||
// size 变了,创建新对象,但保持其他属性引用
|
||
if (node.type === 'group') {
|
||
return {
|
||
...node,
|
||
size: newSize,
|
||
// tabs 数组引用保持不变
|
||
};
|
||
} else {
|
||
return {
|
||
...node,
|
||
size: newSize,
|
||
// children 数组引用保持不变
|
||
};
|
||
}
|
||
};
|
||
|
||
const splitToDirection = useCallback((direction: SplitDirection) => {
|
||
const activeGroup = getActiveGroup();
|
||
if (!activeGroup) return;
|
||
|
||
const activeTab = activeGroup.tabs.find(t => t.id === activeGroup.activeTabId);
|
||
if (!activeTab) return;
|
||
|
||
const newGroupId = `group-${Date.now()}`;
|
||
const newTab: TerminalTab = {
|
||
id: `tab-${Date.now()}`,
|
||
title: activeTab.title,
|
||
serverId: activeTab.serverId,
|
||
serverName: activeTab.serverName,
|
||
isActive: true,
|
||
};
|
||
|
||
const newGroup: EditorGroup = {
|
||
type: 'group',
|
||
id: newGroupId,
|
||
tabs: [newTab],
|
||
activeTabId: newTab.id,
|
||
size: 50,
|
||
};
|
||
|
||
setLayout(prev => {
|
||
const result = findParent(prev.root, activeGroup.id);
|
||
if (!result) return prev;
|
||
|
||
const { parent, index, node } = result;
|
||
const isHorizontalSplit = direction === 'left' || direction === 'right';
|
||
const newOrientation: LayoutOrientation = isHorizontalSplit ? 'horizontal' : 'vertical';
|
||
|
||
// 如果是根节点
|
||
if (!parent) {
|
||
const updatedNode = updateNodeSize(node, 50);
|
||
|
||
const container: SplitContainer = {
|
||
type: 'container',
|
||
id: `container-${Date.now()}`,
|
||
orientation: newOrientation,
|
||
children: direction === 'right' || direction === 'down'
|
||
? [updatedNode, newGroup]
|
||
: [newGroup, updatedNode],
|
||
size: 100,
|
||
};
|
||
return { root: container };
|
||
}
|
||
|
||
// 父容器方向一致:直接插入
|
||
if (parent.orientation === newOrientation) {
|
||
const newChildren = [...parent.children];
|
||
const insertIndex = direction === 'right' || direction === 'down' ? index + 1 : index;
|
||
newChildren.splice(insertIndex, 0, newGroup);
|
||
|
||
// 重新分配大小
|
||
const sizePerChild = 100 / newChildren.length;
|
||
const resizedChildren = newChildren.map(child => updateNodeSize(child, sizePerChild));
|
||
|
||
return {
|
||
root: updateNode(prev.root, parent.id, {
|
||
...parent,
|
||
children: resizedChildren,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// 父容器方向不一致:创建新容器包裹当前节点和新节点
|
||
const updatedNode = updateNodeSize(node, 50);
|
||
|
||
const newContainer: SplitContainer = {
|
||
type: 'container',
|
||
id: `container-${Date.now()}`,
|
||
orientation: newOrientation,
|
||
children: direction === 'right' || direction === 'down'
|
||
? [updatedNode, newGroup]
|
||
: [newGroup, updatedNode],
|
||
size: node.size,
|
||
};
|
||
|
||
const newChildren = [...parent.children];
|
||
newChildren[index] = newContainer;
|
||
|
||
return {
|
||
root: updateNode(prev.root, parent.id, {
|
||
...parent,
|
||
children: newChildren,
|
||
}),
|
||
};
|
||
});
|
||
|
||
setActiveGroupId(newGroupId);
|
||
}, [activeGroupId, getActiveGroup, findParent]);
|
||
|
||
// 更新节点(递归)- 优化:只在真正需要更新时才创建新对象
|
||
const updateNode = (node: SplitNode, targetId: string, newNode: SplitNode): SplitNode => {
|
||
if (node.id === targetId) {
|
||
return newNode;
|
||
}
|
||
if (node.type === 'container') {
|
||
// 递归更新子节点
|
||
const newChildren = node.children.map(child => updateNode(child, targetId, newNode));
|
||
|
||
// 检查是否真的有子节点被更新了
|
||
const hasChanged = newChildren.some((child, index) => child !== node.children[index]);
|
||
|
||
// 如果没有变化,返回原节点(保持引用不变)
|
||
if (!hasChanged) {
|
||
return node;
|
||
}
|
||
|
||
// 有变化才创建新对象
|
||
return {
|
||
...node,
|
||
children: newChildren,
|
||
};
|
||
}
|
||
return node;
|
||
};
|
||
|
||
// 在组中拆分(创建新标签页)
|
||
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;
|
||
}
|
||
|
||
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()}`,
|
||
title: activeTab.title,
|
||
serverId: activeTab.serverId,
|
||
serverName: activeTab.serverName,
|
||
isActive: true,
|
||
};
|
||
|
||
setLayout(prev => ({
|
||
root: updateNode(prev.root, targetGroup.id, {
|
||
...targetGroup,
|
||
tabs: [...targetGroup.tabs.map(t => ({ ...t, isActive: false })), newTab],
|
||
activeTabId: newTab.id,
|
||
}),
|
||
}));
|
||
|
||
// 更新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) {
|
||
console.log(`[switchTab] Group not found: ${groupId}`);
|
||
return prev;
|
||
}
|
||
|
||
return {
|
||
root: updateNode(prev.root, groupId, {
|
||
...group,
|
||
tabs: group.tabs.map(t => ({ ...t, isActive: t.id === tabId })),
|
||
activeTabId: tabId,
|
||
}),
|
||
};
|
||
});
|
||
}, [findGroup]);
|
||
|
||
// 关闭标签页
|
||
const closeTab = useCallback((groupId: string, tabId: string) => {
|
||
// 销毁 Terminal 实例
|
||
const manager = TerminalInstanceManager.getInstance();
|
||
manager.destroy(tabId);
|
||
console.log(`[useSplitView] 销毁 Terminal 实例: ${tabId}`);
|
||
|
||
setLayout(prev => {
|
||
const group = findGroup(prev.root, groupId);
|
||
if (!group) return prev;
|
||
|
||
if (group.tabs.length === 1) {
|
||
// 关闭整个组
|
||
return closeGroup(prev, groupId);
|
||
}
|
||
|
||
const newTabs = group.tabs.filter(t => t.id !== tabId);
|
||
const newActiveTabId = group.activeTabId === tabId ? newTabs[0].id : group.activeTabId;
|
||
|
||
// 更新 isActive 状态
|
||
const updatedTabs = newTabs.map(t => ({
|
||
...t,
|
||
isActive: t.id === newActiveTabId,
|
||
}));
|
||
|
||
return {
|
||
root: updateNode(prev.root, groupId, {
|
||
...group,
|
||
tabs: updatedTabs,
|
||
activeTabId: newActiveTabId,
|
||
}),
|
||
};
|
||
});
|
||
}, [findGroup]);
|
||
|
||
// 关闭组
|
||
const closeGroup = (prev: SplitLayout, groupId: string): SplitLayout => {
|
||
// 销毁组内所有 Terminal 实例
|
||
const group = findGroup(prev.root, groupId);
|
||
if (group) {
|
||
const manager = TerminalInstanceManager.getInstance();
|
||
group.tabs.forEach(tab => {
|
||
manager.destroy(tab.id);
|
||
console.log(`[useSplitView] 关闭组,销毁 Terminal 实例: ${tab.id}`);
|
||
});
|
||
}
|
||
|
||
const result = findParent(prev.root, groupId);
|
||
if (!result) return prev;
|
||
|
||
const { parent } = result;
|
||
if (!parent) {
|
||
// 根节点 且 只有一个Tab,关闭整个窗口
|
||
if (group && group.tabs.length === 1) {
|
||
console.log('[useSplitView] 最后一个Tab,关闭整个窗口');
|
||
onWindowClose?.();
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
const newChildren = parent.children.filter(c => c.id !== groupId);
|
||
if (newChildren.length === 0) return prev;
|
||
|
||
if (newChildren.length === 1) {
|
||
// 只剩一个子节点,提升它
|
||
const child = newChildren[0];
|
||
const grandParentResult = findParent(prev.root, parent.id);
|
||
|
||
if (!grandParentResult || !grandParentResult.parent) {
|
||
// 父容器是根节点,提升子节点为新根
|
||
return { root: { ...child, size: 100 } };
|
||
}
|
||
|
||
// 用子节点替换父容器
|
||
const grandParent = grandParentResult.parent;
|
||
const newGrandChildren = grandParent.children.map(c =>
|
||
c.id === parent.id ? { ...child, size: parent.size } : c
|
||
);
|
||
|
||
return {
|
||
root: updateNode(prev.root, grandParent.id, {
|
||
...grandParent,
|
||
children: newGrandChildren,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// 重新分配大小
|
||
const sizePerChild = 100 / newChildren.length;
|
||
newChildren.forEach(child => { child.size = sizePerChild; });
|
||
|
||
return {
|
||
root: updateNode(prev.root, parent.id, {
|
||
...parent,
|
||
children: newChildren,
|
||
}),
|
||
};
|
||
};
|
||
|
||
// 调整分屏大小
|
||
const resizeGroups = useCallback((nodeId: string, delta: number, containerSize?: number) => {
|
||
setLayout(prev => {
|
||
const result = findParent(prev.root, nodeId);
|
||
if (!result || !result.parent) return prev;
|
||
|
||
const { parent, index } = result;
|
||
if (index >= parent.children.length - 1) return prev;
|
||
|
||
const current = parent.children[index];
|
||
const next = parent.children[index + 1];
|
||
const totalSize = current.size + next.size;
|
||
|
||
// 将像素delta转换为百分比delta
|
||
// delta是像素值,需要转换为百分比:(delta / containerSize) * totalSize
|
||
const deltaPercent = containerSize ? (delta / containerSize) * totalSize : delta;
|
||
|
||
// 计算最小尺寸百分比:根据方向和容器尺寸计算
|
||
let minSizePercent = totalSize * 0.2; // 默认20%
|
||
if (containerSize && parent.type === 'container') {
|
||
const minPixels = getMinSplitSize(containerSize, parent.orientation);
|
||
minSizePercent = getMinSplitPercent(minPixels, containerSize, totalSize);
|
||
}
|
||
|
||
const newCurrentSize = Math.max(minSizePercent, Math.min(totalSize - minSizePercent, current.size + deltaPercent));
|
||
const newNextSize = totalSize - newCurrentSize;
|
||
|
||
const newChildren = parent.children.map((child, i) => {
|
||
if (i === index) return { ...child, size: newCurrentSize };
|
||
if (i === index + 1) return { ...child, size: newNextSize };
|
||
return child;
|
||
});
|
||
|
||
return {
|
||
root: updateNode(prev.root, parent.id, {
|
||
...parent,
|
||
children: newChildren,
|
||
}),
|
||
};
|
||
});
|
||
}, [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,
|
||
splitUp: () => splitToDirection('up'),
|
||
splitDown: () => splitToDirection('down'),
|
||
splitLeft: () => splitToDirection('left'),
|
||
splitRight: () => splitToDirection('right'),
|
||
splitInGroup,
|
||
switchTab,
|
||
closeTab,
|
||
closeAll,
|
||
resizeGroups,
|
||
setActiveGroupId,
|
||
};
|
||
};
|