增加终端
This commit is contained in:
@@ -60,6 +60,10 @@
|
|||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"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",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
|
151
src-tauri/Cargo.lock
generated
151
src-tauri/Cargo.lock
generated
@@ -736,6 +736,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"portable-pty",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1250,7 +1251,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml",
|
"toml",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1419,10 +1420,21 @@ version = "0.3.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.25"
|
version = "0.2.25"
|
||||||
@@ -2462,6 +2474,15 @@ dependencies = [
|
|||||||
"unic-langid",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2846,6 +2867,15 @@ version = "2.7.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2980,6 +3010,20 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2990,7 +3034,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3743,6 +3787,27 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -4655,6 +4720,48 @@ dependencies = [
|
|||||||
"unsafe-libyaml",
|
"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]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -4708,6 +4815,22 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -5494,6 +5617,15 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termios"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-slice"
|
name = "thin-slice"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -5879,7 +6011,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -6874,6 +7006,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -7075,7 +7216,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
@@ -48,6 +48,7 @@ reqwest = { version = "0.12", features = ["json", "native-tls-vendored"] }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
portable-pty = "0.8"
|
||||||
which = "7"
|
which = "7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
|
@@ -13,3 +13,4 @@ pub mod relay_adapters;
|
|||||||
pub mod packycode_nodes;
|
pub mod packycode_nodes;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod terminal;
|
||||||
|
258
src-tauri/src/commands/terminal.rs
Normal file
258
src-tauri/src/commands/terminal.rs
Normal file
@@ -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<chrono::Utc>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal child process wrapper
|
||||||
|
pub struct TerminalChild {
|
||||||
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for managing terminal sessions
|
||||||
|
pub type TerminalState = Arc<Mutex<HashMap<String, (TerminalSession, Option<TerminalChild>)>>>;
|
||||||
|
|
||||||
|
/// 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<String, String> {
|
||||||
|
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<Vec<String>, String> {
|
||||||
|
let state = terminal_state.lock().await;
|
||||||
|
|
||||||
|
let sessions: Vec<String> = 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<u32, String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -75,6 +75,10 @@ use commands::filesystem::{
|
|||||||
use commands::git::{
|
use commands::git::{
|
||||||
get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits,
|
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 process::ProcessRegistryState;
|
||||||
use file_watcher::FileWatcherState;
|
use file_watcher::FileWatcherState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -220,6 +224,9 @@ fn main() {
|
|||||||
app.manage(UsageIndexState::default());
|
app.manage(UsageIndexState::default());
|
||||||
app.manage(UsageCacheState::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)
|
// 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 std::env::var("TAURI_OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||||
if let Some(win) = app.get_webview_window("main") {
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
@@ -402,6 +409,14 @@ fn main() {
|
|||||||
get_git_branches,
|
get_git_branches,
|
||||||
get_git_diff,
|
get_git_diff,
|
||||||
get_git_commits,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Terminal,
|
Terminal as TerminalIcon,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Copy,
|
Copy,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileX,
|
FileX,
|
||||||
Clock
|
Clock,
|
||||||
|
Square
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -64,6 +65,7 @@ import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer";
|
|||||||
import { MainContentArea } from "@/components/layout/MainContentArea";
|
import { MainContentArea } from "@/components/layout/MainContentArea";
|
||||||
import { SidePanel } from "@/components/layout/SidePanel";
|
import { SidePanel } from "@/components/layout/SidePanel";
|
||||||
import { ChatView } from "@/components/layout/ChatView";
|
import { ChatView } from "@/components/layout/ChatView";
|
||||||
|
import { Terminal } from "@/components/Terminal";
|
||||||
|
|
||||||
interface ClaudeCodeSessionProps {
|
interface ClaudeCodeSessionProps {
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +122,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
openFileEditor,
|
openFileEditor,
|
||||||
closeFileEditor,
|
closeFileEditor,
|
||||||
openPreview: openLayoutPreview,
|
openPreview: openLayoutPreview,
|
||||||
closePreview: closeLayoutPreview
|
closePreview: closeLayoutPreview,
|
||||||
|
openTerminal,
|
||||||
|
closeTerminal,
|
||||||
|
toggleTerminalMaximize
|
||||||
} = layoutManager;
|
} = layoutManager;
|
||||||
|
|
||||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||||
@@ -1389,7 +1394,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{displayableMessages.length === 0 ? (
|
{displayableMessages.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
|
<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>
|
<p className="text-sm">开始对话或等待消息加载...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1560,6 +1565,29 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</motion.div>
|
</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 preview is maximized, render only the WebviewPreview in full screen
|
||||||
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
||||||
return (
|
return (
|
||||||
@@ -1604,7 +1632,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex-1">
|
||||||
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
|
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1624,6 +1652,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* File Explorer Toggle */}
|
||||||
{projectPath && (
|
{projectPath && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -1840,7 +1889,16 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
visible: true,
|
visible: true,
|
||||||
content: (
|
content: (
|
||||||
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
<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
|
<FileEditorEnhanced
|
||||||
filePath={layout.editingFile}
|
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;
|
@@ -9,9 +9,10 @@ interface LayoutState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
splitPosition: number;
|
splitPosition: number;
|
||||||
isCompactMode: boolean;
|
isCompactMode: boolean;
|
||||||
activeView: 'chat' | 'editor' | 'preview'; // 新增:当前活动视图
|
activeView: 'chat' | 'editor' | 'preview' | 'terminal'; // 新增终端视图
|
||||||
editingFile: string | null; // 新增:正在编辑的文件
|
editingFile: string | null; // 新增:正在编辑的文件
|
||||||
previewUrl: string | null; // 新增:预览URL
|
previewUrl: string | null; // 新增:预览URL
|
||||||
|
isTerminalMaximized: boolean; // 新增:终端是否最大化
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutBreakpoints {
|
interface LayoutBreakpoints {
|
||||||
@@ -35,6 +36,7 @@ const DEFAULT_LAYOUT: LayoutState = {
|
|||||||
activeView: 'chat', // 默认显示聊天视图
|
activeView: 'chat', // 默认显示聊天视图
|
||||||
editingFile: null,
|
editingFile: null,
|
||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
|
isTerminalMaximized: false, // 默认终端不最大化
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'claudia_layout_preferences';
|
const STORAGE_KEY = 'claudia_layout_preferences';
|
||||||
@@ -298,6 +300,29 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
});
|
});
|
||||||
}, [saveLayout]);
|
}, [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 {
|
return {
|
||||||
layout,
|
layout,
|
||||||
@@ -319,5 +344,9 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
openPreview,
|
openPreview,
|
||||||
closePreview,
|
closePreview,
|
||||||
switchToChatView,
|
switchToChatView,
|
||||||
|
// 终端相关方法
|
||||||
|
openTerminal,
|
||||||
|
closeTerminal,
|
||||||
|
toggleTerminalMaximize,
|
||||||
};
|
};
|
||||||
}
|
}
|
106
src/lib/api.ts
106
src/lib/api.ts
@@ -2512,5 +2512,111 @@ export const api = {
|
|||||||
console.error("Failed to unwatch Claude project directory:", error);
|
console.error("Failed to unwatch Claude project directory:", error);
|
||||||
throw 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user