Files
claudia/src/components/Terminal.tsx
2025-10-13 22:58:21 +08:00

421 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal as XTerm } from 'xterm';
import { WebLinksAddon } from 'xterm-addon-web-links';
import { SearchAddon } from 'xterm-addon-search';
import 'xterm/css/xterm.css';
import { Button } from '@/components/ui/button';
import { X, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
interface TerminalProps {
className?: string;
onClose?: () => void;
isMaximized?: boolean;
onToggleMaximize?: () => void;
projectPath?: string;
}
export const Terminal: React.FC<TerminalProps> = ({
className,
onClose,
isMaximized = false,
onToggleMaximize,
projectPath
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const isInitializedRef = useRef(false);
const unlistenRef = useRef<(() => void) | null>(null);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [terminalSize, setTerminalSize] = useState({ cols: 80, rows: 24 });
const [containerWidth, setContainerWidth] = useState(0);
// 计算终端应该有的尺寸
const calculateOptimalSize = useCallback(() => {
if (!terminalRef.current) return { cols: 80, rows: 24 };
const container = terminalRef.current;
const rect = container.getBoundingClientRect();
// 获取或估算字符尺寸
const fontSize = 14; // 我们设置的字体大小
const charWidth = fontSize * 0.6; // 等宽字体的典型宽度比例
const lineHeight = fontSize * 1.2; // 行高
// 计算能容纳的最大列数和行数
const availableWidth = rect.width - 2;
const availableHeight = rect.height - 2;
const cols = Math.max(80, Math.floor(availableWidth / charWidth));
const rows = Math.max(24, Math.floor(availableHeight / lineHeight));
// 计算实际使用的宽度
return { cols, rows };
}, []);
// 调整终端大小
const resizeTerminal = useCallback(() => {
if (!xtermRef.current || !terminalRef.current) return;
// 先尝试获取实际的字符尺寸
let actualCharWidth = 8.4; // 默认值
let actualLineHeight = 16.8; // 默认值
try {
const core = (xtermRef.current as any)._core;
if (core && core._renderService && core._renderService.dimensions) {
const dims = core._renderService.dimensions;
if (dims.actualCellWidth) actualCharWidth = dims.actualCellWidth;
if (dims.actualCellHeight) actualLineHeight = dims.actualCellHeight;
}
} catch (e) {
// 使用默认值
}
// 使用实际字符尺寸计算新的列数和行数
const rect = terminalRef.current.getBoundingClientRect();
const availableWidth = rect.width - 2;
const availableHeight = rect.height - 2;
// 更新容器宽度显示
setContainerWidth(rect.width);
const newCols = Math.max(80, Math.floor(availableWidth / actualCharWidth));
const newRows = Math.max(24, Math.floor(availableHeight / actualLineHeight));
// 计算宽度使用情况
// 只有当尺寸真的改变时才调整
if (newCols !== terminalSize.cols || newRows !== terminalSize.rows) {
setTerminalSize({ cols: newCols, rows: newRows });
xtermRef.current.resize(newCols, newRows);
// 更新后端
if (sessionId) {
api.resizeTerminal(sessionId, newCols, newRows).catch(console.error);
}
// 强制刷新渲染
try {
const core = (xtermRef.current as any)._core;
if (core && core._renderService && core._renderService._onIntersectionChange) {
core._renderService._onIntersectionChange({ intersectionRatio: 1 });
}
} catch (e) {
// 忽略错误,某些版本的 xterm 可能没有这个方法
}
}
}, [terminalSize, sessionId]);
// 防抖的resize处理
const handleResize = useCallback(() => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
resizeTerminal();
}, 100);
}, [resizeTerminal]);
// 初始化终端
useEffect(() => {
if (isInitializedRef.current || !terminalRef.current) return;
let isMounted = true;
const initializeTerminal = async () => {
try {
isInitializedRef.current = true;
// 先计算初始尺寸
const initialSize = calculateOptimalSize();
setTerminalSize(initialSize);
// 创建终端实例
const xterm = new XTerm({
cols: initialSize.cols,
rows: initialSize.rows,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: '#264f78',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
},
fontFamily: '"MesloLGS NF", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", "JetBrains Mono", "SF Mono", "Monaco", "Consolas", "Courier New", monospace',
fontSize: 14,
fontWeight: 'normal',
fontWeightBold: 'bold',
lineHeight: 1.2,
letterSpacing: 0,
scrollback: 10000,
convertEol: true,
cursorBlink: true,
cursorStyle: 'block',
drawBoldTextInBrightColors: true,
macOptionIsMeta: true,
rightClickSelectsWord: true,
allowProposedApi: true,
// @ts-ignore
rendererType: 'canvas',
windowsMode: false,
});
// 添加插件
const webLinksAddon = new WebLinksAddon();
const searchAddon = new SearchAddon();
xterm.loadAddon(webLinksAddon);
xterm.loadAddon(searchAddon);
// 打开终端
if (terminalRef.current) {
xterm.open(terminalRef.current);
} else {
console.error('[Terminal] Terminal container ref is null');
return;
}
// 保存引用
xtermRef.current = xterm;
// 延迟一下确保渲染完成,然后获取实际字符尺寸并调整
setTimeout(() => {
if (xtermRef.current && terminalRef.current) {
// 尝试获取实际的字符尺寸
try {
const core = (xtermRef.current as any)._core;
if (core && core._renderService && core._renderService.dimensions) {
const dims = core._renderService.dimensions;
const actualCharWidth = dims.actualCellWidth || dims.scaledCellWidth;
const actualLineHeight = dims.actualCellHeight || dims.scaledCellHeight;
if (actualCharWidth && actualLineHeight) {
// 使用实际尺寸重新计算
const rect = terminalRef.current.getBoundingClientRect();
const availableWidth = rect.width - 2;
const newCols = Math.floor(availableWidth / actualCharWidth);
if (newCols > xtermRef.current.cols) {
xtermRef.current.resize(newCols, xtermRef.current.rows);
setTerminalSize({ cols: newCols, rows: xtermRef.current.rows });
}
}
}
} catch (e) {
console.warn('[Terminal] Could not get actual char dimensions:', e);
}
}
resizeTerminal();
}, 150);
// 如果没有有效的 projectPath,跳过创建终端会话
if (!projectPath || projectPath.trim() === '') {
if (xtermRef.current) {
xtermRef.current.write('\r\n\x1b[33mNo project directory selected. Please select a project to use the terminal.\x1b[0m\r\n');
}
return;
}
// 创建终端会话
const newSessionId = await api.createTerminalSession(projectPath);
if (!isMounted) {
await api.closeTerminalSession(newSessionId);
return;
}
setSessionId(newSessionId);
setIsConnected(true);
// 监听终端输出
const unlisten = await api.listenToTerminalOutput(newSessionId, (data: string) => {
if (xtermRef.current && isMounted) {
xtermRef.current.write(data);
}
});
unlistenRef.current = unlisten;
// 监听数据输入
xterm.onData((data) => {
if (newSessionId && isMounted) {
api.sendTerminalInput(newSessionId, data).catch((error) => {
console.error('[Terminal] Failed to send input:', error);
});
}
});
} catch (error) {
console.error('[Terminal] Failed to initialize:', error);
if (xtermRef.current && isMounted) {
xtermRef.current.write('\r\n\x1b[31mFailed to start terminal session\x1b[0m\r\n');
}
}
};
initializeTerminal();
return () => {
isMounted = false;
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
if (sessionId) {
api.closeTerminalSession(sessionId).catch(console.error);
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
isInitializedRef.current = false;
setTimeout(() => {
api.cleanupTerminalSessions().catch(console.error);
}, 1000);
};
}, []); // 只运行一次
// 监听容器大小变化
useEffect(() => {
if (!terminalRef.current) return;
// 初始化时立即获取容器宽度
const rect = terminalRef.current.getBoundingClientRect();
setContainerWidth(rect.width);
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
setContainerWidth(width);
}
handleResize();
});
resizeObserver.observe(terminalRef.current);
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
// 最大化状态改变时调整大小
useEffect(() => {
handleResize();
}, [isMaximized, handleResize]);
return (
<div className={cn('flex flex-col h-full w-full bg-[#1e1e1e]', className)}>
{/* 终端头部 */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-900 border-b border-gray-700">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
isConnected ? 'bg-green-500' : 'bg-red-500'
)} />
<span className="text-sm text-gray-300">
Terminal {sessionId ? `(${sessionId.slice(0, 8)})` : ''}
</span>
</div>
{projectPath && (
<span className="text-xs text-gray-500">
{projectPath}
</span>
)}
<span className="text-xs text-gray-400">
{terminalSize.cols}×{terminalSize.rows}
</span>
<span className="text-xs text-yellow-400">
: {containerWidth.toFixed(0)}px
</span>
</div>
<div className="flex items-center gap-1">
{onToggleMaximize && (
<Button
variant="ghost"
size="icon"
onClick={onToggleMaximize}
className="h-6 w-6 text-gray-400 hover:text-white"
>
{isMaximized ? (
<Minimize2 className="h-3 w-3" />
) : (
<Maximize2 className="h-3 w-3" />
)}
</Button>
)}
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-6 w-6 text-gray-400 hover:text-white hover:bg-red-600"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 终端主体 */}
<div className="flex-1 relative overflow-hidden">
<div
ref={terminalRef}
className="absolute inset-0 p-1"
style={{
backgroundColor: '#1e1e1e',
}}
/>
{!isConnected && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2" />
<p className="text-gray-300 text-sm">...</p>
</div>
</div>
)}
</div>
</div>
);
};
export default Terminal;