增加终端
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Terminal,
|
||||
Terminal as TerminalIcon,
|
||||
FolderOpen,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
FileText,
|
||||
FilePlus,
|
||||
FileX,
|
||||
Clock
|
||||
Clock,
|
||||
Square
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -64,6 +65,7 @@ import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer";
|
||||
import { MainContentArea } from "@/components/layout/MainContentArea";
|
||||
import { SidePanel } from "@/components/layout/SidePanel";
|
||||
import { ChatView } from "@/components/layout/ChatView";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
|
||||
interface ClaudeCodeSessionProps {
|
||||
/**
|
||||
@@ -120,7 +122,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
openFileEditor,
|
||||
closeFileEditor,
|
||||
openPreview: openLayoutPreview,
|
||||
closePreview: closeLayoutPreview
|
||||
closePreview: closeLayoutPreview,
|
||||
openTerminal,
|
||||
closeTerminal,
|
||||
toggleTerminalMaximize
|
||||
} = layoutManager;
|
||||
|
||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||
@@ -1389,7 +1394,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
<AnimatePresence>
|
||||
{displayableMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
|
||||
<Terminal className="h-12 w-12 mb-3 opacity-50" />
|
||||
<TerminalIcon className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">开始对话或等待消息加载...</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1560,6 +1565,29 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// If terminal is maximized, render only the Terminal in full screen
|
||||
if (layout.activeView === 'terminal' && layout.isTerminalMaximized) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-background"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Terminal
|
||||
onClose={closeTerminal}
|
||||
isMaximized={layout.isTerminalMaximized}
|
||||
onToggleMaximize={toggleTerminalMaximize}
|
||||
projectPath={projectPath}
|
||||
className="h-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// If preview is maximized, render only the WebviewPreview in full screen
|
||||
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
||||
return (
|
||||
@@ -1604,7 +1632,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-muted-foreground" />
|
||||
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -1624,6 +1652,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal Toggle */}
|
||||
{projectPath && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openTerminal}
|
||||
className={cn("h-8 w-8", layout.activeView === 'terminal' && "text-primary")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>终端</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* File Explorer Toggle */}
|
||||
{projectPath && (
|
||||
<TooltipProvider>
|
||||
@@ -1840,7 +1889,16 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
visible: true,
|
||||
content: (
|
||||
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
||||
{layout.activeView === 'editor' && layout.editingFile ? (
|
||||
{layout.activeView === 'terminal' ? (
|
||||
// 终端视图
|
||||
<Terminal
|
||||
onClose={closeTerminal}
|
||||
isMaximized={layout.isTerminalMaximized}
|
||||
onToggleMaximize={toggleTerminalMaximize}
|
||||
projectPath={projectPath}
|
||||
className="h-full"
|
||||
/>
|
||||
) : layout.activeView === 'editor' && layout.editingFile ? (
|
||||
// 文件编辑器视图
|
||||
<FileEditorEnhanced
|
||||
filePath={layout.editingFile}
|
||||
|
263
src/components/Terminal.tsx
Normal file
263
src/components/Terminal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
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 fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
// 调整终端大小
|
||||
const handleResize = useCallback(() => {
|
||||
if (fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch (error) {
|
||||
console.warn('Terminal resize failed:', error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化和启动终端 - 只运行一次
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current || !terminalRef.current) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
try {
|
||||
console.log('Initializing terminal...');
|
||||
isInitializedRef.current = true;
|
||||
|
||||
// 创建终端实例
|
||||
const xterm = new XTerm({
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#404040',
|
||||
},
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Fira Code", "Source Code Pro", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowTransparency: true,
|
||||
scrollback: 1000,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
});
|
||||
|
||||
// 添加插件
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
|
||||
xterm.loadAddon(fitAddon);
|
||||
xterm.loadAddon(webLinksAddon);
|
||||
xterm.loadAddon(searchAddon);
|
||||
|
||||
// 打开终端
|
||||
if (terminalRef.current) {
|
||||
xterm.open(terminalRef.current);
|
||||
}
|
||||
|
||||
// 适应容器大小
|
||||
setTimeout(() => fitAddon.fit(), 100);
|
||||
|
||||
// 存储引用
|
||||
xtermRef.current = xterm;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// 创建终端会话
|
||||
const newSessionId = await api.createTerminalSession(projectPath || process.cwd());
|
||||
|
||||
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;
|
||||
|
||||
// 监听数据输入
|
||||
// 使用PTY后,shell会自动处理回显
|
||||
xterm.onData((data) => {
|
||||
console.log('Terminal onData received:', JSON.stringify(data), 'Session ID:', newSessionId);
|
||||
if (newSessionId && isMounted) {
|
||||
// 直接发送数据到PTY,PTY会处理回显
|
||||
api.sendTerminalInput(newSessionId, data).catch((error) => {
|
||||
console.error('Failed to send terminal input:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Terminal initialized with session:', newSessionId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize terminal:', 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 (unlistenRef.current) {
|
||||
unlistenRef.current();
|
||||
unlistenRef.current = null;
|
||||
}
|
||||
|
||||
// 关闭会话
|
||||
if (sessionId) {
|
||||
api.closeTerminalSession(sessionId).catch(console.error);
|
||||
}
|
||||
|
||||
// 清理终端实例
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
|
||||
fitAddonRef.current = null;
|
||||
isInitializedRef.current = false;
|
||||
|
||||
// 清理孤儿会话
|
||||
setTimeout(() => {
|
||||
api.cleanupTerminalSessions().catch(console.error);
|
||||
}, 1000);
|
||||
};
|
||||
}, []); // 空依赖数组 - 只运行一次
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => handleResize();
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// 当最大化状态改变时调整大小
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [isMaximized, handleResize]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-black', 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>
|
||||
)}
|
||||
</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">
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="absolute inset-0 p-2"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
|
||||
{!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;
|
Reference in New Issue
Block a user