diff --git a/package.json b/package.json index d395cf1..f769751 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,10 @@ "remark-gfm": "^4.0.0", "tailwind-merge": "^2.6.0", "tailwindcss": "^4.1.8", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", + "xterm-addon-search": "^0.13.0", + "xterm-addon-web-links": "^0.9.0", "zod": "^3.24.1", "zustand": "^5.0.6" }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc44b11..51f9127 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -736,6 +736,7 @@ dependencies = [ "notify", "objc", "once_cell", + "portable-pty", "regex", "reqwest", "rusqlite", @@ -1250,7 +1251,7 @@ dependencies = [ "rustc_version", "toml", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1419,10 +1420,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.25" @@ -2462,6 +2474,15 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2846,6 +2867,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2980,6 +3010,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.30.1" @@ -2990,7 +3034,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3743,6 +3787,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.25.1", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4655,6 +4720,48 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.1" @@ -4708,6 +4815,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -5494,6 +5617,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thin-slice" version = "0.1.1" @@ -5879,7 +6011,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -6874,6 +7006,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -7075,7 +7216,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be9553a..ff1ceb7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,7 @@ reqwest = { version = "0.12", features = ["json", "native-tls-vendored"] } futures = "0.3" async-trait = "0.1" tempfile = "3" +portable-pty = "0.8" which = "7" sha2 = "0.10" zstd = "0.13" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f34f95d..03cc1fb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,3 +13,4 @@ pub mod relay_adapters; pub mod packycode_nodes; pub mod filesystem; pub mod git; +pub mod terminal; diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs new file mode 100644 index 0000000..b0245c5 --- /dev/null +++ b/src-tauri/src/commands/terminal.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::Mutex; +use uuid::Uuid; +use anyhow::Result; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::io::{Read, Write}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalSession { + pub id: String, + pub working_directory: String, + pub created_at: chrono::DateTime, + pub is_active: bool, +} + +/// Terminal child process wrapper +pub struct TerminalChild { + writer: Arc>>, +} + +/// State for managing terminal sessions +pub type TerminalState = Arc)>>>; + +/// Creates a new terminal session using PTY +#[tauri::command] +pub async fn create_terminal_session( + working_directory: String, + app_handle: AppHandle, + terminal_state: State<'_, TerminalState>, +) -> Result { + let session_id = Uuid::new_v4().to_string(); + + log::info!("Creating terminal session: {} in {}", session_id, working_directory); + + // Check if working directory exists + if !std::path::Path::new(&working_directory).exists() { + return Err(format!("Working directory does not exist: {}", working_directory)); + } + + let session = TerminalSession { + id: session_id.clone(), + working_directory: working_directory.clone(), + created_at: chrono::Utc::now(), + is_active: true, + }; + + // Create PTY system + let pty_system = native_pty_system(); + + // Create PTY pair with size + let pty_pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }).map_err(|e| format!("Failed to create PTY: {}", e))?; + + // Get shell command + let shell = get_default_shell(); + let mut cmd = CommandBuilder::new(&shell); + + // Set as login interactive shell + if shell.contains("bash") || shell.contains("zsh") { + cmd.arg("-il"); // Interactive login shell + } else if shell.contains("fish") { + cmd.arg("-il"); + } + + // Set working directory + cmd.cwd(working_directory.clone()); + + // Set environment variables + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + // Spawn the shell process + let _child = pty_pair.slave.spawn_command(cmd) + .map_err(|e| format!("Failed to spawn shell: {}", e))?; + + // Get writer for stdin + let writer = pty_pair.master.take_writer() + .map_err(|e| format!("Failed to get PTY writer: {}", e))?; + + // Start reading output in background + let session_id_clone = session_id.clone(); + let app_handle_clone = app_handle.clone(); + let mut reader = pty_pair.master.try_clone_reader() + .map_err(|e| format!("Failed to get PTY reader: {}", e))?; + + // Spawn reader thread + std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, // EOF + Ok(n) => { + let data = String::from_utf8_lossy(&buffer[..n]).to_string(); + let _ = app_handle_clone.emit(&format!("terminal-output:{}", session_id_clone), &data); + } + Err(e) => { + log::error!("Error reading PTY output: {}", e); + break; + } + } + } + log::debug!("PTY reader thread finished for session: {}", session_id_clone); + }); + + // Store the session with PTY writer + let terminal_child = TerminalChild { + writer: Arc::new(Mutex::new(writer)), + }; + + { + let mut state = terminal_state.lock().await; + state.insert(session_id.clone(), (session, Some(terminal_child))); + } + + log::info!("Terminal session created successfully: {}", session_id); + Ok(session_id) +} + +/// Sends input to a terminal session +#[tauri::command] +pub async fn send_terminal_input( + session_id: String, + input: String, + terminal_state: State<'_, TerminalState>, +) -> Result<(), String> { + let state = terminal_state.lock().await; + + if let Some((_session, child_opt)) = state.get(&session_id) { + if let Some(child) = child_opt { + log::debug!("Sending input to terminal {}: {:?}", session_id, input); + + // Write to PTY + let mut writer = child.writer.lock().await; + writer.write_all(input.as_bytes()) + .map_err(|e| format!("Failed to write to terminal: {}", e))?; + writer.flush() + .map_err(|e| format!("Failed to flush terminal input: {}", e))?; + return Ok(()); + } + } + + Err(format!("Terminal session not found or not active: {}", session_id)) +} + +/// Closes a terminal session +#[tauri::command] +pub async fn close_terminal_session( + session_id: String, + terminal_state: State<'_, TerminalState>, +) -> Result<(), String> { + let mut state = terminal_state.lock().await; + + if let Some((mut session, _child)) = state.remove(&session_id) { + session.is_active = false; + // PTY and child process will be dropped automatically + + log::info!("Closed terminal session: {}", session_id); + Ok(()) + } else { + Err(format!("Terminal session not found: {}", session_id)) + } +} + +/// Lists all active terminal sessions +#[tauri::command] +pub async fn list_terminal_sessions( + terminal_state: State<'_, TerminalState>, +) -> Result, String> { + let state = terminal_state.lock().await; + + let sessions: Vec = state.iter() + .filter_map(|(id, (session, _))| { + if session.is_active { + Some(id.clone()) + } else { + None + } + }) + .collect(); + + Ok(sessions) +} + +/// Resizes a terminal session +#[tauri::command] +pub async fn resize_terminal( + session_id: String, + _cols: u16, + _rows: u16, + _terminal_state: State<'_, TerminalState>, +) -> Result<(), String> { + // Note: With the current architecture, resize is not supported + // To support resize, we would need to keep a reference to the PTY master + // or use a different approach + log::warn!("Terminal resize not currently supported for session: {}", session_id); + Ok(()) +} + +/// Cleanup orphaned terminal sessions +#[tauri::command] +pub async fn cleanup_terminal_sessions( + terminal_state: State<'_, TerminalState>, +) -> Result { + let mut state = terminal_state.lock().await; + let mut cleaned_up = 0; + + let mut to_remove = Vec::new(); + + for (id, (session, _child)) in state.iter() { + if !session.is_active { + to_remove.push(id.clone()); + cleaned_up += 1; + } + } + + // Remove the sessions + for id in to_remove { + state.remove(&id); + } + + if cleaned_up > 0 { + log::info!("Cleaned up {} orphaned terminal sessions", cleaned_up); + } + + Ok(cleaned_up) +} + +/// Get the default shell for the current platform +fn get_default_shell() -> String { + if cfg!(target_os = "windows") { + // Try PowerShell first, fallback to cmd + if std::process::Command::new("pwsh").arg("--version").output().is_ok() { + "pwsh".to_string() + } else if std::process::Command::new("powershell").arg("-Version").output().is_ok() { + "powershell".to_string() + } else { + "cmd".to_string() + } + } else { + // Unix-like systems: try zsh, bash, then sh + std::env::var("SHELL").unwrap_or_else(|_| { + if std::path::Path::new("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else if std::path::Path::new("/bin/bash").exists() { + "/bin/bash".to_string() + } else { + "/bin/sh".to_string() + } + }) + } +} \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d03d7a..aebca31 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -75,6 +75,10 @@ use commands::filesystem::{ use commands::git::{ get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits, }; +use commands::terminal::{ + create_terminal_session, send_terminal_input, close_terminal_session, + list_terminal_sessions, resize_terminal, cleanup_terminal_sessions, TerminalState, +}; use process::ProcessRegistryState; use file_watcher::FileWatcherState; use std::sync::Mutex; @@ -220,6 +224,9 @@ fn main() { app.manage(UsageIndexState::default()); app.manage(UsageCacheState::default()); + // Initialize Terminal state + app.manage(TerminalState::default()); + // Optionally auto-open DevTools if env var is set (works in packaged builds) if std::env::var("TAURI_OPEN_DEVTOOLS").ok().as_deref() == Some("1") { if let Some(win) = app.get_webview_window("main") { @@ -402,6 +409,14 @@ fn main() { get_git_branches, get_git_diff, get_git_commits, + + // Terminal + create_terminal_session, + send_terminal_input, + close_terminal_session, + list_terminal_sessions, + resize_terminal, + cleanup_terminal_sessions, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 6ee93d8..f43d374 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -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 = ({ 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 = ({ {displayableMessages.length === 0 ? (
- +

开始对话或等待消息加载...

) : ( @@ -1560,6 +1565,29 @@ export const ClaudeCodeSession: React.FC = ({ ); + // If terminal is maximized, render only the Terminal in full screen + if (layout.activeView === 'terminal' && layout.isTerminalMaximized) { + return ( + + + + + + ); + } + // 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 = ({
- +

{t('app.claudeCodeSession')}

@@ -1624,6 +1652,27 @@ export const ClaudeCodeSession: React.FC = ({

)} + {/* Terminal Toggle */} + {projectPath && ( + + + + + + +

终端

+
+
+
+ )} + {/* File Explorer Toggle */} {projectPath && ( @@ -1840,7 +1889,16 @@ export const ClaudeCodeSession: React.FC = ({ visible: true, content: ( - {layout.activeView === 'editor' && layout.editingFile ? ( + {layout.activeView === 'terminal' ? ( + // 终端视图 + + ) : layout.activeView === 'editor' && layout.editingFile ? ( // 文件编辑器视图 void; + isMaximized?: boolean; + onToggleMaximize?: () => void; + projectPath?: string; +} + +export const Terminal: React.FC = ({ + className, + onClose, + isMaximized = false, + onToggleMaximize, + projectPath +}) => { + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const isInitializedRef = useRef(false); + const unlistenRef = useRef<(() => void) | null>(null); + + const [isConnected, setIsConnected] = useState(false); + const [sessionId, setSessionId] = useState(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 ( +
+ {/* 终端头部 */} +
+
+
+
+ + Terminal {sessionId ? `(${sessionId.slice(0, 8)})` : ''} + +
+ {projectPath && ( + + {projectPath} + + )} +
+ +
+ {onToggleMaximize && ( + + )} + {onClose && ( + + )} +
+
+ + {/* 终端主体 */} +
+
+ + {!isConnected && ( +
+
+
+

正在连接终端...

+
+
+ )} +
+
+ ); +}; + +export default Terminal; \ No newline at end of file diff --git a/src/hooks/useLayoutManager.ts b/src/hooks/useLayoutManager.ts index 856ee0d..9affe7a 100644 --- a/src/hooks/useLayoutManager.ts +++ b/src/hooks/useLayoutManager.ts @@ -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, }; } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 6c72f55..12d0571 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + try { + return await invoke("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 { + try { + return await invoke("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(`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 { + try { + return await invoke("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 { + try { + return await invoke("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 { + try { + return await invoke("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 { + try { + return await invoke("cleanup_terminal_sessions"); + } catch (error) { + console.error("Failed to cleanup terminal sessions:", error); + throw error; + } } };