增加终端

This commit is contained in:
2025-08-15 00:29:57 +08:00
parent 96eb05856e
commit 4588c89557
10 changed files with 888 additions and 12 deletions

View File

@@ -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
View 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) {
// 直接发送数据到PTYPTY会处理回显
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;

View File

@@ -9,9 +9,10 @@ interface LayoutState {
showTimeline: boolean;
splitPosition: number;
isCompactMode: boolean;
activeView: 'chat' | 'editor' | 'preview'; // 新增:当前活动视图
activeView: 'chat' | 'editor' | 'preview' | 'terminal'; // 新增终端视图
editingFile: string | null; // 新增:正在编辑的文件
previewUrl: string | null; // 新增预览URL
isTerminalMaximized: boolean; // 新增:终端是否最大化
}
interface LayoutBreakpoints {
@@ -35,6 +36,7 @@ const DEFAULT_LAYOUT: LayoutState = {
activeView: 'chat', // 默认显示聊天视图
editingFile: null,
previewUrl: null,
isTerminalMaximized: false, // 默认终端不最大化
};
const STORAGE_KEY = 'claudia_layout_preferences';
@@ -298,6 +300,29 @@ export function useLayoutManager(projectPath?: string) {
previewUrl: null,
});
}, [saveLayout]);
// 打开终端
const openTerminal = useCallback(() => {
saveLayout({
activeView: 'terminal',
editingFile: null,
previewUrl: null,
});
}, [saveLayout]);
// 关闭终端
const closeTerminal = useCallback(() => {
saveLayout({
activeView: 'chat',
});
}, [saveLayout]);
// 切换终端最大化状态
const toggleTerminalMaximize = useCallback(() => {
saveLayout({
isTerminalMaximized: !layout.isTerminalMaximized,
});
}, [layout.isTerminalMaximized, saveLayout]);
return {
layout,
@@ -319,5 +344,9 @@ export function useLayoutManager(projectPath?: string) {
openPreview,
closePreview,
switchToChatView,
// 终端相关方法
openTerminal,
closeTerminal,
toggleTerminalMaximize,
};
}

View File

@@ -2512,5 +2512,111 @@ export const api = {
console.error("Failed to unwatch Claude project directory:", error);
throw error;
}
},
// ============= Terminal API =============
/**
* Creates a new terminal session using Zellij
* @param workingDirectory - The working directory for the terminal session
* @returns Promise resolving to the session ID
*/
async createTerminalSession(workingDirectory: string): Promise<string> {
try {
return await invoke<string>("create_terminal_session", { workingDirectory });
} catch (error) {
console.error("Failed to create terminal session:", error);
throw error;
}
},
/**
* Sends input to a terminal session
* @param sessionId - The terminal session ID
* @param input - The input data to send
* @returns Promise resolving when input is sent
*/
async sendTerminalInput(sessionId: string, input: string): Promise<void> {
try {
return await invoke<void>("send_terminal_input", { sessionId, input });
} catch (error) {
console.error("Failed to send terminal input:", error);
throw error;
}
},
/**
* Listen to terminal output for a session
* @param sessionId - The terminal session ID
* @param callback - Callback function to handle output
* @returns Promise resolving to unlisten function
*/
async listenToTerminalOutput(sessionId: string, callback: (data: string) => void): Promise<() => void> {
try {
const { listen } = await import("@tauri-apps/api/event");
const unlisten = await listen<string>(`terminal-output:${sessionId}`, (event) => {
callback(event.payload);
});
return unlisten;
} catch (error) {
console.error("Failed to listen to terminal output:", error);
throw error;
}
},
/**
* Closes a terminal session
* @param sessionId - The terminal session ID to close
* @returns Promise resolving when session is closed
*/
async closeTerminalSession(sessionId: string): Promise<void> {
try {
return await invoke<void>("close_terminal_session", { sessionId });
} catch (error) {
console.error("Failed to close terminal session:", error);
throw error;
}
},
/**
* Lists all active terminal sessions
* @returns Promise resolving to array of active terminal session IDs
*/
async listTerminalSessions(): Promise<string[]> {
try {
return await invoke<string[]>("list_terminal_sessions");
} catch (error) {
console.error("Failed to list terminal sessions:", error);
throw error;
}
},
/**
* Resizes a terminal session
* @param sessionId - The terminal session ID
* @param cols - Number of columns
* @param rows - Number of rows
* @returns Promise resolving when resize is complete
*/
async resizeTerminal(sessionId: string, cols: number, rows: number): Promise<void> {
try {
return await invoke<void>("resize_terminal", { sessionId, cols, rows });
} catch (error) {
console.error("Failed to resize terminal:", error);
throw error;
}
},
/**
* Cleanup orphaned terminal sessions
* @returns Promise resolving to the number of sessions cleaned up
*/
async cleanupTerminalSessions(): Promise<number> {
try {
return await invoke<number>("cleanup_terminal_sessions");
} catch (error) {
console.error("Failed to cleanup terminal sessions:", error);
throw error;
}
}
};