mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to v0.35.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+16
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"version": "0.33.0",
|
||||
"version": "0.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxycast",
|
||||
"version": "0.33.0",
|
||||
"version": "0.34.0",
|
||||
"dependencies": {
|
||||
"@fabianlars/tauri-plugin-oauth": "^2",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -30,7 +30,9 @@
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -3978,12 +3980,24 @@
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -42,7 +42,9 @@
|
||||
"@types/styled-components": "^5.1.36",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+41
-1
@@ -2691,6 +2691,32 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3784,7 +3810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.34.0"
|
||||
version = "0.35.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -3817,11 +3843,13 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls-pemfile",
|
||||
"scopeguard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"ssh2",
|
||||
"subtle",
|
||||
"tar",
|
||||
"tauri",
|
||||
@@ -4861,6 +4889,18 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.34.0"
|
||||
version = "0.35.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -67,6 +67,8 @@ arboard = "3"
|
||||
glob = "0.3.3"
|
||||
hex = "0.4.3"
|
||||
portable-pty = "0.8"
|
||||
scopeguard = "1"
|
||||
ssh2 = "0.9"
|
||||
|
||||
# Platform specific dependencies for browser interceptor
|
||||
|
||||
|
||||
@@ -767,6 +767,8 @@ pub fn run() {
|
||||
commands::plugin_install_cmd::is_plugin_installed,
|
||||
// Plugin UI commands
|
||||
commands::plugin_cmd::get_plugins_with_ui,
|
||||
commands::plugin_cmd::read_plugin_manifest_cmd,
|
||||
commands::plugin_cmd::launch_plugin_ui,
|
||||
// Plugin RPC commands
|
||||
commands::plugin_rpc_cmd::plugin_rpc_connect,
|
||||
commands::plugin_rpc_cmd::plugin_rpc_disconnect,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest};
|
||||
use crate::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -186,18 +186,23 @@ fn read_plugin_manifest(install_path: &Path) -> Option<PluginManifest> {
|
||||
/// 获取带有 UI 配置的已安装插件列表
|
||||
///
|
||||
/// 从已安装插件中筛选带有 UI 配置的插件,返回 PluginUIInfo 列表
|
||||
/// 同时扫描插件目录中未注册但存在的插件
|
||||
/// _需求: 3.1, 3.3_
|
||||
#[tauri::command]
|
||||
pub async fn get_plugins_with_ui(
|
||||
installer_state: tauri::State<'_, PluginInstallerState>,
|
||||
plugin_manager_state: tauri::State<'_, PluginManagerState>,
|
||||
) -> Result<Vec<PluginUIInfo>, String> {
|
||||
let installer = installer_state.0.read().await;
|
||||
let manager = plugin_manager_state.0.read().await;
|
||||
|
||||
// 获取所有已安装插件
|
||||
// 获取所有已安装插件(从数据库)
|
||||
let installed_plugins = installer.list_installed().map_err(|e| e.to_string())?;
|
||||
let mut registered_ids: std::collections::HashSet<String> =
|
||||
installed_plugins.iter().map(|p| p.id.clone()).collect();
|
||||
|
||||
// 筛选带有 UI 配置的插件
|
||||
let ui_plugins: Vec<PluginUIInfo> = installed_plugins
|
||||
// 筛选带有 UI 配置的已注册插件
|
||||
let mut ui_plugins: Vec<PluginUIInfo> = installed_plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
// 读取插件的 manifest 文件
|
||||
@@ -221,6 +226,43 @@ pub async fn get_plugins_with_ui(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 扫描插件目录中未注册的插件
|
||||
let plugins_dir = manager.plugins_dir();
|
||||
if let Ok(entries) = std::fs::read_dir(plugins_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let plugin_id = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(id) = plugin_id {
|
||||
// 跳过已注册的插件
|
||||
if registered_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 尝试读取 manifest
|
||||
if let Some(manifest) = read_plugin_manifest(&path) {
|
||||
if let Some(ui_config) = manifest.ui {
|
||||
if !ui_config.surfaces.is_empty() {
|
||||
ui_plugins.push(PluginUIInfo {
|
||||
plugin_id: id.clone(),
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
icon: ui_config.icon.unwrap_or_else(|| "puzzle".to_string()),
|
||||
surfaces: ui_config.surfaces,
|
||||
});
|
||||
registered_ids.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ui_plugins)
|
||||
}
|
||||
|
||||
@@ -266,3 +308,60 @@ pub async fn handle_plugin_action(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 读取插件清单文件
|
||||
///
|
||||
/// 从插件目录读取 plugin.json 文件
|
||||
/// 用于检查插件是否存在于文件系统中(即使未在数据库中注册)
|
||||
#[tauri::command]
|
||||
pub async fn read_plugin_manifest_cmd(
|
||||
state: tauri::State<'_, PluginManagerState>,
|
||||
plugin_id: String,
|
||||
) -> Result<Option<PluginManifest>, String> {
|
||||
let manager = state.0.read().await;
|
||||
let plugins_dir = manager.plugins_dir();
|
||||
let plugin_path = plugins_dir.join(&plugin_id);
|
||||
|
||||
Ok(read_plugin_manifest(&plugin_path))
|
||||
}
|
||||
|
||||
/// 启动插件 UI(用于 binary 类型插件)
|
||||
///
|
||||
/// 启动插件的独立 UI 窗口
|
||||
#[tauri::command]
|
||||
pub async fn launch_plugin_ui(
|
||||
state: tauri::State<'_, PluginManagerState>,
|
||||
plugin_id: String,
|
||||
) -> Result<(), String> {
|
||||
let manager = state.0.read().await;
|
||||
let plugins_dir = manager.plugins_dir();
|
||||
let plugin_path = plugins_dir.join(&plugin_id);
|
||||
|
||||
// 读取插件清单
|
||||
let manifest =
|
||||
read_plugin_manifest(&plugin_path).ok_or_else(|| format!("插件 {} 不存在", plugin_id))?;
|
||||
|
||||
// 检查是否是 binary 类型
|
||||
if manifest.plugin_type != PluginType::Binary {
|
||||
return Err("只有 binary 类型的插件支持独立启动".to_string());
|
||||
}
|
||||
|
||||
// 获取二进制文件路径
|
||||
let binary_config = manifest
|
||||
.binary
|
||||
.ok_or_else(|| "插件缺少 binary 配置".to_string())?;
|
||||
|
||||
let binary_name = &binary_config.binary_name;
|
||||
let binary_path = plugin_path.join(binary_name);
|
||||
|
||||
if !binary_path.exists() {
|
||||
return Err(format!("插件二进制文件不存在: {}", binary_path.display()));
|
||||
}
|
||||
|
||||
// 启动二进制文件
|
||||
std::process::Command::new(&binary_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动插件失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ impl PluginInstaller {
|
||||
/// 从本地文件安装插件
|
||||
///
|
||||
/// 流程: 验证 → 解压 → 注册 → 复制文件
|
||||
/// 如果插件已存在,会先清理旧版本数据再安装新版本
|
||||
/// _需求: 1.1, 1.2, 1.3_
|
||||
pub async fn install_from_file(
|
||||
&self,
|
||||
@@ -83,9 +84,10 @@ impl PluginInstaller {
|
||||
progress.on_progress(InstallProgress::validating("验证清单文件..."));
|
||||
let manifest = self.validator.extract_and_validate_manifest(path, format)?;
|
||||
|
||||
// 检查插件是否已存在
|
||||
// 检查插件是否已存在,如果存在则先清理旧版本
|
||||
if self.registry.exists(&manifest.name)? {
|
||||
return Err(InstallError::AlreadyExists(manifest.name.clone()));
|
||||
progress.on_progress(InstallProgress::installing(0, "清理旧版本..."));
|
||||
self.cleanup_old_version(&manifest.name)?;
|
||||
}
|
||||
|
||||
// 阶段 3: 解压到临时目录
|
||||
@@ -126,6 +128,7 @@ impl PluginInstaller {
|
||||
/// 从 URL 安装插件
|
||||
///
|
||||
/// 流程: 下载 → 验证 → 解压 → 注册 → 复制文件
|
||||
/// 如果插件已存在,会先清理旧版本数据再安装新版本
|
||||
/// _需求: 2.1, 2.2_
|
||||
pub async fn install_from_url(
|
||||
&self,
|
||||
@@ -151,11 +154,10 @@ impl PluginInstaller {
|
||||
.validator
|
||||
.extract_and_validate_manifest(&download_path, format)?;
|
||||
|
||||
// 检查插件是否已存在
|
||||
// 检查插件是否已存在,如果存在则先清理旧版本
|
||||
if self.registry.exists(&manifest.name)? {
|
||||
// 清理下载文件
|
||||
let _ = fs::remove_file(&download_path);
|
||||
return Err(InstallError::AlreadyExists(manifest.name.clone()));
|
||||
progress.on_progress(InstallProgress::installing(0, "清理旧版本..."));
|
||||
self.cleanup_old_version(&manifest.name)?;
|
||||
}
|
||||
|
||||
// 阶段 4: 解压到临时目录
|
||||
@@ -208,7 +210,7 @@ impl PluginInstaller {
|
||||
|
||||
/// 卸载插件
|
||||
///
|
||||
/// 流程: 删除文件 → 注销注册表
|
||||
/// 流程: 删除文件 → 清理数据目录 → 注销注册表
|
||||
/// _需求: 4.2_
|
||||
pub async fn uninstall(&self, plugin_id: &str) -> Result<(), InstallError> {
|
||||
// 获取插件信息
|
||||
@@ -222,12 +224,73 @@ impl PluginInstaller {
|
||||
fs::remove_dir_all(&plugin.install_path)?;
|
||||
}
|
||||
|
||||
// 清理插件数据目录(在 Application Support 下的独立目录)
|
||||
self.cleanup_plugin_data_dirs(plugin_id);
|
||||
|
||||
// 注销注册表
|
||||
self.registry.unregister(plugin_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理插件在 Application Support 下的数据目录
|
||||
///
|
||||
/// 插件可能在以下位置创建数据:
|
||||
/// - ~/Library/Application Support/{plugin-id}
|
||||
/// - ~/Library/Caches/{plugin-id}
|
||||
/// - ~/Library/WebKit/{plugin-id}
|
||||
fn cleanup_plugin_data_dirs(&self, plugin_id: &str) {
|
||||
// 获取用户数据目录
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
// ~/Library/Application Support/{plugin-id}
|
||||
let plugin_data_dir = data_dir.join(plugin_id);
|
||||
if plugin_data_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_data_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存目录
|
||||
if let Some(cache_dir) = dirs::cache_dir() {
|
||||
// ~/Library/Caches/{plugin-id}
|
||||
let plugin_cache_dir = cache_dir.join(plugin_id);
|
||||
if plugin_cache_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_cache_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 WebKit 数据目录(macOS 特有)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
let webkit_dir = home_dir.join("Library").join("WebKit").join(plugin_id);
|
||||
if webkit_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&webkit_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧版本插件(用于更新安装)
|
||||
///
|
||||
/// 删除旧版本的插件文件、数据目录和注册表记录
|
||||
fn cleanup_old_version(&self, plugin_id: &str) -> Result<(), InstallError> {
|
||||
// 获取旧插件信息
|
||||
if let Some(old_plugin) = self.registry.get(plugin_id)? {
|
||||
// 删除旧插件文件
|
||||
if old_plugin.install_path.exists() {
|
||||
fs::remove_dir_all(&old_plugin.install_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理插件数据目录
|
||||
self.cleanup_plugin_data_dirs(plugin_id);
|
||||
|
||||
// 注销旧的注册表记录
|
||||
let _ = self.registry.unregister(plugin_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取已安装插件列表
|
||||
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, InstallError> {
|
||||
self.registry.list()
|
||||
@@ -564,25 +627,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_from_file_already_exists() {
|
||||
let (installer, _plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path = create_test_plugin_zip(temp_dir.path(), "duplicate-plugin", "1.0.0");
|
||||
async fn test_install_from_file_update_existing() {
|
||||
let (installer, plugins_dir, temp_dir, _db_dir) = create_test_installer();
|
||||
let package_path_v1 = create_test_plugin_zip(temp_dir.path(), "update-plugin", "1.0.0");
|
||||
|
||||
let progress = NoopProgressCallback;
|
||||
|
||||
// 第一次安装
|
||||
let result1 = installer.install_from_file(&package_path, &progress).await;
|
||||
// 第一次安装 v1.0.0
|
||||
let result1 = installer
|
||||
.install_from_file(&package_path_v1, &progress)
|
||||
.await;
|
||||
assert!(result1.is_ok());
|
||||
assert_eq!(result1.unwrap().version, "1.0.0");
|
||||
|
||||
// 第二次安装应该失败
|
||||
let result2 = installer.install_from_file(&package_path, &progress).await;
|
||||
assert!(result2.is_err());
|
||||
match result2.unwrap_err() {
|
||||
InstallError::AlreadyExists(name) => {
|
||||
assert_eq!(name, "duplicate-plugin");
|
||||
}
|
||||
e => panic!("期望 AlreadyExists 错误,实际: {:?}", e),
|
||||
}
|
||||
// 创建新版本
|
||||
let package_path_v2 = create_test_plugin_zip(temp_dir.path(), "update-plugin", "2.0.0");
|
||||
|
||||
// 第二次安装应该成功(覆盖更新)
|
||||
let result2 = installer
|
||||
.install_from_file(&package_path_v2, &progress)
|
||||
.await;
|
||||
assert!(result2.is_ok(), "更新安装应该成功: {:?}", result2);
|
||||
|
||||
let updated = result2.unwrap();
|
||||
assert_eq!(updated.name, "update-plugin");
|
||||
assert_eq!(updated.version, "2.0.0");
|
||||
|
||||
// 验证只有一个插件
|
||||
let plugins = installer.list_installed().unwrap();
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].version, "2.0.0");
|
||||
|
||||
// 验证文件存在
|
||||
let plugin_dir = plugins_dir.path().join("update-plugin");
|
||||
assert!(plugin_dir.exists(), "插件目录应该存在");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -213,7 +213,7 @@ pub struct BinaryManifest {
|
||||
/// _需求: 5.2, 5.3_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct UiManifest {
|
||||
/// UI 展示位置 (如 "main", "settings", "sidebar")
|
||||
/// UI 展示位置 (如 "main", "settings", "sidebar", "tools")
|
||||
#[serde(default)]
|
||||
pub surfaces: Vec<String>,
|
||||
/// 图标名称 (使用 Lucide 图标名)
|
||||
@@ -222,6 +222,12 @@ pub struct UiManifest {
|
||||
/// 窗口标题
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
/// 嵌入式 UI 入口文件路径 (相对于插件目录,如 "dist/index.js")
|
||||
#[serde(default)]
|
||||
pub entry: Option<String>,
|
||||
/// UI 描述
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// 窗口默认宽度
|
||||
#[serde(default)]
|
||||
pub default_width: Option<u32>,
|
||||
@@ -587,18 +593,24 @@ mod tests {
|
||||
prop::collection::vec("[a-z]{1,10}", 0..3),
|
||||
proptest::option::of("[a-z-]{1,20}"),
|
||||
proptest::option::of("[a-zA-Z0-9 ]{1,30}"),
|
||||
proptest::option::of("[a-z/]{1,30}"),
|
||||
proptest::option::of("[a-zA-Z0-9 ]{1,50}"),
|
||||
proptest::option::of(100u32..2000u32),
|
||||
proptest::option::of(100u32..2000u32),
|
||||
)
|
||||
.prop_map(|(surfaces, icon, title, default_width, default_height)| {
|
||||
UiManifest {
|
||||
surfaces,
|
||||
icon,
|
||||
title,
|
||||
default_width,
|
||||
default_height,
|
||||
}
|
||||
})
|
||||
.prop_map(
|
||||
|(surfaces, icon, title, entry, description, default_width, default_height)| {
|
||||
UiManifest {
|
||||
surfaces,
|
||||
icon,
|
||||
title,
|
||||
entry,
|
||||
description,
|
||||
default_width,
|
||||
default_height,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成随机的 PluginType
|
||||
@@ -688,6 +700,8 @@ mod tests {
|
||||
surfaces: vec!["main".to_string(), "settings".to_string()],
|
||||
icon: Some("puzzle".to_string()),
|
||||
title: Some("Test Plugin".to_string()),
|
||||
entry: None,
|
||||
description: None,
|
||||
default_width: Some(800),
|
||||
default_height: Some(600),
|
||||
};
|
||||
@@ -732,6 +746,8 @@ mod tests {
|
||||
surfaces: vec!["main".to_string()],
|
||||
icon: Some("puzzle".to_string()),
|
||||
title: None,
|
||||
entry: None,
|
||||
description: None,
|
||||
default_width: None,
|
||||
default_height: None,
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- PTY 使用默认大小 (24x80) 预创建
|
||||
- 前端连接后通过 resize 同步实际大小
|
||||
- 通过 Tauri Commands 和 Events 暴露给前端
|
||||
- 统一的 BlockController 抽象层支持多种连接类型
|
||||
|
||||
## 核心功能
|
||||
|
||||
@@ -18,15 +19,38 @@
|
||||
- **会话管理**: 多会话支持,生命周期管理
|
||||
- **实时输出**: 通过 Tauri Events 推送终端输出
|
||||
- **状态通知**: 会话状态变化事件
|
||||
- **持久化存储**: 块文件循环缓冲存储、会话元数据 SQLite 存储
|
||||
- **块控制器**: 统一的控制器抽象层(Shell、Cmd、SSH、WSL)
|
||||
- **连接管理**: 本地 PTY、SSH、WSL 连接支持
|
||||
- **Shell 集成**: OSC 序列解析、状态重同步、命令跟踪
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口和类型导出
|
||||
- `error.rs` - 错误类型定义
|
||||
- `events.rs` - Tauri 事件定义(terminal:output, terminal:status)
|
||||
- `events.rs` - Tauri 事件定义(terminal:output, terminal:status, terminal:shell-integration)
|
||||
- `pty_session.rs` - PTY 会话封装(支持默认大小创建)
|
||||
- `session_manager.rs` - 会话管理器
|
||||
- `tests.rs` - 单元测试
|
||||
- `block_controller/` - 块控制器模块
|
||||
- `mod.rs` - 模块入口
|
||||
- `traits.rs` - BlockController trait 定义
|
||||
- `registry.rs` - 控制器注册表
|
||||
- `shell_controller.rs` - Shell/Cmd 控制器实现
|
||||
- `connections/` - 连接模块
|
||||
- `mod.rs` - 模块入口
|
||||
- `local_pty.rs` - 本地 PTY 连接(ShellProc)
|
||||
- `ssh_connection.rs` - SSH 远程连接(待实现)
|
||||
- `wsl_connection.rs` - WSL 连接(待实现)
|
||||
- `integration/` - 集成模块
|
||||
- `mod.rs` - 模块入口
|
||||
- `resync.rs` - 状态重同步控制器
|
||||
- `osc_parser.rs` - OSC 序列解析器(OSC 7/52/133/16162)
|
||||
- `shell_integration.rs` - Shell 集成处理器(状态管理、命令跟踪)
|
||||
- `persistence/` - 持久化存储模块
|
||||
- `mod.rs` - 模块入口
|
||||
- `block_file.rs` - 块文件循环缓冲存储
|
||||
- `session_store.rs` - 会话元数据 SQLite 存储
|
||||
|
||||
## 命令接口
|
||||
|
||||
@@ -45,15 +69,22 @@
|
||||
|--------|------|----------|
|
||||
| `terminal:output` | 终端输出数据 | `{ session_id, data }` |
|
||||
| `terminal:status` | 会话状态变化 | `{ session_id, status, exit_code?, error? }` |
|
||||
| `terminal:shell-integration` | Shell 集成状态变化 | `{ block_id, status, current_dir?, command_info? }` |
|
||||
| `terminal:clipboard-write` | 剪贴板写入请求 | `{ block_id, selection, content }` |
|
||||
| `controller:status` | 控制器状态变化 | `{ block_id, version, shell_proc_status, ... }` |
|
||||
|
||||
## 常量
|
||||
|
||||
- `DEFAULT_ROWS`: 默认终端行数 (24)
|
||||
- `DEFAULT_COLS`: 默认终端列数 (80)
|
||||
- `DEFAULT_TERM_MAX_FILE_SIZE`: 默认块文件最大大小 (256KB)
|
||||
- `TERMINAL_RESET_SEQUENCE`: 终端完全重置序列
|
||||
- `TERMINAL_SOFT_RESET_SEQUENCE`: 终端软重置序列
|
||||
|
||||
## 依赖
|
||||
|
||||
- `portable-pty` - 跨平台 PTY 支持
|
||||
- `rusqlite` - SQLite 数据库支持
|
||||
|
||||
## 更新提醒
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# block_controller
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
块控制器模块,提供统一的控制器抽象层,支持不同类型的终端连接。
|
||||
|
||||
**核心原则:**
|
||||
- 所有控制器类型实现统一的 BlockController trait
|
||||
- 通过注册表管理控制器生命周期
|
||||
- 支持 Shell、Cmd、SSH、WSL 等多种控制器类型
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **BlockController trait**: 统一的控制器接口(start、stop、send_input、get_runtime_status)
|
||||
- **ShellController**: Shell/Cmd 控制器实现,管理本地和远程 Shell 进程
|
||||
- **控制器注册表**: 按 block_id 管理控制器实例
|
||||
- **运行时状态**: 提供控制器状态查询
|
||||
- **状态事件广播**: 通过 Tauri 事件系统广播状态更新
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口和类型导出
|
||||
- `traits.rs` - BlockController trait 定义、BlockControllerRuntimeStatus、BlockInputUnion、BlockMeta
|
||||
- `registry.rs` - 控制器注册表(HashMap + RwLock)
|
||||
- `shell_controller.rs` - ShellController 实现,支持 shell 和 cmd 两种模式
|
||||
|
||||
## 数据结构
|
||||
|
||||
### BlockControllerRuntimeStatus
|
||||
|
||||
```rust
|
||||
pub struct BlockControllerRuntimeStatus {
|
||||
pub block_id: String,
|
||||
pub version: i32,
|
||||
pub shell_proc_status: String, // "init" | "running" | "done"
|
||||
pub shell_proc_conn_name: Option<String>,
|
||||
pub shell_proc_exit_code: i32,
|
||||
}
|
||||
```
|
||||
|
||||
### BlockInputUnion
|
||||
|
||||
```rust
|
||||
pub struct BlockInputUnion {
|
||||
pub input_data: Option<Vec<u8>>,
|
||||
pub sig_name: Option<String>,
|
||||
pub term_size: Option<TermSize>,
|
||||
}
|
||||
```
|
||||
|
||||
### BlockMeta
|
||||
|
||||
```rust
|
||||
pub struct BlockMeta {
|
||||
pub controller: Option<String>, // "shell" | "cmd"
|
||||
pub connection: Option<String>, // SSH/WSL 连接名称
|
||||
pub cmd: Option<String>, // 命令字符串
|
||||
pub cmd_args: Option<Vec<String>>, // 命令参数
|
||||
pub cmd_cwd: Option<String>, // 工作目录
|
||||
pub cmd_env: Option<HashMap<String, String>>, // 环境变量
|
||||
pub cmd_run_on_start: Option<bool>, // 启动时自动运行
|
||||
pub cmd_run_once: Option<bool>, // 仅运行一次
|
||||
pub cmd_clear_on_start: Option<bool>, // 启动前清空输出
|
||||
pub cmd_close_on_exit: Option<bool>, // 退出后自动关闭
|
||||
// ... 其他终端配置
|
||||
}
|
||||
```
|
||||
|
||||
## ShellController 功能
|
||||
|
||||
### 支持的控制器类型
|
||||
|
||||
- **shell**: 交互式 Shell 模式,启动用户默认 Shell
|
||||
- **cmd**: 命令执行模式,执行指定命令
|
||||
|
||||
### Cmd 模式配置选项
|
||||
|
||||
- `cmd_run_on_start`: 启动时自动运行命令(默认 true)
|
||||
- `cmd_run_once`: 仅运行一次,不自动重启
|
||||
- `cmd_clear_on_start`: 启动前清空输出历史
|
||||
- `cmd_close_on_exit`: 命令退出后自动关闭
|
||||
|
||||
### 状态事件
|
||||
|
||||
通过 `controller:status` 事件广播状态更新:
|
||||
|
||||
```rust
|
||||
pub struct ControllerStatusEvent {
|
||||
pub block_id: String,
|
||||
pub version: i32,
|
||||
pub shell_proc_status: String,
|
||||
pub shell_proc_conn_name: Option<String>,
|
||||
pub shell_proc_exit_code: i32,
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- `async-trait` - 异步 trait 支持
|
||||
- `tokio` - 异步运行时(RwLock、mpsc)
|
||||
- `serde` - 序列化支持
|
||||
- `scopeguard` - 作用域守卫
|
||||
- `tauri` - 事件系统
|
||||
|
||||
## Requirements 覆盖
|
||||
|
||||
- 1.1: BlockController trait 定义
|
||||
- 1.2: Shell 控制器创建
|
||||
- 1.3: Cmd 控制器创建
|
||||
- 1.6: 控制器注册表
|
||||
- 1.8: get_runtime_status 方法
|
||||
- 2.7: 状态事件广播
|
||||
- 16.5: cmd:runonstart 配置
|
||||
- 16.6: cmd:runonce 配置
|
||||
- 16.7: cmd:clearonstart 配置
|
||||
- 16.8: cmd:closeonexit 配置
|
||||
- 16.9: 重启按钮支持
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 块控制器模块
|
||||
//!
|
||||
//! 提供统一的控制器抽象层,支持不同类型的终端连接(本地 Shell、SSH、WSL、命令执行)。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `traits` - BlockController trait 定义
|
||||
//! - `registry` - 控制器注册表
|
||||
//! - `shell_controller` - Shell/Cmd 控制器实现
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 定义统一的 BlockController trait 接口
|
||||
//! - 管理控制器生命周期(start、stop、send_input)
|
||||
//! - 提供控制器注册表,支持按 block_id 查找
|
||||
|
||||
mod registry;
|
||||
mod shell_controller;
|
||||
mod traits;
|
||||
|
||||
pub use registry::ControllerRegistry;
|
||||
pub use shell_controller::{ControllerStatusEvent, ShellController, CONTROLLER_STATUS_EVENT};
|
||||
pub use traits::{
|
||||
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
|
||||
TermSize,
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 控制器注册表
|
||||
//!
|
||||
//! 管理所有块控制器的注册和查找。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 按 block_id 注册控制器
|
||||
//! - 按 block_id 查找控制器
|
||||
//! - 删除控制器
|
||||
//! - 列出所有控制器
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 1.6: 维护控制器注册表,支持按 block_id 查找控制器
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::traits::BlockController;
|
||||
|
||||
/// 控制器注册表
|
||||
///
|
||||
/// 使用 HashMap + RwLock 实现线程安全的控制器管理。
|
||||
pub struct ControllerRegistry {
|
||||
/// 控制器映射表: block_id -> BlockController
|
||||
controllers: RwLock<HashMap<String, Arc<RwLock<Box<dyn BlockController>>>>>,
|
||||
}
|
||||
|
||||
impl Default for ControllerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ControllerRegistry {
|
||||
/// 创建新的控制器注册表
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
controllers: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
/// - `controller`: 控制器实例
|
||||
///
|
||||
/// # 返回
|
||||
/// 如果已存在同 block_id 的控制器,返回旧控制器
|
||||
pub async fn register(
|
||||
&self,
|
||||
block_id: String,
|
||||
controller: Box<dyn BlockController>,
|
||||
) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
|
||||
let mut controllers = self.controllers.write().await;
|
||||
controllers.insert(block_id, Arc::new(RwLock::new(controller)))
|
||||
}
|
||||
|
||||
/// 获取控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 如果存在返回控制器的 Arc 引用,否则返回 None
|
||||
pub async fn get(&self, block_id: &str) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
|
||||
let controllers = self.controllers.read().await;
|
||||
controllers.get(block_id).cloned()
|
||||
}
|
||||
|
||||
/// 删除控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 如果存在返回被删除的控制器,否则返回 None
|
||||
pub async fn remove(&self, block_id: &str) -> Option<Arc<RwLock<Box<dyn BlockController>>>> {
|
||||
let mut controllers = self.controllers.write().await;
|
||||
controllers.remove(block_id)
|
||||
}
|
||||
|
||||
/// 检查控制器是否存在
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 存在返回 true,否则返回 false
|
||||
pub async fn contains(&self, block_id: &str) -> bool {
|
||||
let controllers = self.controllers.read().await;
|
||||
controllers.contains_key(block_id)
|
||||
}
|
||||
|
||||
/// 获取所有块 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 所有已注册的块 ID 列表
|
||||
pub async fn list_block_ids(&self) -> Vec<String> {
|
||||
let controllers = self.controllers.read().await;
|
||||
controllers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// 获取控制器数量
|
||||
///
|
||||
/// # 返回
|
||||
/// 已注册的控制器数量
|
||||
pub async fn len(&self) -> usize {
|
||||
let controllers = self.controllers.read().await;
|
||||
controllers.len()
|
||||
}
|
||||
|
||||
/// 检查注册表是否为空
|
||||
///
|
||||
/// # 返回
|
||||
/// 为空返回 true,否则返回 false
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
let controllers = self.controllers.read().await;
|
||||
controllers.is_empty()
|
||||
}
|
||||
|
||||
/// 清空所有控制器
|
||||
pub async fn clear(&self) {
|
||||
let mut controllers = self.controllers.write().await;
|
||||
controllers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::block_controller::traits::{
|
||||
BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
|
||||
};
|
||||
use crate::terminal::TerminalError;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// 测试用的 Mock 控制器
|
||||
struct MockController {
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
}
|
||||
|
||||
impl MockController {
|
||||
fn new(block_id: &str, controller_type: &str) -> Self {
|
||||
Self {
|
||||
block_id: block_id.to_string(),
|
||||
controller_type: controller_type.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockController for MockController {
|
||||
async fn start(
|
||||
&mut self,
|
||||
_block_meta: BlockMeta,
|
||||
_rt_opts: Option<RuntimeOpts>,
|
||||
_force: bool,
|
||||
) -> Result<(), TerminalError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(
|
||||
&mut self,
|
||||
_graceful: bool,
|
||||
_new_status: String,
|
||||
) -> Result<(), TerminalError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
|
||||
BlockControllerRuntimeStatus::new(self.block_id.clone())
|
||||
}
|
||||
|
||||
async fn send_input(&self, _input: &BlockInputUnion) -> Result<(), TerminalError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn controller_type(&self) -> &str {
|
||||
&self.controller_type
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_register_and_get() {
|
||||
let registry = ControllerRegistry::new();
|
||||
let controller = MockController::new("block-1", "shell");
|
||||
|
||||
// 注册控制器
|
||||
let old = registry
|
||||
.register("block-1".to_string(), Box::new(controller))
|
||||
.await;
|
||||
assert!(old.is_none());
|
||||
|
||||
// 获取控制器
|
||||
let ctrl = registry.get("block-1").await;
|
||||
assert!(ctrl.is_some());
|
||||
|
||||
// 验证控制器类型
|
||||
let ctrl = ctrl.unwrap();
|
||||
let ctrl_guard = ctrl.read().await;
|
||||
assert_eq!(ctrl_guard.controller_type(), "shell");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_remove() {
|
||||
let registry = ControllerRegistry::new();
|
||||
let controller = MockController::new("block-2", "cmd");
|
||||
|
||||
registry
|
||||
.register("block-2".to_string(), Box::new(controller))
|
||||
.await;
|
||||
assert!(registry.contains("block-2").await);
|
||||
|
||||
// 删除控制器
|
||||
let removed = registry.remove("block-2").await;
|
||||
assert!(removed.is_some());
|
||||
assert!(!registry.contains("block-2").await);
|
||||
|
||||
// 再次删除应返回 None
|
||||
let removed_again = registry.remove("block-2").await;
|
||||
assert!(removed_again.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_list_and_len() {
|
||||
let registry = ControllerRegistry::new();
|
||||
|
||||
assert!(registry.is_empty().await);
|
||||
assert_eq!(registry.len().await, 0);
|
||||
|
||||
registry
|
||||
.register(
|
||||
"block-a".to_string(),
|
||||
Box::new(MockController::new("block-a", "shell")),
|
||||
)
|
||||
.await;
|
||||
registry
|
||||
.register(
|
||||
"block-b".to_string(),
|
||||
Box::new(MockController::new("block-b", "cmd")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!registry.is_empty().await);
|
||||
assert_eq!(registry.len().await, 2);
|
||||
|
||||
let ids = registry.list_block_ids().await;
|
||||
assert!(ids.contains(&"block-a".to_string()));
|
||||
assert!(ids.contains(&"block-b".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_clear() {
|
||||
let registry = ControllerRegistry::new();
|
||||
|
||||
registry
|
||||
.register(
|
||||
"block-x".to_string(),
|
||||
Box::new(MockController::new("block-x", "shell")),
|
||||
)
|
||||
.await;
|
||||
registry
|
||||
.register(
|
||||
"block-y".to_string(),
|
||||
Box::new(MockController::new("block-y", "shell")),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(registry.len().await, 2);
|
||||
|
||||
registry.clear().await;
|
||||
|
||||
assert!(registry.is_empty().await);
|
||||
assert_eq!(registry.len().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_replace_controller() {
|
||||
let registry = ControllerRegistry::new();
|
||||
|
||||
// 注册第一个控制器
|
||||
let ctrl1 = MockController::new("block-z", "shell");
|
||||
registry
|
||||
.register("block-z".to_string(), Box::new(ctrl1))
|
||||
.await;
|
||||
|
||||
// 验证类型
|
||||
{
|
||||
let ctrl = registry.get("block-z").await.unwrap();
|
||||
let guard = ctrl.read().await;
|
||||
assert_eq!(guard.controller_type(), "shell");
|
||||
}
|
||||
|
||||
// 替换为新控制器
|
||||
let ctrl2 = MockController::new("block-z", "cmd");
|
||||
let old = registry
|
||||
.register("block-z".to_string(), Box::new(ctrl2))
|
||||
.await;
|
||||
assert!(old.is_some());
|
||||
|
||||
// 验证新类型
|
||||
{
|
||||
let ctrl = registry.get("block-z").await.unwrap();
|
||||
let guard = ctrl.read().await;
|
||||
assert_eq!(guard.controller_type(), "cmd");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
//! ShellController 实现
|
||||
//!
|
||||
//! 实现 BlockController trait,管理本地和远程 Shell 进程。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 实现 BlockController trait 接口
|
||||
//! - 管理 Shell 进程生命周期(init、running、done)
|
||||
//! - 支持 "shell" 和 "cmd" 两种控制器类型
|
||||
//! - 状态更新事件广播
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 1.2: 创建本地终端时实例化 Shell_Controller 并设置 controller_type 为 "shell"
|
||||
//! - 1.3: 创建命令执行终端时实例化 Shell_Controller 并设置 controller_type 为 "cmd"
|
||||
//! - 2.7: 会话状态变更时通过事件广播状态更新到所有订阅者
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use super::traits::{
|
||||
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts,
|
||||
};
|
||||
use crate::terminal::connections::ShellProc;
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::persistence::BlockFile;
|
||||
|
||||
/// 控制器状态事件名称
|
||||
pub const CONTROLLER_STATUS_EVENT: &str = "controller:status";
|
||||
|
||||
/// 控制器状态事件
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ControllerStatusEvent {
|
||||
/// 块 ID
|
||||
pub block_id: String,
|
||||
/// 状态版本号
|
||||
pub version: i32,
|
||||
/// Shell 进程状态
|
||||
pub shell_proc_status: String,
|
||||
/// 连接名称
|
||||
pub shell_proc_conn_name: Option<String>,
|
||||
/// 退出码
|
||||
pub shell_proc_exit_code: i32,
|
||||
}
|
||||
|
||||
impl From<BlockControllerRuntimeStatus> for ControllerStatusEvent {
|
||||
fn from(status: BlockControllerRuntimeStatus) -> Self {
|
||||
Self {
|
||||
block_id: status.block_id,
|
||||
version: status.version,
|
||||
shell_proc_status: status.shell_proc_status,
|
||||
shell_proc_conn_name: status.shell_proc_conn_name,
|
||||
shell_proc_exit_code: status.shell_proc_exit_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 控制器
|
||||
///
|
||||
/// 实现 BlockController trait,管理本地和远程 Shell 进程。
|
||||
/// 支持 "shell"(交互式 Shell)和 "cmd"(命令执行)两种模式。
|
||||
///
|
||||
/// ## Cmd 模式配置选项
|
||||
/// - `cmd_run_on_start`: 启动时自动运行命令
|
||||
/// - `cmd_run_once`: 仅运行一次(不自动重启)
|
||||
/// - `cmd_clear_on_start`: 启动前清空输出
|
||||
/// - `cmd_close_on_exit`: 退出后自动关闭
|
||||
pub struct ShellController {
|
||||
/// 控制器类型: "shell" | "cmd"
|
||||
controller_type: String,
|
||||
/// Tab ID
|
||||
tab_id: String,
|
||||
/// Block ID
|
||||
block_id: String,
|
||||
/// 运行锁,防止并发启动
|
||||
run_lock: AtomicBool,
|
||||
/// 进程状态: "init" | "running" | "done"
|
||||
proc_status: RwLock<String>,
|
||||
/// 进程退出码
|
||||
proc_exit_code: AtomicI32,
|
||||
/// 状态版本号,每次状态变更递增
|
||||
status_version: AtomicI32,
|
||||
/// 连接名称(用于 SSH/WSL)
|
||||
conn_name: RwLock<Option<String>>,
|
||||
/// Shell 进程
|
||||
shell_proc: RwLock<Option<ShellProc>>,
|
||||
/// Shell 输入发送器
|
||||
shell_input_tx: RwLock<Option<mpsc::Sender<BlockInputUnion>>>,
|
||||
/// Tauri 应用句柄
|
||||
app_handle: tauri::AppHandle,
|
||||
/// 块文件存储
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
/// 是否已运行过(用于 cmd:runonce)
|
||||
has_run: AtomicBool,
|
||||
/// 当前块元数据(用于重启)
|
||||
current_meta: RwLock<Option<BlockMeta>>,
|
||||
}
|
||||
|
||||
impl ShellController {
|
||||
/// 创建新的 ShellController
|
||||
///
|
||||
/// # 参数
|
||||
/// - `tab_id`: Tab ID
|
||||
/// - `block_id`: Block ID
|
||||
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
///
|
||||
/// # 返回
|
||||
/// 新的 ShellController 实例
|
||||
///
|
||||
/// _Requirements: 1.2, 1.3_
|
||||
pub fn new(
|
||||
tab_id: String,
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Self {
|
||||
tracing::info!(
|
||||
"[ShellController] 创建控制器: block_id={}, type={}",
|
||||
block_id,
|
||||
controller_type
|
||||
);
|
||||
|
||||
Self {
|
||||
controller_type,
|
||||
tab_id,
|
||||
block_id,
|
||||
run_lock: AtomicBool::new(false),
|
||||
proc_status: RwLock::new("init".to_string()),
|
||||
proc_exit_code: AtomicI32::new(0),
|
||||
status_version: AtomicI32::new(0),
|
||||
conn_name: RwLock::new(None),
|
||||
shell_proc: RwLock::new(None),
|
||||
shell_input_tx: RwLock::new(None),
|
||||
app_handle,
|
||||
block_file: None,
|
||||
has_run: AtomicBool::new(false),
|
||||
current_meta: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带块文件的 ShellController
|
||||
///
|
||||
/// # 参数
|
||||
/// - `tab_id`: Tab ID
|
||||
/// - `block_id`: Block ID
|
||||
/// - `controller_type`: 控制器类型
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_file`: 块文件存储
|
||||
pub fn with_block_file(
|
||||
tab_id: String,
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_file: Arc<BlockFile>,
|
||||
) -> Self {
|
||||
let mut controller = Self::new(tab_id, block_id, controller_type, app_handle);
|
||||
controller.block_file = Some(block_file);
|
||||
controller
|
||||
}
|
||||
|
||||
/// 获取 Tab ID
|
||||
pub fn tab_id(&self) -> &str {
|
||||
&self.tab_id
|
||||
}
|
||||
|
||||
/// 获取 Block ID
|
||||
pub fn block_id(&self) -> &str {
|
||||
&self.block_id
|
||||
}
|
||||
|
||||
/// 设置块文件
|
||||
pub fn set_block_file(&mut self, block_file: Arc<BlockFile>) {
|
||||
self.block_file = Some(block_file);
|
||||
}
|
||||
|
||||
/// 获取块文件引用
|
||||
pub fn block_file(&self) -> Option<&Arc<BlockFile>> {
|
||||
self.block_file.as_ref()
|
||||
}
|
||||
|
||||
/// 更新进程状态
|
||||
///
|
||||
/// 更新状态并递增版本号,然后广播状态更新事件。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `new_status`: 新状态 ("init" | "running" | "done")
|
||||
///
|
||||
/// _Requirements: 2.7_
|
||||
async fn set_status(&self, new_status: &str) {
|
||||
{
|
||||
let mut status = self.proc_status.write().await;
|
||||
*status = new_status.to_string();
|
||||
}
|
||||
self.status_version.fetch_add(1, Ordering::SeqCst);
|
||||
self.send_status_update().await;
|
||||
}
|
||||
|
||||
/// 设置退出码
|
||||
///
|
||||
/// # 参数
|
||||
/// - `exit_code`: 进程退出码
|
||||
fn set_exit_code(&self, exit_code: i32) {
|
||||
self.proc_exit_code.store(exit_code, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 设置连接名称
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_name`: 连接名称
|
||||
async fn set_conn_name(&self, conn_name: Option<String>) {
|
||||
let mut name = self.conn_name.write().await;
|
||||
*name = conn_name;
|
||||
}
|
||||
|
||||
/// 发送状态更新事件
|
||||
///
|
||||
/// 通过 Tauri 事件系统广播控制器状态更新。
|
||||
///
|
||||
/// _Requirements: 2.7_
|
||||
async fn send_status_update(&self) {
|
||||
let status = self.get_runtime_status();
|
||||
let event = ControllerStatusEvent::from(status);
|
||||
|
||||
if let Err(e) = self.app_handle.emit(CONTROLLER_STATUS_EVENT, &event) {
|
||||
tracing::error!(
|
||||
"[ShellController] 发送状态更新事件失败: block_id={}, error={}",
|
||||
self.block_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[ShellController] 状态更新: block_id={}, status={}, version={}",
|
||||
self.block_id,
|
||||
event.shell_proc_status,
|
||||
event.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试获取运行锁
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功获取返回 true,已被占用返回 false
|
||||
fn try_acquire_run_lock(&self) -> bool {
|
||||
self.run_lock
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 释放运行锁
|
||||
fn release_run_lock(&self) {
|
||||
self.run_lock.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 检查是否正在运行
|
||||
pub async fn is_running(&self) -> bool {
|
||||
let status = self.proc_status.read().await;
|
||||
*status == "running"
|
||||
}
|
||||
|
||||
/// 检查是否已完成
|
||||
pub async fn is_done(&self) -> bool {
|
||||
let status = self.proc_status.read().await;
|
||||
*status == "done"
|
||||
}
|
||||
|
||||
/// 检查是否为初始状态
|
||||
pub async fn is_init(&self) -> bool {
|
||||
let status = self.proc_status.read().await;
|
||||
*status == "init"
|
||||
}
|
||||
|
||||
/// 检查是否应该运行(考虑 cmd:runonce)
|
||||
///
|
||||
/// _Requirements: 16.6_
|
||||
fn should_run(&self, block_meta: &BlockMeta) -> bool {
|
||||
// 如果是 cmd 模式且设置了 runonce,检查是否已运行过
|
||||
if self.controller_type == "cmd" {
|
||||
if block_meta.cmd_run_once.unwrap_or(false) {
|
||||
if self.has_run.load(Ordering::SeqCst) {
|
||||
tracing::debug!(
|
||||
"[ShellController] cmd:runonce 已运行过,跳过: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 处理 cmd:clearonstart 配置
|
||||
///
|
||||
/// _Requirements: 16.7_
|
||||
async fn handle_clear_on_start(&self, block_meta: &BlockMeta) {
|
||||
if self.controller_type == "cmd" && block_meta.cmd_clear_on_start.unwrap_or(false) {
|
||||
if let Some(ref bf) = self.block_file {
|
||||
if let Err(e) = bf.truncate() {
|
||||
tracing::warn!(
|
||||
"[ShellController] cmd:clearonstart 清空块文件失败: block_id={}, error={}",
|
||||
self.block_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[ShellController] cmd:clearonstart 已清空块文件: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否应该自动运行(cmd:runonstart)
|
||||
///
|
||||
/// _Requirements: 16.5_
|
||||
fn should_auto_run(&self, block_meta: &BlockMeta) -> bool {
|
||||
if self.controller_type == "cmd" {
|
||||
// 默认 cmd 模式自动运行,除非明确设置为 false
|
||||
block_meta.cmd_run_on_start.unwrap_or(true)
|
||||
} else {
|
||||
// shell 模式总是自动运行
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前块元数据
|
||||
pub async fn get_current_meta(&self) -> Option<BlockMeta> {
|
||||
self.current_meta.read().await.clone()
|
||||
}
|
||||
|
||||
/// 重启控制器(用于 cmd 模式重新运行)
|
||||
///
|
||||
/// _Requirements: 16.9_
|
||||
pub async fn restart(&mut self) -> Result<(), TerminalError> {
|
||||
let meta = self.current_meta.read().await.clone();
|
||||
if let Some(block_meta) = meta {
|
||||
// 重置 has_run 标志以允许重新运行
|
||||
self.has_run.store(false, Ordering::SeqCst);
|
||||
self.start(block_meta, None, true).await
|
||||
} else {
|
||||
Err(TerminalError::Internal("没有保存的块元数据".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockController for ShellController {
|
||||
/// 启动控制器
|
||||
///
|
||||
/// 根据 block_meta 配置启动 Shell 或命令执行进程。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_meta`: 块元数据配置
|
||||
/// - `rt_opts`: 运行时选项(终端大小等)
|
||||
/// - `force`: 是否强制重启
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
///
|
||||
/// _Requirements: 1.2, 1.3, 16.5, 16.6, 16.7_
|
||||
async fn start(
|
||||
&mut self,
|
||||
block_meta: BlockMeta,
|
||||
rt_opts: Option<RuntimeOpts>,
|
||||
force: bool,
|
||||
) -> Result<(), TerminalError> {
|
||||
// 尝试获取运行锁
|
||||
if !self.try_acquire_run_lock() {
|
||||
tracing::warn!(
|
||||
"[ShellController] 控制器已在运行中: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 确保在函数退出时释放锁
|
||||
let _lock_guard = scopeguard::guard((), |_| {
|
||||
self.release_run_lock();
|
||||
});
|
||||
|
||||
// 检查当前状态
|
||||
let current_status = self.proc_status.read().await.clone();
|
||||
if current_status == "running" && !force {
|
||||
tracing::debug!(
|
||||
"[ShellController] 控制器已在运行: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 检查是否应该运行(cmd:runonce)
|
||||
if !self.should_run(&block_meta) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 检查是否应该自动运行(cmd:runonstart)
|
||||
if !self.should_auto_run(&block_meta) && !force {
|
||||
tracing::debug!(
|
||||
"[ShellController] cmd:runonstart=false,跳过自动启动: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 如果强制重启,先停止现有进程
|
||||
if force && current_status == "running" {
|
||||
tracing::info!(
|
||||
"[ShellController] 强制重启控制器: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
// 停止现有进程
|
||||
let mut shell_proc = self.shell_proc.write().await;
|
||||
if let Some(proc) = shell_proc.take() {
|
||||
proc.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 cmd:clearonstart
|
||||
self.handle_clear_on_start(&block_meta).await;
|
||||
|
||||
// 保存块元数据
|
||||
{
|
||||
let mut meta = self.current_meta.write().await;
|
||||
*meta = Some(block_meta.clone());
|
||||
}
|
||||
|
||||
// 更新连接名称
|
||||
let conn_name = block_meta.connection.clone();
|
||||
self.set_conn_name(conn_name.clone()).await;
|
||||
|
||||
// 获取终端大小
|
||||
let term_size = rt_opts
|
||||
.as_ref()
|
||||
.map(|opts| opts.term_size)
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"[ShellController] 启动控制器: block_id={}, type={}, conn={:?}, size={}x{}",
|
||||
self.block_id,
|
||||
self.controller_type,
|
||||
conn_name,
|
||||
term_size.cols,
|
||||
term_size.rows
|
||||
);
|
||||
|
||||
// 创建输入通道
|
||||
let (input_tx, input_rx) = mpsc::channel::<BlockInputUnion>(256);
|
||||
{
|
||||
let mut tx = self.shell_input_tx.write().await;
|
||||
*tx = Some(input_tx);
|
||||
}
|
||||
|
||||
// 创建 Shell 进程
|
||||
let shell_proc = ShellProc::new(
|
||||
self.block_id.clone(),
|
||||
self.controller_type.clone(),
|
||||
term_size.rows,
|
||||
term_size.cols,
|
||||
self.app_handle.clone(),
|
||||
block_meta.clone(),
|
||||
input_rx,
|
||||
self.block_file.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 保存进程引用
|
||||
{
|
||||
let mut proc = self.shell_proc.write().await;
|
||||
*proc = Some(shell_proc);
|
||||
}
|
||||
|
||||
// 标记已运行(用于 cmd:runonce)
|
||||
self.has_run.store(true, Ordering::SeqCst);
|
||||
|
||||
// 更新状态为运行中
|
||||
self.set_status("running").await;
|
||||
self.set_exit_code(0);
|
||||
|
||||
tracing::info!("[ShellController] 控制器已启动: block_id={}", self.block_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止控制器
|
||||
///
|
||||
/// 停止 Shell 进程并更新状态。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL)
|
||||
/// - `new_status`: 停止后的新状态
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError> {
|
||||
tracing::info!(
|
||||
"[ShellController] 停止控制器: block_id={}, graceful={}, new_status={}",
|
||||
self.block_id,
|
||||
graceful,
|
||||
new_status
|
||||
);
|
||||
|
||||
// 关闭输入通道
|
||||
{
|
||||
let mut tx = self.shell_input_tx.write().await;
|
||||
*tx = None;
|
||||
}
|
||||
|
||||
// 停止 Shell 进程
|
||||
let exit_code = {
|
||||
let mut shell_proc = self.shell_proc.write().await;
|
||||
if let Some(proc) = shell_proc.take() {
|
||||
if graceful {
|
||||
proc.terminate().await
|
||||
} else {
|
||||
proc.kill().await;
|
||||
-1
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
// 更新退出码和状态
|
||||
self.set_exit_code(exit_code);
|
||||
self.set_status(&new_status).await;
|
||||
|
||||
tracing::info!(
|
||||
"[ShellController] 控制器已停止: block_id={}, exit_code={}",
|
||||
self.block_id,
|
||||
exit_code
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取运行时状态
|
||||
///
|
||||
/// # 返回
|
||||
/// 当前控制器的运行时状态
|
||||
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
|
||||
// 使用 try_read 避免死锁,如果无法获取锁则返回默认值
|
||||
let status = self
|
||||
.proc_status
|
||||
.try_read()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_else(|_| "init".to_string());
|
||||
|
||||
let conn_name = self.conn_name.try_read().map(|n| n.clone()).unwrap_or(None);
|
||||
|
||||
BlockControllerRuntimeStatus {
|
||||
block_id: self.block_id.clone(),
|
||||
version: self.status_version.load(Ordering::SeqCst),
|
||||
shell_proc_status: status,
|
||||
shell_proc_conn_name: conn_name,
|
||||
shell_proc_exit_code: self.proc_exit_code.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送输入到控制器
|
||||
///
|
||||
/// 将输入数据发送到 Shell 进程。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `input`: 输入数据(键盘输入、信号、终端大小调整)
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError> {
|
||||
let tx = self.shell_input_tx.read().await;
|
||||
if let Some(sender) = tx.as_ref() {
|
||||
sender
|
||||
.send(input.clone())
|
||||
.await
|
||||
.map_err(|e| TerminalError::WriteFailed(format!("发送输入失败: {}", e)))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TerminalError::SessionClosed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取控制器类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 控制器类型字符串: "shell" | "cmd"
|
||||
fn controller_type(&self) -> &str {
|
||||
&self.controller_type
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 注意:完整的测试需要 Tauri 运行时环境,这里只测试基本逻辑
|
||||
|
||||
#[test]
|
||||
fn test_controller_status_event_from() {
|
||||
let status = BlockControllerRuntimeStatus {
|
||||
block_id: "test-block".to_string(),
|
||||
version: 5,
|
||||
shell_proc_status: "running".to_string(),
|
||||
shell_proc_conn_name: Some("ssh://user@host".to_string()),
|
||||
shell_proc_exit_code: 0,
|
||||
};
|
||||
|
||||
let event = ControllerStatusEvent::from(status);
|
||||
assert_eq!(event.block_id, "test-block");
|
||||
assert_eq!(event.version, 5);
|
||||
assert_eq!(event.shell_proc_status, "running");
|
||||
assert_eq!(
|
||||
event.shell_proc_conn_name,
|
||||
Some("ssh://user@host".to_string())
|
||||
);
|
||||
assert_eq!(event.shell_proc_exit_code, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! BlockController trait 定义
|
||||
//!
|
||||
//! 定义统一的块控制器接口,所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 定义 BlockController trait 接口
|
||||
//! - 定义 BlockControllerRuntimeStatus 运行时状态结构
|
||||
//! - 定义 BlockInputUnion 输入联合类型
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 1.1: 定义统一的 trait 接口
|
||||
//! - 1.8: 提供 get_runtime_status 方法
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::terminal::TerminalError;
|
||||
|
||||
/// 终端大小
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TermSize {
|
||||
/// 行数
|
||||
pub rows: u16,
|
||||
/// 列数
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
impl Default for TermSize {
|
||||
fn default() -> Self {
|
||||
Self { rows: 24, cols: 80 }
|
||||
}
|
||||
}
|
||||
|
||||
/// 块控制器运行时状态
|
||||
///
|
||||
/// 包含控制器的当前状态信息,用于前端显示和状态同步。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockControllerRuntimeStatus {
|
||||
/// 块 ID
|
||||
pub block_id: String,
|
||||
/// 状态版本号,每次状态变更递增
|
||||
pub version: i32,
|
||||
/// Shell 进程状态: "init" | "running" | "done"
|
||||
pub shell_proc_status: String,
|
||||
/// Shell 进程连接名称(用于 SSH/WSL)
|
||||
pub shell_proc_conn_name: Option<String>,
|
||||
/// Shell 进程退出码
|
||||
pub shell_proc_exit_code: i32,
|
||||
}
|
||||
|
||||
impl BlockControllerRuntimeStatus {
|
||||
/// 创建初始状态
|
||||
pub fn new(block_id: String) -> Self {
|
||||
Self {
|
||||
block_id,
|
||||
version: 0,
|
||||
shell_proc_status: "init".to_string(),
|
||||
shell_proc_conn_name: None,
|
||||
shell_proc_exit_code: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为初始状态
|
||||
pub fn is_init(&self) -> bool {
|
||||
self.shell_proc_status == "init"
|
||||
}
|
||||
|
||||
/// 检查是否正在运行
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.shell_proc_status == "running"
|
||||
}
|
||||
|
||||
/// 检查是否已完成
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.shell_proc_status == "done"
|
||||
}
|
||||
}
|
||||
|
||||
/// 块控制器输入联合类型
|
||||
///
|
||||
/// 封装发送给控制器的各种输入类型。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockInputUnion {
|
||||
/// 输入数据(键盘输入等)
|
||||
pub input_data: Option<Vec<u8>>,
|
||||
/// 信号名称(如 "SIGINT", "SIGTERM")
|
||||
pub sig_name: Option<String>,
|
||||
/// 终端大小调整
|
||||
pub term_size: Option<TermSize>,
|
||||
}
|
||||
|
||||
impl BlockInputUnion {
|
||||
/// 创建数据输入
|
||||
pub fn data(data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
input_data: Some(data),
|
||||
sig_name: None,
|
||||
term_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建信号输入
|
||||
pub fn signal(sig_name: &str) -> Self {
|
||||
Self {
|
||||
input_data: None,
|
||||
sig_name: Some(sig_name.to_string()),
|
||||
term_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建终端大小调整输入
|
||||
pub fn resize(rows: u16, cols: u16) -> Self {
|
||||
Self {
|
||||
input_data: None,
|
||||
sig_name: None,
|
||||
term_size: Some(TermSize { rows, cols }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 块元数据
|
||||
///
|
||||
/// 存储块的配置信息。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BlockMeta {
|
||||
/// 控制器类型: "shell" | "cmd"
|
||||
pub controller: Option<String>,
|
||||
/// 连接名称(用于 SSH/WSL)
|
||||
pub connection: Option<String>,
|
||||
/// 命令字符串
|
||||
pub cmd: Option<String>,
|
||||
/// 命令参数
|
||||
pub cmd_args: Option<Vec<String>>,
|
||||
/// 工作目录
|
||||
pub cmd_cwd: Option<String>,
|
||||
/// 环境变量
|
||||
pub cmd_env: Option<HashMap<String, String>>,
|
||||
/// 启动时自动运行
|
||||
pub cmd_run_on_start: Option<bool>,
|
||||
/// 仅运行一次
|
||||
pub cmd_run_once: Option<bool>,
|
||||
/// 启动前清空输出
|
||||
pub cmd_clear_on_start: Option<bool>,
|
||||
/// 退出后自动关闭
|
||||
pub cmd_close_on_exit: Option<bool>,
|
||||
/// 终端模式: "term" | "vdom"
|
||||
pub term_mode: Option<String>,
|
||||
/// 终端主题
|
||||
pub term_theme: Option<String>,
|
||||
/// 终端字体大小
|
||||
pub term_font_size: Option<f32>,
|
||||
/// 终端滚动缓冲区大小
|
||||
pub term_scrollback: Option<i32>,
|
||||
}
|
||||
|
||||
impl BlockMeta {
|
||||
/// 获取字符串字段
|
||||
pub fn get_string(&self, key: &str) -> String {
|
||||
match key {
|
||||
"controller" => self.controller.clone().unwrap_or_default(),
|
||||
"connection" => self.connection.clone().unwrap_or_default(),
|
||||
"cmd" => self.cmd.clone().unwrap_or_default(),
|
||||
"cmd_cwd" => self.cmd_cwd.clone().unwrap_or_default(),
|
||||
"term_mode" => self.term_mode.clone().unwrap_or_else(|| "term".to_string()),
|
||||
"term_theme" => self.term_theme.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行时选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeOpts {
|
||||
/// 终端大小
|
||||
pub term_size: TermSize,
|
||||
}
|
||||
|
||||
impl Default for RuntimeOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
term_size: TermSize::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 块控制器 trait
|
||||
///
|
||||
/// 所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。
|
||||
/// 提供统一的接口用于管理终端会话的生命周期。
|
||||
#[async_trait]
|
||||
pub trait BlockController: Send + Sync {
|
||||
/// 启动控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_meta`: 块元数据配置
|
||||
/// - `rt_opts`: 运行时选项(终端大小等)
|
||||
/// - `force`: 是否强制重启
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
async fn start(
|
||||
&mut self,
|
||||
block_meta: BlockMeta,
|
||||
rt_opts: Option<RuntimeOpts>,
|
||||
force: bool,
|
||||
) -> Result<(), TerminalError>;
|
||||
|
||||
/// 停止控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL)
|
||||
/// - `new_status`: 停止后的新状态
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError>;
|
||||
|
||||
/// 获取运行时状态
|
||||
///
|
||||
/// # 返回
|
||||
/// 当前控制器的运行时状态
|
||||
fn get_runtime_status(&self) -> BlockControllerRuntimeStatus;
|
||||
|
||||
/// 发送输入到控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `input`: 输入数据(键盘输入、信号、终端大小调整)
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError>;
|
||||
|
||||
/// 获取控制器类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 控制器类型字符串: "shell" | "cmd"
|
||||
fn controller_type(&self) -> &str;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_term_size_default() {
|
||||
let size = TermSize::default();
|
||||
assert_eq!(size.rows, 24);
|
||||
assert_eq!(size.cols, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_status_new() {
|
||||
let status = BlockControllerRuntimeStatus::new("test-block".to_string());
|
||||
assert_eq!(status.block_id, "test-block");
|
||||
assert_eq!(status.version, 0);
|
||||
assert_eq!(status.shell_proc_status, "init");
|
||||
assert!(status.is_init());
|
||||
assert!(!status.is_running());
|
||||
assert!(!status.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_input_union_data() {
|
||||
let input = BlockInputUnion::data(vec![0x1b, 0x5b, 0x41]); // ESC [ A
|
||||
assert!(input.input_data.is_some());
|
||||
assert!(input.sig_name.is_none());
|
||||
assert!(input.term_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_input_union_signal() {
|
||||
let input = BlockInputUnion::signal("SIGINT");
|
||||
assert!(input.input_data.is_none());
|
||||
assert_eq!(input.sig_name, Some("SIGINT".to_string()));
|
||||
assert!(input.term_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_input_union_resize() {
|
||||
let input = BlockInputUnion::resize(30, 100);
|
||||
assert!(input.input_data.is_none());
|
||||
assert!(input.sig_name.is_none());
|
||||
assert_eq!(
|
||||
input.term_size,
|
||||
Some(TermSize {
|
||||
rows: 30,
|
||||
cols: 100
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_meta_get_string() {
|
||||
let meta = BlockMeta {
|
||||
controller: Some("shell".to_string()),
|
||||
connection: Some("ssh://user@host".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(meta.get_string("controller"), "shell");
|
||||
assert_eq!(meta.get_string("connection"), "ssh://user@host");
|
||||
assert_eq!(meta.get_string("cmd"), "");
|
||||
assert_eq!(meta.get_string("term_mode"), "term");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
# connections
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
连接模块,提供不同类型的终端连接实现。
|
||||
|
||||
**核心原则:**
|
||||
- 封装 PTY 进程管理
|
||||
- 支持本地、SSH、WSL 多种连接类型
|
||||
- 异步输入输出处理
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **ShellProc**: 本地 PTY 进程封装,支持 shell 和 cmd 模式
|
||||
- **SSHConn**: SSH 远程连接管理器,支持多种认证方式
|
||||
- **SSHShellProc**: SSH 远程 Shell 进程封装,支持远程 PTY 创建和数据转发
|
||||
- **WSLConn**: WSL 连接管理器(仅 Windows),支持发行版列表和 PTY 创建
|
||||
- **输出读取**: 异步读取 PTY 输出并通过 Tauri 事件推送
|
||||
- **输入处理**: 处理键盘输入、信号和终端大小调整
|
||||
- **块文件集成**: 自动保存输出到块文件
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口和类型导出
|
||||
- `local_pty.rs` - 本地 PTY 连接实现(ShellProc)
|
||||
- `ssh_connection.rs` - SSH 远程连接实现
|
||||
- `ssh_shell_proc.rs` - SSH 远程 Shell 进程实现
|
||||
- `wsl_connection.rs` - WSL 连接实现(仅 Windows)
|
||||
- `connection_router.rs` - 连接类型路由和工厂模式
|
||||
|
||||
## ShellProc 功能
|
||||
|
||||
### 创建进程
|
||||
|
||||
```rust
|
||||
let shell_proc = ShellProc::new(
|
||||
block_id,
|
||||
controller_type, // "shell" | "cmd"
|
||||
rows,
|
||||
cols,
|
||||
app_handle,
|
||||
block_meta,
|
||||
input_rx,
|
||||
block_file,
|
||||
).await?;
|
||||
```
|
||||
|
||||
### 支持的命令模式
|
||||
|
||||
- **shell 模式**: 启动用户默认 Shell($SHELL 或 /bin/bash)
|
||||
- **cmd 模式**: 执行指定命令,支持参数和环境变量
|
||||
|
||||
### 环境变量
|
||||
|
||||
自动设置以下环境变量:
|
||||
- `TERM=xterm-256color`
|
||||
- `COLORTERM=truecolor`
|
||||
- 自定义环境变量(通过 `cmd_env`)
|
||||
|
||||
### 进程控制
|
||||
|
||||
- `terminate()`: 优雅终止(SIGTERM)
|
||||
- `kill()`: 强制终止(SIGKILL)
|
||||
- `resize()`: 调整终端大小
|
||||
- `write()`: 写入数据到 PTY
|
||||
|
||||
## SSH 连接功能
|
||||
|
||||
### 连接字符串解析
|
||||
|
||||
```rust
|
||||
// 支持多种格式
|
||||
let opts = SSHOpts::parse("user@host:port")?;
|
||||
let opts = SSHOpts::parse("ssh://user@host")?;
|
||||
let opts = SSHOpts::parse("[::1]:22")?; // IPv6
|
||||
```
|
||||
|
||||
### 连接状态管理
|
||||
|
||||
```rust
|
||||
let conn = SSHConn::new(opts);
|
||||
conn.connect(&conn_flags).await?;
|
||||
conn.authenticate(&auth_methods).await?;
|
||||
let status = conn.derive_conn_status();
|
||||
```
|
||||
|
||||
### 支持的认证方式
|
||||
|
||||
- 公钥认证(密钥文件)
|
||||
- SSH Agent 认证
|
||||
- 密码认证
|
||||
- 键盘交互认证(待完善)
|
||||
|
||||
### SSH 配置文件解析
|
||||
|
||||
支持从 `~/.ssh/config` 读取连接配置:
|
||||
|
||||
```rust
|
||||
// 获取主机配置(自动合并通配符配置)
|
||||
let config = SSHConfigParser::get_host_config("myserver")?;
|
||||
|
||||
// 从指定文件获取配置
|
||||
let config = SSHConfigParser::get_host_config_from_file(&path, "myserver")?;
|
||||
|
||||
// 解析配置内容
|
||||
let hosts = SSHConfigParser::parse_config_content(content)?;
|
||||
```
|
||||
|
||||
#### 支持的配置选项
|
||||
|
||||
- **基础选项**: HostName, User, Port, IdentityFile
|
||||
- **认证选项**: PubkeyAuthentication, PasswordAuthentication, KbdInteractiveAuthentication, PreferredAuthentications
|
||||
- **连接选项**: ConnectTimeout, ServerAliveInterval, ServerAliveCountMax, Compression
|
||||
- **代理选项**: ProxyJump, ProxyCommand
|
||||
- **转发选项**: LocalForward, RemoteForward, DynamicForward, ForwardAgent
|
||||
- **其他选项**: BatchMode, StrictHostKeyChecking, RequestTTY, RemoteCommand, SendEnv, SetEnv
|
||||
|
||||
#### 配置合并语义
|
||||
|
||||
遵循 SSH 配置文件的 "first match wins" 语义:
|
||||
- 按顺序遍历所有 Host 块
|
||||
- 如果模式匹配,合并配置(第一个匹配的值优先)
|
||||
- 通配符 `*` 匹配所有主机
|
||||
- IdentityFile 是累加的(不覆盖)
|
||||
|
||||
#### 通配符模式匹配
|
||||
|
||||
支持以下模式:
|
||||
- `*` - 匹配所有
|
||||
- `*.example.com` - 后缀匹配
|
||||
- `server*` - 前缀匹配
|
||||
- `?` - 匹配单个字符
|
||||
- `!pattern` - 否定匹配(排除)
|
||||
|
||||
### ProxyJump 支持
|
||||
|
||||
支持跳板机配置,最大深度为 10:
|
||||
|
||||
```rust
|
||||
// 解析 ProxyJump 链
|
||||
let chain = SSHConfigParser::parse_proxy_jump_chain("jump1.com, user@jump2.com:2222");
|
||||
|
||||
// 解析单个跳板机
|
||||
let opts = SSHConfigParser::parse_proxy_jump_host("user@jump.example.com:2222")?;
|
||||
|
||||
// 解析完整的 ProxyJump 链(递归解析每个跳板机的配置)
|
||||
let chain = SSHConfigParser::resolve_proxy_jump_chain("bastion@jump.example.com", 0)?;
|
||||
```
|
||||
|
||||
## SSH 远程 Shell 进程功能
|
||||
|
||||
### 创建远程 Shell 进程
|
||||
|
||||
```rust
|
||||
// 从 SSH 会话创建
|
||||
let ssh_proc = SSHShellProc::new(
|
||||
block_id,
|
||||
controller_type, // "shell" | "cmd"
|
||||
&session,
|
||||
rows,
|
||||
cols,
|
||||
app_handle,
|
||||
block_meta,
|
||||
input_rx,
|
||||
block_file,
|
||||
).await?;
|
||||
|
||||
// 从 SSHConn 创建(便捷方法)
|
||||
let ssh_proc = SSHShellProc::from_ssh_conn(
|
||||
block_id,
|
||||
controller_type,
|
||||
&ssh_conn,
|
||||
rows,
|
||||
cols,
|
||||
app_handle,
|
||||
block_meta,
|
||||
input_rx,
|
||||
block_file,
|
||||
).await?;
|
||||
```
|
||||
|
||||
### 远程 PTY 功能
|
||||
|
||||
- **PTY 请求**: 使用 xterm-256color 终端类型
|
||||
- **Shell 模式**: 启动远程交互式 Shell
|
||||
- **Cmd 模式**: 执行远程命令,支持工作目录和环境变量
|
||||
- **数据转发**: 异步读取远程输出并推送到前端
|
||||
- **终端大小同步**: 自动同步终端大小到远程 PTY
|
||||
|
||||
### 进程控制
|
||||
|
||||
- `terminate()`: 优雅终止(发送 Ctrl+C 并关闭 Channel)
|
||||
- `kill()`: 强制终止(直接关闭 Channel)
|
||||
- `resize()`: 调整远程 PTY 大小
|
||||
- `write()`: 写入数据到远程 PTY
|
||||
- `send_eof()`: 发送 EOF 到远程
|
||||
|
||||
### 信号处理
|
||||
|
||||
由于 ssh2 crate 限制,信号通过控制字符发送:
|
||||
- `SIGINT`: 发送 Ctrl+C (0x03)
|
||||
- `SIGQUIT`: 发送 Ctrl+\ (0x1C)
|
||||
|
||||
## WSL 连接功能(仅 Windows)
|
||||
|
||||
### 连接字符串解析
|
||||
|
||||
```rust
|
||||
// 支持多种格式
|
||||
let opts = WSLOpts::parse("wsl://")?; // 默认发行版
|
||||
let opts = WSLOpts::parse("wsl://Ubuntu")?; // 指定发行版
|
||||
let opts = WSLOpts::parse("wsl://Ubuntu/home/user")?; // 指定路径
|
||||
```
|
||||
|
||||
### 发行版管理
|
||||
|
||||
```rust
|
||||
// 列出所有可用的 WSL 发行版
|
||||
let distros = WSLConn::list_distros()?;
|
||||
|
||||
// 获取默认发行版
|
||||
let default = WSLConn::get_default_distro()?;
|
||||
|
||||
// 检查 WSL 是否可用
|
||||
let available = WSLConn::is_wsl_available();
|
||||
```
|
||||
|
||||
### 连接状态管理
|
||||
|
||||
```rust
|
||||
let conn = WSLConn::new(opts);
|
||||
conn.connect().await?;
|
||||
let status = conn.derive_conn_status();
|
||||
```
|
||||
|
||||
### WSL Shell 进程
|
||||
|
||||
```rust
|
||||
let wsl_proc = WSLShellProc::new(
|
||||
block_id,
|
||||
opts,
|
||||
rows,
|
||||
cols,
|
||||
app_handle,
|
||||
block_meta,
|
||||
input_rx,
|
||||
block_file,
|
||||
).await?;
|
||||
```
|
||||
|
||||
### 连接类型检测
|
||||
|
||||
```rust
|
||||
// 检测连接类型
|
||||
is_local_conn_name("local"); // true
|
||||
is_ssh_conn_name("user@host"); // true
|
||||
is_wsl_conn_name("wsl://Ubuntu"); // true
|
||||
```
|
||||
|
||||
## 连接类型路由
|
||||
|
||||
### 自动路由
|
||||
|
||||
根据连接名称自动选择连接类型:
|
||||
|
||||
```rust
|
||||
use crate::terminal::connections::{ConnectionRouter, ConnectionType};
|
||||
|
||||
// 自动路由
|
||||
let conn_type = ConnectionRouter::route(""); // Local
|
||||
let conn_type = ConnectionRouter::route("local"); // Local
|
||||
let conn_type = ConnectionRouter::route("user@host"); // SSH
|
||||
let conn_type = ConnectionRouter::route("wsl://Ubuntu"); // WSL
|
||||
```
|
||||
|
||||
### 路由规则
|
||||
|
||||
1. 空字符串或 "local" → `ConnectionType::Local`
|
||||
2. 以 "wsl://" 开头或等于 "wsl" → `ConnectionType::WSL`
|
||||
3. 以 "ssh://" 开头、包含 "@" 或其他非本地/WSL 格式 → `ConnectionType::SSH`
|
||||
|
||||
### 连接验证
|
||||
|
||||
```rust
|
||||
// 验证连接名称格式
|
||||
let conn_type = ConnectionRouter::validate("user@host:22")?;
|
||||
|
||||
// 检查连接类型是否在当前平台可用
|
||||
let available = ConnectionRouter::is_available(ConnectionType::WSL);
|
||||
```
|
||||
|
||||
### 连接信息
|
||||
|
||||
```rust
|
||||
// 获取完整的连接信息
|
||||
let info = ConnectionInfo::from_conn_name("user@host");
|
||||
println!("类型: {}", info.conn_type); // ssh
|
||||
println!("可用: {}", info.available); // true
|
||||
println!("描述: {}", info.description); // SSH 远程连接
|
||||
```
|
||||
|
||||
## 事件
|
||||
|
||||
通过 Tauri 事件系统发送:
|
||||
- `terminal:output` - 终端输出数据(Base64 编码)
|
||||
- `terminal:status` - 终端状态变化
|
||||
- `terminal:conn-change` - 连接状态变化
|
||||
|
||||
## 依赖
|
||||
|
||||
- `portable-pty` - 跨平台 PTY 支持
|
||||
- `ssh2` - SSH 协议支持
|
||||
- `tokio` - 异步运行时
|
||||
- `base64` - 数据编码
|
||||
- `parking_lot` - 高性能锁
|
||||
|
||||
## Requirements 覆盖
|
||||
|
||||
### 连接类型路由 (connection_router.rs)
|
||||
- 1.4: 创建 SSH 终端时使用 SSH_Connection 建立远程连接
|
||||
- 1.5: 创建 WSL 终端时使用 WSL_Connection 建立连接
|
||||
|
||||
### 本地 PTY (local_pty.rs)
|
||||
- 16.1: cmd 命令字符串配置
|
||||
- 16.2: cmd_args 参数配置
|
||||
- 16.3: cmd_cwd 工作目录配置
|
||||
- 17.1: Shell 进程生命周期管理
|
||||
- 17.2: 环境变量设置
|
||||
- 17.3: 优雅终止和强制终止
|
||||
- 17.4: 退出码记录
|
||||
|
||||
### SSH 连接 (ssh_connection.rs)
|
||||
- 4.1: SSH 连接字符串解析
|
||||
- 4.3-4.6: 多种认证方式
|
||||
- 4.7: ProxyJump 跳板机配置支持
|
||||
- 4.10: 连接断开处理
|
||||
- 4.12: SSH 配置文件解析(~/.ssh/config)
|
||||
- 7.1-7.7: 连接状态管理
|
||||
|
||||
### SSH 远程 Shell 进程 (ssh_shell_proc.rs)
|
||||
- 4.2: SSH 连接建立成功时创建远程 PTY 会话
|
||||
- 4.7: 支持 ProxyJump 配置(通过 SSHConn)
|
||||
- 4.11: 用户调整终端大小时同步调整远程 PTY 大小
|
||||
|
||||
### WSL 连接 (wsl_connection.rs)
|
||||
- 5.1: 连接到指定的 WSL 发行版
|
||||
- 5.2: 创建 PTY 会话
|
||||
- 5.3: 列出所有可用的 WSL 发行版
|
||||
- 5.4: 连接断开处理和重连
|
||||
- 5.6: 终端大小同步
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,434 @@
|
||||
//! 连接类型路由模块
|
||||
//!
|
||||
//! 根据连接名称自动选择连接类型,实现连接工厂模式。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 根据连接名称自动路由到正确的连接类型
|
||||
//! - 提供连接工厂函数创建相应的连接
|
||||
//! - 支持本地 PTY、SSH、WSL 三种连接类型
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 1.4: 创建 SSH 终端时使用 SSH_Connection 建立远程连接
|
||||
//! - 1.5: 创建 WSL 终端时使用 WSL_Connection 建立连接
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{is_local_conn_name, is_ssh_conn_name, is_wsl_conn_name};
|
||||
use crate::terminal::error::TerminalError;
|
||||
|
||||
// ============================================================================
|
||||
// 连接类型枚举
|
||||
// ============================================================================
|
||||
|
||||
/// 连接类型
|
||||
///
|
||||
/// 表示终端会话可以使用的连接类型。
|
||||
///
|
||||
/// _Requirements: 1.4, 1.5_
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionType {
|
||||
/// 本地 PTY 连接
|
||||
Local,
|
||||
/// SSH 远程连接
|
||||
SSH,
|
||||
/// WSL 连接(仅 Windows)
|
||||
WSL,
|
||||
}
|
||||
|
||||
impl Default for ConnectionType {
|
||||
fn default() -> Self {
|
||||
Self::Local
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Local => write!(f, "local"),
|
||||
Self::SSH => write!(f, "ssh"),
|
||||
Self::WSL => write!(f, "wsl"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ConnectionType {
|
||||
type Err = TerminalError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"local" | "" => Ok(Self::Local),
|
||||
"ssh" => Ok(Self::SSH),
|
||||
"wsl" => Ok(Self::WSL),
|
||||
_ => Err(TerminalError::InvalidConnectionType(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 连接路由器
|
||||
// ============================================================================
|
||||
|
||||
/// 连接路由器
|
||||
///
|
||||
/// 根据连接名称自动选择连接类型。
|
||||
///
|
||||
/// ## 路由规则
|
||||
/// 1. 空字符串或 "local" → Local
|
||||
/// 2. 以 "wsl://" 开头或等于 "wsl" → WSL
|
||||
/// 3. 以 "ssh://" 开头、包含 "@" 或其他非本地/WSL 格式 → SSH
|
||||
///
|
||||
/// _Requirements: 1.4, 1.5_
|
||||
pub struct ConnectionRouter;
|
||||
|
||||
impl ConnectionRouter {
|
||||
/// 根据连接名称确定连接类型
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_name`: 连接名称
|
||||
///
|
||||
/// # 返回
|
||||
/// 对应的连接类型
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// use proxycast::terminal::connections::ConnectionRouter;
|
||||
///
|
||||
/// assert_eq!(ConnectionRouter::route(""), ConnectionType::Local);
|
||||
/// assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local);
|
||||
/// assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL);
|
||||
/// assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH);
|
||||
/// ```
|
||||
///
|
||||
/// _Requirements: 1.4, 1.5_
|
||||
pub fn route(conn_name: &str) -> ConnectionType {
|
||||
let conn_name = conn_name.trim();
|
||||
|
||||
// 1. 检查是否为本地连接
|
||||
if is_local_conn_name(conn_name) {
|
||||
return ConnectionType::Local;
|
||||
}
|
||||
|
||||
// 2. 检查是否为 WSL 连接
|
||||
if is_wsl_conn_name(conn_name) {
|
||||
return ConnectionType::WSL;
|
||||
}
|
||||
|
||||
// 3. 检查是否为 SSH 连接
|
||||
if is_ssh_conn_name(conn_name) {
|
||||
return ConnectionType::SSH;
|
||||
}
|
||||
|
||||
// 4. 默认为本地连接
|
||||
ConnectionType::Local
|
||||
}
|
||||
|
||||
/// 验证连接名称格式是否有效
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_name`: 连接名称
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(ConnectionType)`: 连接名称有效,返回对应的连接类型
|
||||
/// - `Err(TerminalError)`: 连接名称无效
|
||||
pub fn validate(conn_name: &str) -> Result<ConnectionType, TerminalError> {
|
||||
let conn_name = conn_name.trim();
|
||||
let conn_type = Self::route(conn_name);
|
||||
|
||||
// 对于 SSH 连接,验证格式
|
||||
if conn_type == ConnectionType::SSH {
|
||||
// 尝试解析 SSH 连接字符串
|
||||
use super::SSHOpts;
|
||||
SSHOpts::parse(conn_name)?;
|
||||
}
|
||||
|
||||
// 对于 WSL 连接,验证格式
|
||||
if conn_type == ConnectionType::WSL {
|
||||
use super::WSLOpts;
|
||||
WSLOpts::parse(conn_name)?;
|
||||
}
|
||||
|
||||
Ok(conn_type)
|
||||
}
|
||||
|
||||
/// 检查连接类型是否在当前平台上可用
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_type`: 连接类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 连接类型是否可用
|
||||
pub fn is_available(conn_type: ConnectionType) -> bool {
|
||||
match conn_type {
|
||||
ConnectionType::Local => true,
|
||||
ConnectionType::SSH => true, // SSH 在所有平台上可用
|
||||
ConnectionType::WSL => cfg!(target_os = "windows"), // WSL 仅在 Windows 上可用
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取连接类型的描述
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_type`: 连接类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 连接类型的人类可读描述
|
||||
pub fn description(conn_type: ConnectionType) -> &'static str {
|
||||
match conn_type {
|
||||
ConnectionType::Local => "本地终端",
|
||||
ConnectionType::SSH => "SSH 远程连接",
|
||||
ConnectionType::WSL => "Windows Subsystem for Linux",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 连接信息
|
||||
// ============================================================================
|
||||
|
||||
/// 连接信息
|
||||
///
|
||||
/// 包含解析后的连接详情。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionInfo {
|
||||
/// 原始连接名称
|
||||
pub conn_name: String,
|
||||
/// 连接类型
|
||||
pub conn_type: ConnectionType,
|
||||
/// 是否在当前平台可用
|
||||
pub available: bool,
|
||||
/// 连接描述
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl ConnectionInfo {
|
||||
/// 从连接名称创建连接信息
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn_name`: 连接名称
|
||||
///
|
||||
/// # 返回
|
||||
/// 连接信息
|
||||
pub fn from_conn_name(conn_name: &str) -> Self {
|
||||
let conn_type = ConnectionRouter::route(conn_name);
|
||||
Self {
|
||||
conn_name: conn_name.to_string(),
|
||||
conn_type,
|
||||
available: ConnectionRouter::is_available(conn_type),
|
||||
description: ConnectionRouter::description(conn_type).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ========================================================================
|
||||
// ConnectionType 测试
|
||||
// ========================================================================
|
||||
|
||||
mod connection_type_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default() {
|
||||
assert_eq!(ConnectionType::default(), ConnectionType::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
assert_eq!(ConnectionType::Local.to_string(), "local");
|
||||
assert_eq!(ConnectionType::SSH.to_string(), "ssh");
|
||||
assert_eq!(ConnectionType::WSL.to_string(), "wsl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str() {
|
||||
assert_eq!(
|
||||
"local".parse::<ConnectionType>().unwrap(),
|
||||
ConnectionType::Local
|
||||
);
|
||||
assert_eq!("".parse::<ConnectionType>().unwrap(), ConnectionType::Local);
|
||||
assert_eq!(
|
||||
"ssh".parse::<ConnectionType>().unwrap(),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
assert_eq!(
|
||||
"SSH".parse::<ConnectionType>().unwrap(),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
assert_eq!(
|
||||
"wsl".parse::<ConnectionType>().unwrap(),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
assert_eq!(
|
||||
"WSL".parse::<ConnectionType>().unwrap(),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_invalid() {
|
||||
assert!("invalid".parse::<ConnectionType>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ConnectionRouter 测试
|
||||
// ========================================================================
|
||||
|
||||
mod connection_router_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_route_local() {
|
||||
assert_eq!(ConnectionRouter::route(""), ConnectionType::Local);
|
||||
assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local);
|
||||
assert_eq!(ConnectionRouter::route(" "), ConnectionType::Local);
|
||||
assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_wsl() {
|
||||
assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL);
|
||||
assert_eq!(ConnectionRouter::route("wsl://Debian"), ConnectionType::WSL);
|
||||
assert_eq!(ConnectionRouter::route("wsl://"), ConnectionType::WSL);
|
||||
assert_eq!(ConnectionRouter::route("wsl"), ConnectionType::WSL);
|
||||
assert_eq!(ConnectionRouter::route("WSL"), ConnectionType::WSL);
|
||||
assert_eq!(
|
||||
ConnectionRouter::route(" wsl://Ubuntu "),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_ssh() {
|
||||
assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH);
|
||||
assert_eq!(ConnectionRouter::route("user@host:22"), ConnectionType::SSH);
|
||||
assert_eq!(
|
||||
ConnectionRouter::route("ssh://user@host"),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
assert_eq!(ConnectionRouter::route("ssh://host"), ConnectionType::SSH);
|
||||
assert_eq!(ConnectionRouter::route("example.com"), ConnectionType::SSH);
|
||||
assert_eq!(ConnectionRouter::route("192.168.1.1"), ConnectionType::SSH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_with_whitespace() {
|
||||
assert_eq!(
|
||||
ConnectionRouter::route(" user@host "),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::route(" wsl://Ubuntu "),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_local() {
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("").unwrap(),
|
||||
ConnectionType::Local
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("local").unwrap(),
|
||||
ConnectionType::Local
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ssh() {
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("user@host").unwrap(),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("user@host:22").unwrap(),
|
||||
ConnectionType::SSH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_wsl() {
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("wsl://Ubuntu").unwrap(),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::validate("wsl").unwrap(),
|
||||
ConnectionType::WSL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_available() {
|
||||
assert!(ConnectionRouter::is_available(ConnectionType::Local));
|
||||
assert!(ConnectionRouter::is_available(ConnectionType::SSH));
|
||||
// WSL 可用性取决于平台
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(ConnectionRouter::is_available(ConnectionType::WSL));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert!(!ConnectionRouter::is_available(ConnectionType::WSL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
assert_eq!(
|
||||
ConnectionRouter::description(ConnectionType::Local),
|
||||
"本地终端"
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::description(ConnectionType::SSH),
|
||||
"SSH 远程连接"
|
||||
);
|
||||
assert_eq!(
|
||||
ConnectionRouter::description(ConnectionType::WSL),
|
||||
"Windows Subsystem for Linux"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ConnectionInfo 测试
|
||||
// ========================================================================
|
||||
|
||||
mod connection_info_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_from_conn_name_local() {
|
||||
let info = ConnectionInfo::from_conn_name("");
|
||||
assert_eq!(info.conn_name, "");
|
||||
assert_eq!(info.conn_type, ConnectionType::Local);
|
||||
assert!(info.available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_conn_name_ssh() {
|
||||
let info = ConnectionInfo::from_conn_name("user@host");
|
||||
assert_eq!(info.conn_name, "user@host");
|
||||
assert_eq!(info.conn_type, ConnectionType::SSH);
|
||||
assert!(info.available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_conn_name_wsl() {
|
||||
let info = ConnectionInfo::from_conn_name("wsl://Ubuntu");
|
||||
assert_eq!(info.conn_name, "wsl://Ubuntu");
|
||||
assert_eq!(info.conn_type, ConnectionType::WSL);
|
||||
// WSL 可用性取决于平台
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(info.available);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert!(!info.available);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! 本地 PTY 连接实现
|
||||
//!
|
||||
//! 封装本地 PTY 进程,提供 ShellProc 结构体供 ShellController 使用。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 创建和管理本地 PTY 子进程
|
||||
//! - 异步读取 PTY 输出并通过 Tauri Event 推送
|
||||
//! - 处理 PTY 输入写入
|
||||
//! - 监控进程退出状态
|
||||
//! - 支持命令执行模式(cmd)
|
||||
//! - 支持 Shell 集成脚本加载
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 17.1: 管理 Shell 进程的完整生命周期
|
||||
//! - 17.2: 设置正确的环境变量
|
||||
//! - 17.3: 支持优雅终止和强制终止
|
||||
//! - 17.4: 记录退出码并更新状态
|
||||
//! - 17.5: 支持自定义 Shell 路径和参数
|
||||
//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录
|
||||
//! - 17.9: bash 使用 --rcfile 加载集成脚本
|
||||
//! - 17.10: fish 使用 -C 参数 source 集成脚本
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use parking_lot::Mutex;
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::terminal::block_controller::{BlockInputUnion, BlockMeta};
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::events::{
|
||||
event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent,
|
||||
};
|
||||
use crate::terminal::integration::{ShellLaunchBuilder, ShellType};
|
||||
use crate::terminal::persistence::BlockFile;
|
||||
|
||||
/// Shell 进程封装
|
||||
///
|
||||
/// 封装 PTY 进程,提供输入输出和生命周期管理。
|
||||
pub struct ShellProc {
|
||||
/// Block ID
|
||||
block_id: String,
|
||||
/// 控制器类型
|
||||
controller_type: String,
|
||||
/// PTY 写入器
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
/// PTY Master(用于调整大小)
|
||||
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
|
||||
/// 关闭标志
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
/// 进程退出码
|
||||
exit_code: Arc<AtomicI32>,
|
||||
/// 是否已退出
|
||||
exited: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ShellProc {
|
||||
/// 创建新的 Shell 进程
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: Block ID
|
||||
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
|
||||
/// - `rows`: 终端行数
|
||||
/// - `cols`: 终端列数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_meta`: 块元数据配置
|
||||
/// - `input_rx`: 输入接收器
|
||||
/// - `block_file`: 块文件存储(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(ShellProc)`: 创建成功
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 17.1, 17.2, 17.8, 17.9, 17.10_
|
||||
pub async fn new(
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_meta: BlockMeta,
|
||||
input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) -> Result<Self, TerminalError> {
|
||||
tracing::info!(
|
||||
"[ShellProc] 创建进程: block_id={}, type={}, size={}x{}",
|
||||
block_id,
|
||||
controller_type,
|
||||
cols,
|
||||
rows
|
||||
);
|
||||
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
// 创建 PTY
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
|
||||
|
||||
// 构建命令(传递 app_handle 和 block_id 用于 Shell 集成)
|
||||
let cmd = Self::build_command(&controller_type, &block_meta, &app_handle, &block_id)?;
|
||||
|
||||
// 启动子进程
|
||||
let _child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
|
||||
|
||||
// 获取写入器
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
|
||||
|
||||
// 获取读取器
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?;
|
||||
|
||||
// 创建共享状态
|
||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
let exit_code = Arc::new(AtomicI32::new(0));
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
let master = Arc::new(Mutex::new(pair.master));
|
||||
|
||||
// 启动输出读取任务
|
||||
Self::spawn_output_reader(
|
||||
block_id.clone(),
|
||||
reader,
|
||||
app_handle.clone(),
|
||||
shutdown_flag.clone(),
|
||||
exit_code.clone(),
|
||||
exited.clone(),
|
||||
block_file,
|
||||
);
|
||||
|
||||
// 启动输入处理任务
|
||||
Self::spawn_input_handler(
|
||||
block_id.clone(),
|
||||
writer.clone(),
|
||||
master.clone(),
|
||||
input_rx,
|
||||
shutdown_flag.clone(),
|
||||
);
|
||||
|
||||
tracing::info!("[ShellProc] 进程已创建: block_id={}", block_id);
|
||||
|
||||
Ok(Self {
|
||||
block_id,
|
||||
controller_type,
|
||||
writer,
|
||||
master,
|
||||
shutdown_flag,
|
||||
exit_code,
|
||||
exited,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建命令
|
||||
///
|
||||
/// 根据控制器类型和块元数据构建要执行的命令。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `controller_type`: 控制器类型
|
||||
/// - `block_meta`: 块元数据
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_id`: Block ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 构建好的命令
|
||||
///
|
||||
/// _Requirements: 17.2, 17.5, 17.8, 17.9, 17.10_
|
||||
fn build_command(
|
||||
controller_type: &str,
|
||||
block_meta: &BlockMeta,
|
||||
app_handle: &tauri::AppHandle,
|
||||
block_id: &str,
|
||||
) -> Result<CommandBuilder, TerminalError> {
|
||||
let mut cmd = if controller_type == "cmd" {
|
||||
// 命令执行模式
|
||||
Self::build_cmd_command(block_meta)?
|
||||
} else {
|
||||
// Shell 模式 - 使用集成脚本
|
||||
Self::build_shell_command(block_meta, app_handle, block_id)?
|
||||
};
|
||||
|
||||
// 设置工作目录
|
||||
if let Some(cwd) = &block_meta.cmd_cwd {
|
||||
cmd.cwd(cwd);
|
||||
} else if let Some(home) = dirs::home_dir() {
|
||||
cmd.cwd(home);
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// 构建 Shell 命令
|
||||
///
|
||||
/// 使用 Shell 集成脚本构建启动命令。
|
||||
///
|
||||
/// _Requirements: 17.5, 17.7, 17.8, 17.9, 17.10_
|
||||
fn build_shell_command(
|
||||
block_meta: &BlockMeta,
|
||||
app_handle: &tauri::AppHandle,
|
||||
block_id: &str,
|
||||
) -> Result<CommandBuilder, TerminalError> {
|
||||
// 获取用户默认 shell
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
tracing::info!("[ShellProc] 使用 shell: {}", shell);
|
||||
|
||||
// 获取应用数据目录
|
||||
let app_data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| TerminalError::Internal(format!("获取应用数据目录失败: {}", e)))?;
|
||||
|
||||
// 使用 ShellLaunchBuilder 构建启动配置
|
||||
let builder = ShellLaunchBuilder::new(&app_data_dir, block_id.to_string());
|
||||
let launch_config = builder.build(&shell, block_meta.cmd_env.as_ref())?;
|
||||
|
||||
// 构建命令
|
||||
let mut cmd = CommandBuilder::new(&launch_config.shell_path);
|
||||
|
||||
// 添加参数
|
||||
for arg in &launch_config.args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
// 设置环境变量
|
||||
for (key, value) in &launch_config.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// 检测 Shell 类型并记录
|
||||
let shell_type = ShellType::from_path(&shell);
|
||||
tracing::info!(
|
||||
"[ShellProc] Shell 类型: {:?}, 参数: {:?}",
|
||||
shell_type,
|
||||
launch_config.args
|
||||
);
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// 构建命令执行命令
|
||||
///
|
||||
/// _Requirements: 16.1, 16.2, 16.3, 17.2_
|
||||
fn build_cmd_command(block_meta: &BlockMeta) -> Result<CommandBuilder, TerminalError> {
|
||||
let cmd_str = block_meta
|
||||
.cmd
|
||||
.as_ref()
|
||||
.ok_or_else(|| TerminalError::PtyCreationFailed("cmd 模式需要指定命令".to_string()))?;
|
||||
|
||||
tracing::info!("[ShellProc] 执行命令: {}", cmd_str);
|
||||
|
||||
// 使用 shell 执行命令
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.arg("-c");
|
||||
|
||||
// 构建完整命令字符串
|
||||
let full_cmd = if let Some(args) = &block_meta.cmd_args {
|
||||
format!("{} {}", cmd_str, args.join(" "))
|
||||
} else {
|
||||
cmd_str.clone()
|
||||
};
|
||||
|
||||
cmd.arg(&full_cmd);
|
||||
|
||||
// 设置通用环境变量
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
|
||||
// 设置自定义环境变量
|
||||
if let Some(env_vars) = &block_meta.cmd_env {
|
||||
for (key, value) in env_vars {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// 启动输出读取任务
|
||||
///
|
||||
/// 在独立线程中读取 PTY 输出,并通过 Tauri 事件发送到前端。
|
||||
fn spawn_output_reader(
|
||||
block_id: String,
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
app_handle: tauri::AppHandle,
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
exit_code: Arc<AtomicI32>,
|
||||
exited: Arc<AtomicBool>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
|
||||
loop {
|
||||
// 检查关闭标志
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
tracing::debug!("[ShellProc] 收到关闭信号: block_id={}", block_id);
|
||||
break;
|
||||
}
|
||||
|
||||
// 读取输出
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => {
|
||||
// EOF,进程已退出
|
||||
tracing::info!("[ShellProc] 进程已退出: block_id={}", block_id);
|
||||
exited.store(true, Ordering::SeqCst);
|
||||
|
||||
// 发送状态事件
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_STATUS,
|
||||
TerminalStatusEvent {
|
||||
session_id: block_id.clone(),
|
||||
status: SessionStatus::Done,
|
||||
exit_code: Some(exit_code.load(Ordering::SeqCst)),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let output_data = &buffer[..n];
|
||||
|
||||
// 保存到块文件
|
||||
if let Some(ref bf) = block_file {
|
||||
if let Err(e) = bf.append_data(output_data) {
|
||||
tracing::warn!(
|
||||
"[ShellProc] 写入块文件失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送输出事件
|
||||
let data = BASE64.encode(output_data);
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_OUTPUT,
|
||||
TerminalOutputEvent {
|
||||
session_id: block_id.clone(),
|
||||
data,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// 检查是否是因为关闭导致的错误
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::error!("[ShellProc] 读取错误: block_id={}, error={}", block_id, e);
|
||||
exited.store(true, Ordering::SeqCst);
|
||||
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_STATUS,
|
||||
TerminalStatusEvent {
|
||||
session_id: block_id.clone(),
|
||||
status: SessionStatus::Error,
|
||||
exit_code: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 启动输入处理任务
|
||||
///
|
||||
/// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。
|
||||
fn spawn_input_handler(
|
||||
block_id: String,
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
master: Arc<Mutex<Box<dyn portable_pty::MasterPty + Send>>>,
|
||||
mut input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(input) = input_rx.recv().await {
|
||||
// 检查关闭标志
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 处理输入数据
|
||||
if let Some(data) = &input.input_data {
|
||||
let mut w = writer.lock();
|
||||
if let Err(e) = w.write_all(data) {
|
||||
tracing::error!("[ShellProc] 写入失败: block_id={}, error={}", block_id, e);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = w.flush() {
|
||||
tracing::error!(
|
||||
"[ShellProc] Flush 失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理终端大小调整
|
||||
if let Some(size) = &input.term_size {
|
||||
let m = master.lock();
|
||||
if let Err(e) = m.resize(PtySize {
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}) {
|
||||
tracing::error!(
|
||||
"[ShellProc] 调整大小失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[ShellProc] 调整大小: block_id={}, size={}x{}",
|
||||
block_id,
|
||||
size.cols,
|
||||
size.rows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理信号
|
||||
if let Some(sig_name) = &input.sig_name {
|
||||
tracing::debug!(
|
||||
"[ShellProc] 收到信号: block_id={}, signal={}",
|
||||
block_id,
|
||||
sig_name
|
||||
);
|
||||
// TODO: 实现信号发送
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("[ShellProc] 输入处理任务结束: block_id={}", block_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取 Block ID
|
||||
pub fn block_id(&self) -> &str {
|
||||
&self.block_id
|
||||
}
|
||||
|
||||
/// 获取控制器类型
|
||||
pub fn controller_type(&self) -> &str {
|
||||
&self.controller_type
|
||||
}
|
||||
|
||||
/// 检查进程是否已退出
|
||||
pub fn is_exited(&self) -> bool {
|
||||
self.exited.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 获取退出码
|
||||
pub fn get_exit_code(&self) -> i32 {
|
||||
self.exit_code.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 写入数据到 PTY
|
||||
pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
|
||||
let mut writer = self.writer.lock();
|
||||
writer
|
||||
.write_all(data)
|
||||
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 调整 PTY 大小
|
||||
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> {
|
||||
let master = self.master.lock();
|
||||
master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
|
||||
tracing::debug!(
|
||||
"[ShellProc] 调整大小: block_id={}, size={}x{}",
|
||||
self.block_id,
|
||||
cols,
|
||||
rows
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 优雅终止进程
|
||||
///
|
||||
/// 发送 SIGTERM 信号并等待进程退出。
|
||||
///
|
||||
/// # 返回
|
||||
/// 进程退出码
|
||||
///
|
||||
/// _Requirements: 17.3_
|
||||
pub async fn terminate(&self) -> i32 {
|
||||
tracing::info!("[ShellProc] 优雅终止进程: block_id={}", self.block_id);
|
||||
|
||||
// 设置关闭标志
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// TODO: 发送 SIGTERM 信号
|
||||
// 目前 portable_pty 不直接支持发送信号,需要通过其他方式实现
|
||||
|
||||
// 等待一小段时间让进程退出
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
self.exit_code.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 强制终止进程
|
||||
///
|
||||
/// 发送 SIGKILL 信号立即终止进程。
|
||||
///
|
||||
/// _Requirements: 17.3_
|
||||
pub async fn kill(&self) {
|
||||
tracing::info!("[ShellProc] 强制终止进程: block_id={}", self.block_id);
|
||||
|
||||
// 设置关闭标志
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// TODO: 发送 SIGKILL 信号
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ShellProc {
|
||||
fn drop(&mut self) {
|
||||
// 确保关闭标志被设置
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
tracing::debug!("[ShellProc] 进程已销毁: block_id={}", self.block_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 连接模块
|
||||
//!
|
||||
//! 提供不同类型的终端连接实现:本地 PTY、SSH、WSL。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `local_pty` - 本地 PTY 连接
|
||||
//! - `ssh_connection` - SSH 远程连接
|
||||
//! - `ssh_shell_proc` - SSH 远程 Shell 进程
|
||||
//! - `wsl_connection` - WSL 连接(仅 Windows)
|
||||
//! - `connection_router` - 连接类型路由
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 本地 PTY 进程管理
|
||||
//! - SSH 远程连接和认证
|
||||
//! - SSH 远程 PTY 创建和数据转发
|
||||
//! - WSL 发行版连接
|
||||
//! - 连接类型自动路由
|
||||
|
||||
pub mod connection_router;
|
||||
pub mod local_pty;
|
||||
pub mod ssh_connection;
|
||||
pub mod ssh_shell_proc;
|
||||
pub mod wsl_connection;
|
||||
|
||||
pub use connection_router::{ConnectionInfo, ConnectionRouter, ConnectionType};
|
||||
pub use local_pty::ShellProc;
|
||||
pub use ssh_connection::{
|
||||
build_default_auth_methods, get_default_identity_files, is_local_conn_name,
|
||||
is_ssh_agent_available, is_ssh_conn_name, ConnKeywords, ConnStatus, ConnectionState,
|
||||
HostKeyVerification, NoOpAuthCallback, SSHAuthCallback, SSHAuthMethod, SSHConfigEntry,
|
||||
SSHConfigParser, SSHConn, SSHOpts, DEFAULT_SSH_PORT, MAX_PROXY_JUMP_DEPTH,
|
||||
};
|
||||
pub use ssh_shell_proc::SSHShellProc;
|
||||
pub use wsl_connection::{
|
||||
is_wsl_conn_name, WSLConn, WSLDistro, WSLDistroState, WSLOpts, WSLShellProc,
|
||||
DEFAULT_WSL_DISTRO, WSL_CONN_PREFIX,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,700 @@
|
||||
//! SSH 远程 Shell 进程实现
|
||||
//!
|
||||
//! 封装 SSH 远程 PTY 进程,提供 SSHShellProc 结构体供 ShellController 使用。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 创建和管理远程 PTY 会话
|
||||
//! - 异步读取远程 PTY 输出并通过 Tauri Event 推送
|
||||
//! - 处理远程 PTY 输入写入
|
||||
//! - 监控远程进程退出状态
|
||||
//! - 终端大小同步
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 4.2: SSH 连接建立成功时创建远程 PTY 会话
|
||||
//! - 4.7: 支持 ProxyJump 配置
|
||||
//! - 4.11: 用户调整终端大小时同步调整远程 PTY 大小
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use parking_lot::Mutex;
|
||||
use ssh2::{Channel, Session};
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::terminal::block_controller::{BlockInputUnion, BlockMeta, TermSize};
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::events::{
|
||||
event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent,
|
||||
};
|
||||
use crate::terminal::persistence::BlockFile;
|
||||
|
||||
use super::ssh_connection::SSHConn;
|
||||
|
||||
/// SSH Shell 进程封装
|
||||
///
|
||||
/// 封装 SSH 远程 PTY 进程,提供输入输出和生命周期管理。
|
||||
///
|
||||
/// _Requirements: 4.2, 4.11_
|
||||
pub struct SSHShellProc {
|
||||
/// Block ID
|
||||
block_id: String,
|
||||
/// 控制器类型
|
||||
controller_type: String,
|
||||
/// SSH Channel(用于数据传输)
|
||||
channel: Arc<Mutex<Channel>>,
|
||||
/// 关闭标志
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
/// 进程退出码
|
||||
exit_code: Arc<AtomicI32>,
|
||||
/// 是否已退出
|
||||
exited: Arc<AtomicBool>,
|
||||
/// 当前终端大小
|
||||
term_size: Arc<Mutex<TermSize>>,
|
||||
}
|
||||
|
||||
impl SSHShellProc {
|
||||
/// 创建新的 SSH Shell 进程
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: Block ID
|
||||
/// - `controller_type`: 控制器类型 ("shell" | "cmd")
|
||||
/// - `session`: SSH 会话
|
||||
/// - `rows`: 终端行数
|
||||
/// - `cols`: 终端列数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_meta`: 块元数据配置
|
||||
/// - `input_rx`: 输入接收器
|
||||
/// - `block_file`: 块文件存储(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(SSHShellProc)`: 创建成功
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 4.2_
|
||||
pub async fn new(
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
session: &Session,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_meta: BlockMeta,
|
||||
input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) -> Result<Self, TerminalError> {
|
||||
tracing::info!(
|
||||
"[SSHShellProc] 创建远程进程: block_id={}, type={}, size={}x{}",
|
||||
block_id,
|
||||
controller_type,
|
||||
cols,
|
||||
rows
|
||||
);
|
||||
|
||||
// 创建 SSH Channel
|
||||
let mut channel = session.channel_session().map_err(|e| {
|
||||
TerminalError::SSHConnectionFailed(format!("创建 SSH Channel 失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 请求 PTY
|
||||
// 使用 xterm-256color 终端类型
|
||||
channel
|
||||
.request_pty(
|
||||
"xterm-256color",
|
||||
None,
|
||||
Some((cols as u32, rows as u32, 0, 0)),
|
||||
)
|
||||
.map_err(|e| TerminalError::SSHConnectionFailed(format!("请求远程 PTY 失败: {}", e)))?;
|
||||
|
||||
// 根据控制器类型启动 Shell 或执行命令
|
||||
if controller_type == "cmd" {
|
||||
// 命令执行模式
|
||||
let cmd = Self::build_remote_command(&block_meta)?;
|
||||
tracing::info!("[SSHShellProc] 执行远程命令: {}", cmd);
|
||||
channel.exec(&cmd).map_err(|e| {
|
||||
TerminalError::SSHConnectionFailed(format!("执行远程命令失败: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
// Shell 模式 - 启动交互式 Shell
|
||||
channel.shell().map_err(|e| {
|
||||
TerminalError::SSHConnectionFailed(format!("启动远程 Shell 失败: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// 设置非阻塞模式
|
||||
session.set_blocking(false);
|
||||
|
||||
// 创建共享状态
|
||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
let exit_code = Arc::new(AtomicI32::new(0));
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let channel = Arc::new(Mutex::new(channel));
|
||||
let term_size = Arc::new(Mutex::new(TermSize { rows, cols }));
|
||||
|
||||
// 启动输出读取任务
|
||||
Self::spawn_output_reader(
|
||||
block_id.clone(),
|
||||
channel.clone(),
|
||||
app_handle.clone(),
|
||||
shutdown_flag.clone(),
|
||||
exit_code.clone(),
|
||||
exited.clone(),
|
||||
block_file,
|
||||
);
|
||||
|
||||
// 启动输入处理任务
|
||||
Self::spawn_input_handler(
|
||||
block_id.clone(),
|
||||
channel.clone(),
|
||||
term_size.clone(),
|
||||
input_rx,
|
||||
shutdown_flag.clone(),
|
||||
);
|
||||
|
||||
tracing::info!("[SSHShellProc] 远程进程已创建: block_id={}", block_id);
|
||||
|
||||
Ok(Self {
|
||||
block_id,
|
||||
controller_type,
|
||||
channel,
|
||||
shutdown_flag,
|
||||
exit_code,
|
||||
exited,
|
||||
term_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 SSHConn 创建 SSH Shell 进程
|
||||
///
|
||||
/// 便捷方法,从已连接的 SSHConn 创建远程 Shell 进程。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: Block ID
|
||||
/// - `controller_type`: 控制器类型
|
||||
/// - `ssh_conn`: SSH 连接管理器
|
||||
/// - `rows`: 终端行数
|
||||
/// - `cols`: 终端列数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_meta`: 块元数据配置
|
||||
/// - `input_rx`: 输入接收器
|
||||
/// - `block_file`: 块文件存储(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(SSHShellProc)`: 创建成功
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
pub async fn from_ssh_conn(
|
||||
block_id: String,
|
||||
controller_type: String,
|
||||
ssh_conn: &SSHConn,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_meta: BlockMeta,
|
||||
input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) -> Result<Self, TerminalError> {
|
||||
let session = ssh_conn
|
||||
.get_session()
|
||||
.ok_or_else(|| TerminalError::SSHConnectionFailed("SSH 会话未建立".to_string()))?;
|
||||
|
||||
Self::new(
|
||||
block_id,
|
||||
controller_type,
|
||||
&session,
|
||||
rows,
|
||||
cols,
|
||||
app_handle,
|
||||
block_meta,
|
||||
input_rx,
|
||||
block_file,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 构建远程命令
|
||||
///
|
||||
/// 根据块元数据构建要在远程执行的命令。
|
||||
///
|
||||
/// _Requirements: 16.1, 16.2, 16.3_
|
||||
fn build_remote_command(block_meta: &BlockMeta) -> Result<String, TerminalError> {
|
||||
let cmd_str = block_meta.cmd.as_ref().ok_or_else(|| {
|
||||
TerminalError::SSHConnectionFailed("cmd 模式需要指定命令".to_string())
|
||||
})?;
|
||||
|
||||
// 构建完整命令字符串
|
||||
let mut full_cmd = String::new();
|
||||
|
||||
// 如果指定了工作目录,先 cd 到该目录
|
||||
if let Some(cwd) = &block_meta.cmd_cwd {
|
||||
full_cmd.push_str(&format!("cd {} && ", shell_escape(cwd)));
|
||||
}
|
||||
|
||||
// 设置环境变量
|
||||
if let Some(env_vars) = &block_meta.cmd_env {
|
||||
for (key, value) in env_vars {
|
||||
full_cmd.push_str(&format!("export {}={} && ", key, shell_escape(value)));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加命令和参数
|
||||
full_cmd.push_str(cmd_str);
|
||||
if let Some(args) = &block_meta.cmd_args {
|
||||
for arg in args {
|
||||
full_cmd.push(' ');
|
||||
full_cmd.push_str(&shell_escape(arg));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(full_cmd)
|
||||
}
|
||||
|
||||
/// 启动输出读取任务
|
||||
///
|
||||
/// 在独立线程中读取远程 PTY 输出,并通过 Tauri 事件发送到前端。
|
||||
///
|
||||
/// _Requirements: 4.2_
|
||||
fn spawn_output_reader(
|
||||
block_id: String,
|
||||
channel: Arc<Mutex<Channel>>,
|
||||
app_handle: tauri::AppHandle,
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
exit_code: Arc<AtomicI32>,
|
||||
exited: Arc<AtomicBool>,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut consecutive_empty_reads = 0;
|
||||
const MAX_EMPTY_READS: u32 = 100; // 防止空循环
|
||||
|
||||
loop {
|
||||
// 检查关闭标志
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
tracing::debug!("[SSHShellProc] 收到关闭信号: block_id={}", block_id);
|
||||
break;
|
||||
}
|
||||
|
||||
// 读取输出
|
||||
let read_result = {
|
||||
let mut ch = channel.lock();
|
||||
|
||||
// 检查 Channel 是否已关闭
|
||||
if ch.eof() {
|
||||
// 获取退出状态
|
||||
let code = ch.exit_status().unwrap_or(0);
|
||||
exit_code.store(code, Ordering::SeqCst);
|
||||
exited.store(true, Ordering::SeqCst);
|
||||
|
||||
tracing::info!(
|
||||
"[SSHShellProc] 远程进程已退出: block_id={}, exit_code={}",
|
||||
block_id,
|
||||
code
|
||||
);
|
||||
|
||||
// 发送状态事件
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_STATUS,
|
||||
TerminalStatusEvent {
|
||||
session_id: block_id.clone(),
|
||||
status: SessionStatus::Done,
|
||||
exit_code: Some(code),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
ch.read(&mut buffer)
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Ok(0) => {
|
||||
// 没有数据可读,短暂休眠后重试
|
||||
consecutive_empty_reads += 1;
|
||||
if consecutive_empty_reads > MAX_EMPTY_READS {
|
||||
// 检查 Channel 状态
|
||||
let ch = channel.lock();
|
||||
if ch.eof() {
|
||||
let code = ch.exit_status().unwrap_or(0);
|
||||
exit_code.store(code, Ordering::SeqCst);
|
||||
exited.store(true, Ordering::SeqCst);
|
||||
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_STATUS,
|
||||
TerminalStatusEvent {
|
||||
session_id: block_id.clone(),
|
||||
status: SessionStatus::Done,
|
||||
exit_code: Some(code),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
consecutive_empty_reads = 0;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Ok(n) => {
|
||||
consecutive_empty_reads = 0;
|
||||
let output_data = &buffer[..n];
|
||||
|
||||
// 保存到块文件
|
||||
if let Some(ref bf) = block_file {
|
||||
if let Err(e) = bf.append_data(output_data) {
|
||||
tracing::warn!(
|
||||
"[SSHShellProc] 写入块文件失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送输出事件
|
||||
let data = BASE64.encode(output_data);
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_OUTPUT,
|
||||
TerminalOutputEvent {
|
||||
session_id: block_id.clone(),
|
||||
data,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// 非阻塞模式下没有数据,短暂休眠
|
||||
consecutive_empty_reads += 1;
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Err(e) => {
|
||||
// 检查是否是因为关闭导致的错误
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::error!(
|
||||
"[SSHShellProc] 读取错误: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
exited.store(true, Ordering::SeqCst);
|
||||
|
||||
let _ = app_handle.emit(
|
||||
event_names::TERMINAL_STATUS,
|
||||
TerminalStatusEvent {
|
||||
session_id: block_id.clone(),
|
||||
status: SessionStatus::Error,
|
||||
exit_code: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 启动输入处理任务
|
||||
///
|
||||
/// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。
|
||||
///
|
||||
/// _Requirements: 4.11_
|
||||
fn spawn_input_handler(
|
||||
block_id: String,
|
||||
channel: Arc<Mutex<Channel>>,
|
||||
term_size: Arc<Mutex<TermSize>>,
|
||||
mut input_rx: mpsc::Receiver<BlockInputUnion>,
|
||||
shutdown_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(input) = input_rx.recv().await {
|
||||
// 检查关闭标志
|
||||
if shutdown_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 处理输入数据
|
||||
if let Some(data) = &input.input_data {
|
||||
let mut ch = channel.lock();
|
||||
if let Err(e) = ch.write_all(data) {
|
||||
tracing::error!(
|
||||
"[SSHShellProc] 写入失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = ch.flush() {
|
||||
tracing::error!(
|
||||
"[SSHShellProc] Flush 失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理终端大小调整
|
||||
if let Some(size) = &input.term_size {
|
||||
// 更新本地记录的终端大小
|
||||
{
|
||||
let mut ts = term_size.lock();
|
||||
*ts = *size;
|
||||
}
|
||||
|
||||
// 发送 PTY 大小调整请求到远程
|
||||
let mut ch = channel.lock();
|
||||
if let Err(e) =
|
||||
ch.request_pty_size(size.cols as u32, size.rows as u32, Some(0), Some(0))
|
||||
{
|
||||
tracing::error!(
|
||||
"[SSHShellProc] 调整远程 PTY 大小失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}",
|
||||
block_id,
|
||||
size.cols,
|
||||
size.rows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理信号
|
||||
if let Some(sig_name) = &input.sig_name {
|
||||
tracing::debug!(
|
||||
"[SSHShellProc] 收到信号: block_id={}, signal={}",
|
||||
block_id,
|
||||
sig_name
|
||||
);
|
||||
|
||||
// SSH 协议支持发送信号,但 ssh2 crate 没有直接暴露此功能
|
||||
// 对于 SIGINT,我们可以发送 Ctrl+C (0x03)
|
||||
// 对于其他信号,记录日志但不执行操作
|
||||
match sig_name.as_str() {
|
||||
"SIGINT" => {
|
||||
// 发送 Ctrl+C
|
||||
let mut ch = channel.lock();
|
||||
if let Err(e) = ch.write_all(&[0x03]) {
|
||||
tracing::warn!(
|
||||
"[SSHShellProc] 发送 Ctrl+C 失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
"SIGQUIT" => {
|
||||
// 发送 Ctrl+\ (0x1C)
|
||||
let mut ch = channel.lock();
|
||||
if let Err(e) = ch.write_all(&[0x1C]) {
|
||||
tracing::warn!(
|
||||
"[SSHShellProc] 发送 Ctrl+\\ 失败: block_id={}, error={}",
|
||||
block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[SSHShellProc] 不支持的信号: block_id={}, signal={}",
|
||||
block_id,
|
||||
sig_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("[SSHShellProc] 输入处理任务结束: block_id={}", block_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取 Block ID
|
||||
pub fn block_id(&self) -> &str {
|
||||
&self.block_id
|
||||
}
|
||||
|
||||
/// 获取控制器类型
|
||||
pub fn controller_type(&self) -> &str {
|
||||
&self.controller_type
|
||||
}
|
||||
|
||||
/// 检查进程是否已退出
|
||||
pub fn is_exited(&self) -> bool {
|
||||
self.exited.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 获取退出码
|
||||
pub fn get_exit_code(&self) -> i32 {
|
||||
self.exit_code.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 获取当前终端大小
|
||||
pub fn get_term_size(&self) -> TermSize {
|
||||
*self.term_size.lock()
|
||||
}
|
||||
|
||||
/// 写入数据到远程 PTY
|
||||
///
|
||||
/// _Requirements: 4.2_
|
||||
pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> {
|
||||
let mut channel = self.channel.lock();
|
||||
channel
|
||||
.write_all(data)
|
||||
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
|
||||
channel
|
||||
.flush()
|
||||
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 调整远程 PTY 大小
|
||||
///
|
||||
/// _Requirements: 4.11_
|
||||
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> {
|
||||
// 更新本地记录
|
||||
{
|
||||
let mut ts = self.term_size.lock();
|
||||
*ts = TermSize { rows, cols };
|
||||
}
|
||||
|
||||
// 发送到远程
|
||||
let mut channel = self.channel.lock();
|
||||
channel
|
||||
.request_pty_size(cols as u32, rows as u32, Some(0), Some(0))
|
||||
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
|
||||
|
||||
tracing::debug!(
|
||||
"[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}",
|
||||
self.block_id,
|
||||
cols,
|
||||
rows
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 优雅终止进程
|
||||
///
|
||||
/// 发送 EOF 并等待进程退出。
|
||||
///
|
||||
/// # 返回
|
||||
/// 进程退出码
|
||||
pub async fn terminate(&self) -> i32 {
|
||||
tracing::info!(
|
||||
"[SSHShellProc] 优雅终止远程进程: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
|
||||
// 设置关闭标志
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// 发送 Ctrl+C 尝试中断进程
|
||||
{
|
||||
let mut channel = self.channel.lock();
|
||||
let _ = channel.write_all(&[0x03]); // Ctrl+C
|
||||
}
|
||||
|
||||
// 等待一小段时间让进程响应
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// 关闭 Channel
|
||||
{
|
||||
let mut channel = self.channel.lock();
|
||||
let _ = channel.send_eof();
|
||||
let _ = channel.wait_close();
|
||||
}
|
||||
|
||||
self.exit_code.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 强制终止进程
|
||||
///
|
||||
/// 关闭 Channel 立即终止连接。
|
||||
pub async fn kill(&self) {
|
||||
tracing::info!(
|
||||
"[SSHShellProc] 强制终止远程进程: block_id={}",
|
||||
self.block_id
|
||||
);
|
||||
|
||||
// 设置关闭标志
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// 直接关闭 Channel
|
||||
{
|
||||
let mut channel = self.channel.lock();
|
||||
let _ = channel.send_eof();
|
||||
let _ = channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送 EOF 到远程
|
||||
///
|
||||
/// 用于通知远程进程输入已结束。
|
||||
pub fn send_eof(&self) -> Result<(), TerminalError> {
|
||||
let mut channel = self.channel.lock();
|
||||
channel
|
||||
.send_eof()
|
||||
.map_err(|e| TerminalError::WriteFailed(format!("发送 EOF 失败: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SSHShellProc {
|
||||
fn drop(&mut self) {
|
||||
// 确保关闭标志被设置
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// 尝试关闭 Channel
|
||||
if let Some(mut channel) = self.channel.try_lock() {
|
||||
let _ = channel.send_eof();
|
||||
let _ = channel.close();
|
||||
}
|
||||
|
||||
tracing::debug!("[SSHShellProc] 远程进程已销毁: block_id={}", self.block_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 转义辅助函数
|
||||
///
|
||||
/// 对字符串进行 Shell 转义,防止命令注入。
|
||||
fn shell_escape(s: &str) -> String {
|
||||
// 如果字符串只包含安全字符,直接返回
|
||||
if s.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/')
|
||||
{
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
// 否则用单引号包裹,并转义内部的单引号
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_shell_escape_simple() {
|
||||
assert_eq!(shell_escape("hello"), "hello");
|
||||
assert_eq!(shell_escape("hello_world"), "hello_world");
|
||||
assert_eq!(shell_escape("/path/to/file"), "/path/to/file");
|
||||
assert_eq!(shell_escape("file.txt"), "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_escape_special_chars() {
|
||||
assert_eq!(shell_escape("hello world"), "'hello world'");
|
||||
assert_eq!(shell_escape("hello'world"), "'hello'\\''world'");
|
||||
assert_eq!(shell_escape("$HOME"), "'$HOME'");
|
||||
assert_eq!(shell_escape("a;b"), "'a;b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_term_size_default() {
|
||||
let size = TermSize::default();
|
||||
assert_eq!(size.rows, 24);
|
||||
assert_eq!(size.cols, 80);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
//! ## 功能
|
||||
//! - 会话管理错误
|
||||
//! - PTY 操作错误
|
||||
//! - 块文件存储错误
|
||||
//! - 数据库错误
|
||||
//! - 序列化支持
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -36,9 +38,53 @@ pub enum TerminalError {
|
||||
#[error("Base64 解码失败: {0}")]
|
||||
Base64DecodeFailed(String),
|
||||
|
||||
/// 块文件错误
|
||||
#[error("块文件错误: {0}")]
|
||||
BlockFileError(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
/// 控制器未找到
|
||||
#[error("控制器未找到: {0}")]
|
||||
ControllerNotFound(String),
|
||||
|
||||
/// SSH 连接失败
|
||||
#[error("SSH 连接失败: {0}")]
|
||||
SSHConnectionFailed(String),
|
||||
|
||||
/// SSH 认证失败
|
||||
#[error("SSH 认证失败: {0}")]
|
||||
SSHAuthFailed(String),
|
||||
|
||||
/// WSL 连接失败
|
||||
#[error("WSL 连接失败: {0}")]
|
||||
WSLConnectionFailed(String),
|
||||
|
||||
/// 无效的 OSC 序列
|
||||
#[error("无效的 OSC 序列: {0}")]
|
||||
InvalidOSCSequence(String),
|
||||
|
||||
/// 连接超时
|
||||
#[error("连接超时")]
|
||||
ConnectionTimeout,
|
||||
|
||||
/// 用户取消
|
||||
#[error("用户取消")]
|
||||
UserCancelled,
|
||||
|
||||
/// 主机密钥验证失败
|
||||
#[error("主机密钥验证失败: {0}")]
|
||||
HostKeyVerificationFailed(String),
|
||||
|
||||
/// 内部错误
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// 无效的连接类型
|
||||
#[error("无效的连接类型: {0}")]
|
||||
InvalidConnectionType(String),
|
||||
}
|
||||
|
||||
impl From<TerminalError> for String {
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
//! ## 事件列表
|
||||
//! - `terminal:output` - 终端输出数据
|
||||
//! - `terminal:status` - 终端状态变化
|
||||
//! - `terminal:shell-integration` - Shell 集成状态变化
|
||||
//! - `terminal:clipboard-write` - 剪贴板写入请求
|
||||
//! - `terminal:conn-change` - 连接状态变化
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::terminal::connections::ConnStatus;
|
||||
|
||||
/// 会话状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -54,10 +59,29 @@ pub struct TerminalStatusEvent {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 连接状态变更事件
|
||||
///
|
||||
/// Event name: `terminal:conn-change`
|
||||
///
|
||||
/// _Requirements: 7.3_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnChangeEvent {
|
||||
/// 连接名称
|
||||
pub connection: String,
|
||||
/// 连接状态详情
|
||||
pub status: ConnStatus,
|
||||
}
|
||||
|
||||
/// 事件名称常量
|
||||
pub mod event_names {
|
||||
/// 终端输出事件名
|
||||
pub const TERMINAL_OUTPUT: &str = "terminal:output";
|
||||
/// 终端状态事件名
|
||||
pub const TERMINAL_STATUS: &str = "terminal:status";
|
||||
/// Shell 集成状态事件名
|
||||
pub const SHELL_INTEGRATION_STATUS: &str = "terminal:shell-integration";
|
||||
/// 剪贴板写入事件名
|
||||
pub const CLIPBOARD_WRITE: &str = "terminal:clipboard-write";
|
||||
/// 连接状态变更事件名
|
||||
pub const CONN_CHANGE: &str = "terminal:conn-change";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# integration
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
集成模块,提供 Shell 集成、OSC 序列解析、状态重同步、Shell 集成脚本管理等功能。
|
||||
|
||||
**核心原则:**
|
||||
- OSC 序列解析和处理
|
||||
- Shell 集成状态管理
|
||||
- Shell 集成脚本安装和管理
|
||||
- 终端状态重同步机制
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **OSC 解析器**: 解析 OSC 7/52/133/16162 序列
|
||||
- **Shell 集成**: 目录同步、命令时间记录、状态管理
|
||||
- **Shell 脚本**: 各种 Shell 的集成脚本安装和启动配置
|
||||
- **状态重同步**: 连接恢复时重建终端状态
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口,导出公共类型
|
||||
- `resync.rs` - 状态重同步控制器,实现终端状态重建
|
||||
- `osc_parser.rs` - OSC 序列解析器,支持 OSC 7/52/133/16162
|
||||
- `shell_integration.rs` - Shell 集成处理器,管理 Shell 状态和命令跟踪
|
||||
- `shell_scripts.rs` - Shell 集成脚本管理,支持 Bash/Zsh/Fish/PowerShell
|
||||
|
||||
## 已实现功能
|
||||
|
||||
### 任务 6: 状态重同步控制器 ✅
|
||||
- `ResyncController` - 状态重同步控制器
|
||||
- `resync_controller` - 便捷重同步函数
|
||||
- `ResyncOptions` - 重同步选项配置
|
||||
- `ResyncResult` - 重同步结果
|
||||
- 终端重置序列发送(完全重置和软重置)
|
||||
- 历史数据恢复(从 BlockFile 读取)
|
||||
|
||||
### 任务 7.1: OSC 序列解析器 ✅
|
||||
- `OSCParser` - OSC 序列解析器
|
||||
- `OSCSequence` - OSC 序列类型枚举
|
||||
- `PromptMarkType` - 命令提示符标记类型
|
||||
- `ParsedOSC` - 解析结果结构
|
||||
- `strip_osc_sequences` - 过滤 OSC 序列工具函数
|
||||
- 支持 OSC 7(当前目录)、OSC 52(剪贴板)、OSC 133(命令标记)、OSC 16162(Wave 命令)
|
||||
|
||||
### 任务 7.3: ShellIntegration 处理器 ✅
|
||||
- `ShellIntegration` - Shell 集成处理器
|
||||
- `ShellIntegrationStatus` - 集成状态枚举(Ready、RunningCommand、Unknown)
|
||||
- `ShellType` - Shell 类型枚举(Bash、Zsh、Fish、Pwsh)
|
||||
- `CommandInfo` - 命令执行信息(开始时间、结束时间、持续时间)
|
||||
- `ShellIntegrationEvent` - 状态变更事件
|
||||
- 当前目录跟踪(OSC 7)
|
||||
- 命令时间记录(OSC 133)
|
||||
- Wave 命令处理(OSC 16162)
|
||||
|
||||
### 任务 21.1: Shell 集成脚本安装 ✅
|
||||
- `ShellScripts` - Shell 集成脚本管理器
|
||||
- `ShellLaunchConfig` - Shell 启动配置
|
||||
- `ShellLaunchBuilder` - Shell 启动配置构建器
|
||||
- Bash 集成(--rcfile 参数)
|
||||
- Zsh 集成(ZDOTDIR 环境变量)
|
||||
- Fish 集成(-C source 参数)
|
||||
- PowerShell 集成(-NoExit -Command)
|
||||
- 自动安装集成脚本到应用数据目录
|
||||
|
||||
### 任务 21.2: 环境变量配置 ✅
|
||||
- `TerminalEnvConfig` - 终端环境变量配置管理器
|
||||
- 标准环境变量设置(TERM、COLORTERM)
|
||||
- 块标识环境变量(PROXYCAST_BLOCKID、WAVETERM_BLOCKID)
|
||||
- 版本信息环境变量(PROXYCAST_VERSION)
|
||||
- 语言设置环境变量(LANG、LC_ALL)
|
||||
- 自定义环境变量合并支持
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 集成模块
|
||||
//!
|
||||
//! 提供 Shell 集成、OSC 序列解析、状态重同步等功能。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `osc_parser` - OSC 序列解析器
|
||||
//! - `shell_integration` - Shell 集成处理器
|
||||
//! - `shell_scripts` - Shell 集成脚本管理
|
||||
//! - `resync` - 状态重同步控制器
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - OSC 序列解析(OSC 7/52/133/16162)
|
||||
//! - Shell 集成状态管理
|
||||
//! - Shell 集成脚本安装和管理
|
||||
//! - 终端状态重同步
|
||||
|
||||
pub mod osc_parser;
|
||||
pub mod resync;
|
||||
pub mod shell_integration;
|
||||
pub mod shell_scripts;
|
||||
|
||||
// 重新导出常用类型
|
||||
pub use osc_parser::{strip_osc_sequences, OSCParser, OSCSequence, ParsedOSC, PromptMarkType};
|
||||
pub use resync::{
|
||||
resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE,
|
||||
TERMINAL_SOFT_RESET_SEQUENCE,
|
||||
};
|
||||
pub use shell_integration::{
|
||||
CommandInfo, ShellIntegration, ShellIntegrationEvent, ShellIntegrationStatus, ShellType,
|
||||
};
|
||||
pub use shell_scripts::{ShellLaunchBuilder, ShellLaunchConfig, ShellScripts, TerminalEnvConfig};
|
||||
@@ -0,0 +1,689 @@
|
||||
//! OSC 序列解析器
|
||||
//!
|
||||
//! 解析终端输出中的 OSC(Operating System Command)序列,支持:
|
||||
//! - OSC 7: 当前工作目录
|
||||
//! - OSC 52: 剪贴板操作
|
||||
//! - OSC 133: 命令提示符标记(Shell Integration)
|
||||
//! - OSC 16162: Wave 特定命令
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 从字节流中识别和解析 OSC 序列
|
||||
//! - 支持多种 OSC 序列类型
|
||||
//! - 无效序列容错处理
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 6.1: OSC 7 当前目录解析
|
||||
//! - 6.2: OSC 52 剪贴板解析
|
||||
//! - 6.3: OSC 133 命令提示符标记解析
|
||||
//! - 6.4: OSC 16162 Wave 命令解析
|
||||
//! - 6.7: 无效序列容错处理
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
|
||||
/// OSC 序列起始标记
|
||||
const OSC_START: &[u8] = b"\x1b]";
|
||||
/// OSC 序列结束标记 - BEL
|
||||
const OSC_END_BEL: u8 = 0x07;
|
||||
/// OSC 序列结束标记 - ST (String Terminator)
|
||||
const OSC_END_ST: &[u8] = b"\x1b\\";
|
||||
|
||||
/// OSC 序列类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OSCSequence {
|
||||
/// OSC 7 - 当前工作目录
|
||||
/// 格式: OSC 7 ; file://hostname/path ST
|
||||
CurrentDirectory {
|
||||
/// 主机名(可选)
|
||||
hostname: Option<String>,
|
||||
/// 路径
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// OSC 52 - 剪贴板操作
|
||||
/// 格式: OSC 52 ; selection ; base64-data ST
|
||||
Clipboard {
|
||||
/// 选择类型 (c=clipboard, p=primary, s=secondary, etc.)
|
||||
selection: String,
|
||||
/// Base64 编码的数据
|
||||
data: String,
|
||||
},
|
||||
|
||||
/// OSC 133 - 命令提示符标记(Shell Integration)
|
||||
/// 格式: OSC 133 ; type ST
|
||||
PromptMark {
|
||||
/// 标记类型
|
||||
mark_type: PromptMarkType,
|
||||
},
|
||||
|
||||
/// OSC 16162 - Wave 特定命令
|
||||
/// 格式: OSC 16162 ; command ST
|
||||
WaveCommand {
|
||||
/// 命令内容
|
||||
command: String,
|
||||
},
|
||||
|
||||
/// 未知的 OSC 序列
|
||||
Unknown {
|
||||
/// OSC 代码
|
||||
code: String,
|
||||
/// 参数
|
||||
params: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 命令提示符标记类型(OSC 133)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptMarkType {
|
||||
/// A - 提示符开始(Prompt Start)
|
||||
PromptStart,
|
||||
/// B - 命令开始(Command Start,用户输入开始)
|
||||
CommandStart,
|
||||
/// C - 命令执行(Command Executed)
|
||||
CommandExecuted,
|
||||
/// D - 命令结束(Command Finished)
|
||||
CommandFinished,
|
||||
/// 未知标记类型
|
||||
Unknown(char),
|
||||
}
|
||||
|
||||
impl PromptMarkType {
|
||||
/// 从字符解析标记类型
|
||||
pub fn from_char(c: char) -> Self {
|
||||
match c {
|
||||
'A' => Self::PromptStart,
|
||||
'B' => Self::CommandStart,
|
||||
'C' => Self::CommandExecuted,
|
||||
'D' => Self::CommandFinished,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为字符
|
||||
pub fn to_char(&self) -> char {
|
||||
match self {
|
||||
Self::PromptStart => 'A',
|
||||
Self::CommandStart => 'B',
|
||||
Self::CommandExecuted => 'C',
|
||||
Self::CommandFinished => 'D',
|
||||
Self::Unknown(c) => *c,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedOSC {
|
||||
/// 解析出的 OSC 序列
|
||||
pub sequence: OSCSequence,
|
||||
/// 序列在原始数据中的位置范围
|
||||
pub range: Range<usize>,
|
||||
}
|
||||
|
||||
/// OSC 序列解析器
|
||||
pub struct OSCParser;
|
||||
|
||||
impl OSCParser {
|
||||
/// 从字节流中解析所有 OSC 序列
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: 输入字节流
|
||||
///
|
||||
/// # 返回
|
||||
/// 解析出的 OSC 序列列表,每个元素包含序列和位置范围
|
||||
///
|
||||
/// # 示例
|
||||
/// ```
|
||||
/// use proxycast::terminal::integration::osc_parser::{OSCParser, OSCSequence};
|
||||
///
|
||||
/// let data = b"\x1b]7;file://localhost/home/user\x07";
|
||||
/// let results = OSCParser::parse(data);
|
||||
/// assert_eq!(results.len(), 1);
|
||||
/// ```
|
||||
///
|
||||
/// _Requirements: 6.1, 6.2, 6.3, 6.4, 6.7_
|
||||
pub fn parse(data: &[u8]) -> Vec<ParsedOSC> {
|
||||
let mut results = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < data.len() {
|
||||
// 查找 OSC 起始标记
|
||||
if let Some(start_offset) = Self::find_osc_start(&data[pos..]) {
|
||||
let start = pos + start_offset;
|
||||
let content_start = start + OSC_START.len();
|
||||
|
||||
// 查找 OSC 结束标记
|
||||
if let Some((end, terminator_len)) = Self::find_osc_end(&data[content_start..]) {
|
||||
let content_end = content_start + end;
|
||||
let sequence_end = content_end + terminator_len;
|
||||
|
||||
// 解析 OSC 内容
|
||||
let content = &data[content_start..content_end];
|
||||
if let Some(sequence) = Self::parse_osc_content(content) {
|
||||
results.push(ParsedOSC {
|
||||
sequence,
|
||||
range: start..sequence_end,
|
||||
});
|
||||
}
|
||||
|
||||
pos = sequence_end;
|
||||
} else {
|
||||
// 没有找到结束标记,跳过这个起始标记
|
||||
pos = content_start;
|
||||
}
|
||||
} else {
|
||||
// 没有更多 OSC 序列
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// 解析单个 OSC 序列
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: 完整的 OSC 序列(包含起始和结束标记)
|
||||
///
|
||||
/// # 返回
|
||||
/// 解析成功返回 OSC 序列,失败返回 None
|
||||
pub fn parse_single(data: &[u8]) -> Option<OSCSequence> {
|
||||
// 检查起始标记
|
||||
if !data.starts_with(OSC_START) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content_start = OSC_START.len();
|
||||
|
||||
// 查找结束标记
|
||||
let (end, _) = Self::find_osc_end(&data[content_start..])?;
|
||||
let content = &data[content_start..content_start + end];
|
||||
|
||||
Self::parse_osc_content(content)
|
||||
}
|
||||
|
||||
/// 查找 OSC 起始标记
|
||||
fn find_osc_start(data: &[u8]) -> Option<usize> {
|
||||
data.windows(OSC_START.len()).position(|w| w == OSC_START)
|
||||
}
|
||||
|
||||
/// 查找 OSC 结束标记
|
||||
///
|
||||
/// 返回 (结束位置, 终止符长度)
|
||||
fn find_osc_end(data: &[u8]) -> Option<(usize, usize)> {
|
||||
for (i, &byte) in data.iter().enumerate() {
|
||||
// BEL 终止符
|
||||
if byte == OSC_END_BEL {
|
||||
return Some((i, 1));
|
||||
}
|
||||
// ST 终止符 (ESC \)
|
||||
if byte == 0x1b && i + 1 < data.len() && data[i + 1] == b'\\' {
|
||||
return Some((i, 2));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 OSC 内容
|
||||
fn parse_osc_content(content: &[u8]) -> Option<OSCSequence> {
|
||||
// 转换为字符串
|
||||
let content_str = String::from_utf8_lossy(content);
|
||||
|
||||
// 分割 OSC 代码和参数
|
||||
let (code, params) = match content_str.find(';') {
|
||||
Some(pos) => (&content_str[..pos], &content_str[pos + 1..]),
|
||||
None => (content_str.as_ref(), ""),
|
||||
};
|
||||
|
||||
// 根据 OSC 代码解析
|
||||
match code {
|
||||
"7" => Self::parse_osc_7(params),
|
||||
"52" => Self::parse_osc_52(params),
|
||||
"133" => Self::parse_osc_133(params),
|
||||
"16162" => Self::parse_osc_16162(params),
|
||||
_ => Some(OSCSequence::Unknown {
|
||||
code: code.to_string(),
|
||||
params: params.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 OSC 7 - 当前工作目录
|
||||
///
|
||||
/// 格式: file://hostname/path 或 file:///path
|
||||
///
|
||||
/// _Requirements: 6.1_
|
||||
fn parse_osc_7(params: &str) -> Option<OSCSequence> {
|
||||
// 移除 file:// 前缀
|
||||
let path_part = params.strip_prefix("file://")?;
|
||||
|
||||
// 解析主机名和路径
|
||||
let (hostname, path) = if path_part.starts_with('/') {
|
||||
// file:///path 格式(无主机名)
|
||||
(None, path_part.to_string())
|
||||
} else {
|
||||
// file://hostname/path 格式
|
||||
match path_part.find('/') {
|
||||
Some(pos) => {
|
||||
let host = &path_part[..pos];
|
||||
let path = &path_part[pos..];
|
||||
(
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
},
|
||||
path.to_string(),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
// 只有主机名,没有路径
|
||||
(Some(path_part.to_string()), "/".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// URL 解码路径
|
||||
let decoded_path = Self::url_decode(&path);
|
||||
|
||||
Some(OSCSequence::CurrentDirectory {
|
||||
hostname,
|
||||
path: decoded_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 OSC 52 - 剪贴板操作
|
||||
///
|
||||
/// 格式: selection;base64-data
|
||||
///
|
||||
/// _Requirements: 6.2_
|
||||
fn parse_osc_52(params: &str) -> Option<OSCSequence> {
|
||||
let (selection, data) = match params.find(';') {
|
||||
Some(pos) => (¶ms[..pos], ¶ms[pos + 1..]),
|
||||
None => (params, ""),
|
||||
};
|
||||
|
||||
Some(OSCSequence::Clipboard {
|
||||
selection: selection.to_string(),
|
||||
data: data.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 OSC 133 - 命令提示符标记
|
||||
///
|
||||
/// 格式: type (A/B/C/D)
|
||||
///
|
||||
/// _Requirements: 6.3_
|
||||
fn parse_osc_133(params: &str) -> Option<OSCSequence> {
|
||||
let mark_char = params.chars().next()?;
|
||||
let mark_type = PromptMarkType::from_char(mark_char);
|
||||
|
||||
Some(OSCSequence::PromptMark { mark_type })
|
||||
}
|
||||
|
||||
/// 解析 OSC 16162 - Wave 命令
|
||||
///
|
||||
/// _Requirements: 6.4_
|
||||
fn parse_osc_16162(params: &str) -> Option<OSCSequence> {
|
||||
Some(OSCSequence::WaveCommand {
|
||||
command: params.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// URL 解码
|
||||
fn url_decode(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
// 尝试解析两个十六进制字符
|
||||
let hex: String = chars.by_ref().take(2).collect();
|
||||
if hex.len() == 2 {
|
||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||
result.push(byte as char);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 解析失败,保留原样
|
||||
result.push('%');
|
||||
result.push_str(&hex);
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 从 OSC 52 数据中解码剪贴板内容
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: Base64 编码的数据
|
||||
///
|
||||
/// # 返回
|
||||
/// 解码后的字符串,解码失败返回 None
|
||||
pub fn decode_clipboard_data(data: &str) -> Option<String> {
|
||||
if data == "?" {
|
||||
// 查询请求
|
||||
return None;
|
||||
}
|
||||
|
||||
let decoded = BASE64.decode(data).ok()?;
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
/// 编码剪贴板内容为 OSC 52 格式
|
||||
///
|
||||
/// # 参数
|
||||
/// - `selection`: 选择类型
|
||||
/// - `content`: 要编码的内容
|
||||
///
|
||||
/// # 返回
|
||||
/// 完整的 OSC 52 序列
|
||||
pub fn encode_clipboard(selection: &str, content: &str) -> Vec<u8> {
|
||||
let encoded = BASE64.encode(content.as_bytes());
|
||||
format!("\x1b]52;{};{}\x07", selection, encoded).into_bytes()
|
||||
}
|
||||
|
||||
/// 构建 OSC 7 序列
|
||||
///
|
||||
/// # 参数
|
||||
/// - `hostname`: 主机名(可选)
|
||||
/// - `path`: 路径
|
||||
///
|
||||
/// # 返回
|
||||
/// 完整的 OSC 7 序列
|
||||
pub fn build_osc_7(hostname: Option<&str>, path: &str) -> Vec<u8> {
|
||||
let host = hostname.unwrap_or("");
|
||||
format!("\x1b]7;file://{}{}\x07", host, path).into_bytes()
|
||||
}
|
||||
|
||||
/// 构建 OSC 133 序列
|
||||
///
|
||||
/// # 参数
|
||||
/// - `mark_type`: 标记类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 完整的 OSC 133 序列
|
||||
pub fn build_osc_133(mark_type: PromptMarkType) -> Vec<u8> {
|
||||
format!("\x1b]133;{}\x07", mark_type.to_char()).into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据流中过滤掉 OSC 序列,返回纯文本数据
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: 输入数据
|
||||
///
|
||||
/// # 返回
|
||||
/// 过滤后的数据
|
||||
pub fn strip_osc_sequences(data: &[u8]) -> Vec<u8> {
|
||||
let parsed = OSCParser::parse(data);
|
||||
if parsed.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(data.len());
|
||||
let mut last_end = 0;
|
||||
|
||||
for osc in parsed {
|
||||
// 添加 OSC 序列之前的数据
|
||||
if osc.range.start > last_end {
|
||||
result.extend_from_slice(&data[last_end..osc.range.start]);
|
||||
}
|
||||
last_end = osc.range.end;
|
||||
}
|
||||
|
||||
// 添加最后一个 OSC 序列之后的数据
|
||||
if last_end < data.len() {
|
||||
result.extend_from_slice(&data[last_end..]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_7_with_hostname() {
|
||||
let data = b"\x1b]7;file://localhost/home/user\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::CurrentDirectory { hostname, path } => {
|
||||
assert_eq!(hostname.as_deref(), Some("localhost"));
|
||||
assert_eq!(path, "/home/user");
|
||||
}
|
||||
_ => panic!("Expected CurrentDirectory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_7_without_hostname() {
|
||||
let data = b"\x1b]7;file:///home/user\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::CurrentDirectory { hostname, path } => {
|
||||
assert!(hostname.is_none());
|
||||
assert_eq!(path, "/home/user");
|
||||
}
|
||||
_ => panic!("Expected CurrentDirectory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_7_url_encoded() {
|
||||
let data = b"\x1b]7;file:///home/user/my%20folder\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::CurrentDirectory { path, .. } => {
|
||||
assert_eq!(path, "/home/user/my folder");
|
||||
}
|
||||
_ => panic!("Expected CurrentDirectory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_52() {
|
||||
let data = b"\x1b]52;c;SGVsbG8gV29ybGQ=\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::Clipboard { selection, data } => {
|
||||
assert_eq!(selection, "c");
|
||||
assert_eq!(data, "SGVsbG8gV29ybGQ=");
|
||||
|
||||
// 验证解码
|
||||
let decoded = OSCParser::decode_clipboard_data(data);
|
||||
assert_eq!(decoded, Some("Hello World".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Clipboard"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_133() {
|
||||
// 测试所有标记类型
|
||||
let test_cases = [
|
||||
(b"\x1b]133;A\x07".as_slice(), PromptMarkType::PromptStart),
|
||||
(b"\x1b]133;B\x07".as_slice(), PromptMarkType::CommandStart),
|
||||
(
|
||||
b"\x1b]133;C\x07".as_slice(),
|
||||
PromptMarkType::CommandExecuted,
|
||||
),
|
||||
(
|
||||
b"\x1b]133;D\x07".as_slice(),
|
||||
PromptMarkType::CommandFinished,
|
||||
),
|
||||
];
|
||||
|
||||
for (data, expected_type) in test_cases {
|
||||
let results = OSCParser::parse(data);
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::PromptMark { mark_type } => {
|
||||
assert_eq!(*mark_type, expected_type);
|
||||
}
|
||||
_ => panic!("Expected PromptMark"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_16162() {
|
||||
let data = b"\x1b]16162;setcwd /home/user\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::WaveCommand { command } => {
|
||||
assert_eq!(command, "setcwd /home/user");
|
||||
}
|
||||
_ => panic!("Expected WaveCommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_osc() {
|
||||
let data = b"Hello\x1b]7;file:///home\x07World\x1b]133;A\x07End";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(matches!(
|
||||
&results[0].sequence,
|
||||
OSCSequence::CurrentDirectory { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&results[1].sequence,
|
||||
OSCSequence::PromptMark { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_osc_with_st_terminator() {
|
||||
let data = b"\x1b]7;file:///home/user\x1b\\";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::CurrentDirectory { path, .. } => {
|
||||
assert_eq!(path, "/home/user");
|
||||
}
|
||||
_ => panic!("Expected CurrentDirectory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unknown_osc() {
|
||||
let data = b"\x1b]999;some params\x07";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::Unknown { code, params } => {
|
||||
assert_eq!(code, "999");
|
||||
assert_eq!(params, "some params");
|
||||
}
|
||||
_ => panic!("Expected Unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_osc() {
|
||||
let data = b"Hello World";
|
||||
let results = OSCParser::parse(data);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_osc_sequences() {
|
||||
let data = b"Hello\x1b]7;file:///home\x07World";
|
||||
let stripped = strip_osc_sequences(data);
|
||||
assert_eq!(stripped, b"HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_clipboard() {
|
||||
let encoded = OSCParser::encode_clipboard("c", "Hello");
|
||||
let results = OSCParser::parse(&encoded);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::Clipboard { selection, data } => {
|
||||
assert_eq!(selection, "c");
|
||||
let decoded = OSCParser::decode_clipboard_data(data);
|
||||
assert_eq!(decoded, Some("Hello".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Clipboard"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_osc_7() {
|
||||
let osc = OSCParser::build_osc_7(Some("localhost"), "/home/user");
|
||||
let results = OSCParser::parse(&osc);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::CurrentDirectory { hostname, path } => {
|
||||
assert_eq!(hostname.as_deref(), Some("localhost"));
|
||||
assert_eq!(path, "/home/user");
|
||||
}
|
||||
_ => panic!("Expected CurrentDirectory"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_osc_133() {
|
||||
let osc = OSCParser::build_osc_133(PromptMarkType::PromptStart);
|
||||
let results = OSCParser::parse(&osc);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].sequence {
|
||||
OSCSequence::PromptMark { mark_type } => {
|
||||
assert_eq!(*mark_type, PromptMarkType::PromptStart);
|
||||
}
|
||||
_ => panic!("Expected PromptMark"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_mark_type_roundtrip() {
|
||||
let types = [
|
||||
PromptMarkType::PromptStart,
|
||||
PromptMarkType::CommandStart,
|
||||
PromptMarkType::CommandExecuted,
|
||||
PromptMarkType::CommandFinished,
|
||||
PromptMarkType::Unknown('X'),
|
||||
];
|
||||
|
||||
for mark_type in types {
|
||||
let c = mark_type.to_char();
|
||||
let parsed = PromptMarkType::from_char(c);
|
||||
assert_eq!(mark_type, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range() {
|
||||
let data = b"ABC\x1b]7;file:///home\x07XYZ";
|
||||
let results = OSCParser::parse(data);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].range.start, 3);
|
||||
// OSC 序列: \x1b]7;file:///home\x07
|
||||
// = ESC(1) + ](1) + "7;file:///home"(14) + BEL(1) = 17 bytes
|
||||
// 所以 end = 3 + 17 = 20
|
||||
assert_eq!(results[0].range.end, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! 状态重同步控制器
|
||||
//!
|
||||
//! 负责在连接恢复时重建终端状态,包括检查控制器状态、发送终端重置序列、恢复历史数据。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 检查当前控制器状态并决定是否需要重启
|
||||
//! - 连接名称变更时停止旧控制器并创建新控制器
|
||||
//! - 发送终端重置序列(重置属性、显示光标、禁用鼠标跟踪等)
|
||||
//! - 从 BlockFile 读取历史输出并推送到前端
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 2.1: 检查当前控制器状态并决定是否需要重启
|
||||
//! - 2.2: 连接名称变更时停止当前控制器并使用新连接重新创建
|
||||
//! - 2.3: 发送终端重置序列
|
||||
//! - 2.4: 从 BlockFile 读取历史输出并推送到前端
|
||||
//! - 2.5: 控制器状态为 "init" 或 "done" 时启动新的控制器实例
|
||||
//! - 2.6: force 参数为 true 时强制重启控制器
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::terminal::block_controller::{
|
||||
BlockController, BlockControllerRuntimeStatus, BlockMeta, ControllerRegistry, RuntimeOpts,
|
||||
ShellController,
|
||||
};
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::events::{event_names, TerminalOutputEvent};
|
||||
use crate::terminal::persistence::BlockFile;
|
||||
|
||||
/// 终端重置序列
|
||||
///
|
||||
/// 用于在重同步时重置终端状态,包括:
|
||||
/// - ESC c: 完全重置终端
|
||||
/// - ESC [?25h: 显示光标
|
||||
/// - ESC [?1000l: 禁用鼠标跟踪
|
||||
/// - ESC [?1002l: 禁用按钮事件鼠标跟踪
|
||||
/// - ESC [?1003l: 禁用任意事件鼠标跟踪
|
||||
/// - ESC [?1006l: 禁用 SGR 鼠标模式
|
||||
/// - ESC [0m: 重置所有属性
|
||||
pub const TERMINAL_RESET_SEQUENCE: &[u8] =
|
||||
b"\x1bc\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[0m";
|
||||
|
||||
/// 软重置序列(不完全重置终端)
|
||||
///
|
||||
/// 用于较轻量的重置,保留部分状态:
|
||||
/// - ESC [!p: 软重置
|
||||
/// - ESC [?25h: 显示光标
|
||||
/// - ESC [0m: 重置所有属性
|
||||
pub const TERMINAL_SOFT_RESET_SEQUENCE: &[u8] = b"\x1b[!p\x1b[?25h\x1b[0m";
|
||||
|
||||
/// 重同步选项
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResyncOptions {
|
||||
/// 是否强制重启控制器
|
||||
pub force: bool,
|
||||
/// 是否发送完全重置序列(否则使用软重置)
|
||||
pub full_reset: bool,
|
||||
/// 是否恢复历史数据
|
||||
pub restore_history: bool,
|
||||
}
|
||||
|
||||
impl ResyncOptions {
|
||||
/// 创建默认选项
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
force: false,
|
||||
full_reset: false,
|
||||
restore_history: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置强制重启
|
||||
pub fn with_force(mut self, force: bool) -> Self {
|
||||
self.force = force;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置完全重置
|
||||
pub fn with_full_reset(mut self, full_reset: bool) -> Self {
|
||||
self.full_reset = full_reset;
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置是否恢复历史
|
||||
pub fn with_restore_history(mut self, restore: bool) -> Self {
|
||||
self.restore_history = restore;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 重同步结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResyncResult {
|
||||
/// 是否创建了新控制器
|
||||
pub controller_created: bool,
|
||||
/// 是否重启了控制器
|
||||
pub controller_restarted: bool,
|
||||
/// 是否恢复了历史数据
|
||||
pub history_restored: bool,
|
||||
/// 恢复的历史数据大小(字节)
|
||||
pub history_size: usize,
|
||||
}
|
||||
|
||||
impl Default for ResyncResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
controller_created: false,
|
||||
controller_restarted: false,
|
||||
history_restored: false,
|
||||
history_size: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态重同步控制器
|
||||
///
|
||||
/// 提供终端状态重同步功能,用于在连接恢复或控制器状态变更时重建终端状态。
|
||||
pub struct ResyncController;
|
||||
|
||||
impl ResyncController {
|
||||
/// 重同步控制器
|
||||
///
|
||||
/// 检查当前控制器状态并决定是否需要重启、创建新控制器或恢复历史数据。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `registry`: 控制器注册表
|
||||
/// - `tab_id`: Tab ID
|
||||
/// - `block_id`: Block ID
|
||||
/// - `block_meta`: 块元数据
|
||||
/// - `rt_opts`: 运行时选项
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_file`: 块文件(可选)
|
||||
/// - `options`: 重同步选项
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(ResyncResult)`: 重同步结果
|
||||
/// - `Err(TerminalError)`: 重同步失败
|
||||
///
|
||||
/// _Requirements: 2.1, 2.2, 2.5, 2.6_
|
||||
pub async fn resync_controller(
|
||||
registry: &ControllerRegistry,
|
||||
tab_id: &str,
|
||||
block_id: &str,
|
||||
block_meta: BlockMeta,
|
||||
rt_opts: Option<RuntimeOpts>,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
options: ResyncOptions,
|
||||
) -> Result<ResyncResult, TerminalError> {
|
||||
let mut result = ResyncResult::default();
|
||||
|
||||
// 1. 获取控制器名称
|
||||
let controller_name = block_meta.get_string("controller");
|
||||
let conn_name = block_meta.connection.clone();
|
||||
|
||||
tracing::info!(
|
||||
"[ResyncController] 开始重同步: block_id={}, controller={}, conn={:?}, force={}",
|
||||
block_id,
|
||||
controller_name,
|
||||
conn_name,
|
||||
options.force
|
||||
);
|
||||
|
||||
// 2. 如果不需要控制器,停止现有的
|
||||
if controller_name.is_empty() {
|
||||
if let Some(ctrl) = registry.get(block_id).await {
|
||||
tracing::info!(
|
||||
"[ResyncController] 控制器名称为空,停止现有控制器: block_id={}",
|
||||
block_id
|
||||
);
|
||||
let mut ctrl_guard = ctrl.write().await;
|
||||
ctrl_guard.stop(true, "done".to_string()).await?;
|
||||
drop(ctrl_guard);
|
||||
registry.remove(block_id).await;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 3. 获取现有控制器
|
||||
let existing = registry.get(block_id).await;
|
||||
|
||||
// 4. 检查是否需要替换控制器类型
|
||||
if let Some(ref ctrl) = existing {
|
||||
let ctrl_guard = ctrl.read().await;
|
||||
let status = ctrl_guard.get_runtime_status();
|
||||
let current_type = ctrl_guard.controller_type();
|
||||
|
||||
// 检查控制器类型是否变更
|
||||
let needs_replace = Self::check_needs_replace(current_type, &controller_name);
|
||||
|
||||
if needs_replace {
|
||||
tracing::info!(
|
||||
"[ResyncController] 控制器类型变更,需要替换: block_id={}, old={}, new={}",
|
||||
block_id,
|
||||
current_type,
|
||||
controller_name
|
||||
);
|
||||
drop(ctrl_guard);
|
||||
|
||||
// 停止并删除旧控制器
|
||||
let mut ctrl_guard = ctrl.write().await;
|
||||
ctrl_guard.stop(true, "done".to_string()).await?;
|
||||
drop(ctrl_guard);
|
||||
registry.remove(block_id).await;
|
||||
} else if controller_name == "shell" || controller_name == "cmd" {
|
||||
// 检查连接是否变更
|
||||
if status.shell_proc_status == "running" {
|
||||
if let Some(ref new_conn) = conn_name {
|
||||
if status.shell_proc_conn_name.as_ref() != Some(new_conn) {
|
||||
tracing::info!(
|
||||
"[ResyncController] 连接变更,需要重启: block_id={}, old={:?}, new={}",
|
||||
block_id,
|
||||
status.shell_proc_conn_name,
|
||||
new_conn
|
||||
);
|
||||
drop(ctrl_guard);
|
||||
|
||||
// 停止控制器但不删除,设置状态为 init
|
||||
let mut ctrl_guard = ctrl.write().await;
|
||||
ctrl_guard.stop(true, "init".to_string()).await?;
|
||||
result.controller_restarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 强制重启
|
||||
if options.force {
|
||||
if let Some(ctrl) = registry.get(block_id).await {
|
||||
tracing::info!("[ResyncController] 强制重启控制器: block_id={}", block_id);
|
||||
let mut ctrl_guard = ctrl.write().await;
|
||||
ctrl_guard.stop(true, "init".to_string()).await?;
|
||||
result.controller_restarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 创建或重用控制器
|
||||
let controller = match registry.get(block_id).await {
|
||||
Some(ctrl) => ctrl,
|
||||
None => {
|
||||
tracing::info!(
|
||||
"[ResyncController] 创建新控制器: block_id={}, type={}",
|
||||
block_id,
|
||||
controller_name
|
||||
);
|
||||
|
||||
let ctrl = Self::create_controller(
|
||||
&controller_name,
|
||||
tab_id,
|
||||
block_id,
|
||||
app_handle.clone(),
|
||||
block_file.clone(),
|
||||
)?;
|
||||
|
||||
registry.register(block_id.to_string(), ctrl).await;
|
||||
|
||||
result.controller_created = true;
|
||||
|
||||
registry
|
||||
.get(block_id)
|
||||
.await
|
||||
.ok_or_else(|| TerminalError::Internal("控制器注册失败".to_string()))?
|
||||
}
|
||||
};
|
||||
|
||||
// 7. 检查是否需要启动
|
||||
let status = {
|
||||
let ctrl_guard = controller.read().await;
|
||||
ctrl_guard.get_runtime_status()
|
||||
};
|
||||
|
||||
if status.shell_proc_status == "init" || status.shell_proc_status == "done" {
|
||||
tracing::info!(
|
||||
"[ResyncController] 启动控制器: block_id={}, status={}",
|
||||
block_id,
|
||||
status.shell_proc_status
|
||||
);
|
||||
|
||||
// 发送重置序列
|
||||
Self::send_reset_sequence(&app_handle, block_id, options.full_reset)?;
|
||||
|
||||
// 恢复历史数据
|
||||
if options.restore_history {
|
||||
if let Some(ref bf) = block_file {
|
||||
let history_size = Self::restore_history(&app_handle, block_id, bf)?;
|
||||
if history_size > 0 {
|
||||
result.history_restored = true;
|
||||
result.history_size = history_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动控制器
|
||||
let mut ctrl_guard = controller.write().await;
|
||||
ctrl_guard.start(block_meta, rt_opts, options.force).await?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[ResyncController] 重同步完成: block_id={}, created={}, restarted={}, history_restored={}",
|
||||
block_id,
|
||||
result.controller_created,
|
||||
result.controller_restarted,
|
||||
result.history_restored
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 检查是否需要替换控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `current_type`: 当前控制器类型
|
||||
/// - `new_type`: 新控制器类型
|
||||
///
|
||||
/// # 返回
|
||||
/// 需要替换返回 true
|
||||
fn check_needs_replace(current_type: &str, new_type: &str) -> bool {
|
||||
// 如果类型不同,需要替换
|
||||
// shell 和 cmd 可以互相转换(都是 ShellController)
|
||||
if current_type == new_type {
|
||||
return false;
|
||||
}
|
||||
|
||||
// shell 和 cmd 之间不需要替换控制器实例,只需要重启
|
||||
if (current_type == "shell" || current_type == "cmd")
|
||||
&& (new_type == "shell" || new_type == "cmd")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// 创建控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `controller_name`: 控制器类型名称
|
||||
/// - `tab_id`: Tab ID
|
||||
/// - `block_id`: Block ID
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_file`: 块文件(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// 创建的控制器实例
|
||||
fn create_controller(
|
||||
controller_name: &str,
|
||||
tab_id: &str,
|
||||
block_id: &str,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
) -> Result<Box<dyn BlockController>, TerminalError> {
|
||||
match controller_name {
|
||||
"shell" | "cmd" => {
|
||||
let controller = if let Some(bf) = block_file {
|
||||
ShellController::with_block_file(
|
||||
tab_id.to_string(),
|
||||
block_id.to_string(),
|
||||
controller_name.to_string(),
|
||||
app_handle,
|
||||
bf,
|
||||
)
|
||||
} else {
|
||||
ShellController::new(
|
||||
tab_id.to_string(),
|
||||
block_id.to_string(),
|
||||
controller_name.to_string(),
|
||||
app_handle,
|
||||
)
|
||||
};
|
||||
Ok(Box::new(controller))
|
||||
}
|
||||
_ => Err(TerminalError::Internal(format!(
|
||||
"未知的控制器类型: {}",
|
||||
controller_name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送终端重置序列
|
||||
///
|
||||
/// 通过 Tauri 事件发送终端重置序列到前端。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_id`: Block ID
|
||||
/// - `full_reset`: 是否使用完全重置序列
|
||||
///
|
||||
/// _Requirements: 2.3_
|
||||
fn send_reset_sequence(
|
||||
app_handle: &tauri::AppHandle,
|
||||
block_id: &str,
|
||||
full_reset: bool,
|
||||
) -> Result<(), TerminalError> {
|
||||
let reset_data = if full_reset {
|
||||
TERMINAL_RESET_SEQUENCE
|
||||
} else {
|
||||
TERMINAL_SOFT_RESET_SEQUENCE
|
||||
};
|
||||
|
||||
let data = BASE64.encode(reset_data);
|
||||
|
||||
app_handle
|
||||
.emit(
|
||||
event_names::TERMINAL_OUTPUT,
|
||||
TerminalOutputEvent {
|
||||
session_id: block_id.to_string(),
|
||||
data,
|
||||
},
|
||||
)
|
||||
.map_err(|e| TerminalError::Internal(format!("发送重置序列失败: {}", e)))?;
|
||||
|
||||
tracing::debug!(
|
||||
"[ResyncController] 发送重置序列: block_id={}, full={}",
|
||||
block_id,
|
||||
full_reset
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 恢复历史数据
|
||||
///
|
||||
/// 从 BlockFile 读取历史数据并通过 Tauri 事件发送到前端。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `block_id`: Block ID
|
||||
/// - `block_file`: 块文件
|
||||
///
|
||||
/// # 返回
|
||||
/// 恢复的数据大小(字节)
|
||||
///
|
||||
/// _Requirements: 2.4_
|
||||
fn restore_history(
|
||||
app_handle: &tauri::AppHandle,
|
||||
block_id: &str,
|
||||
block_file: &BlockFile,
|
||||
) -> Result<usize, TerminalError> {
|
||||
let history_data = block_file.read_all()?;
|
||||
|
||||
if history_data.is_empty() {
|
||||
tracing::debug!(
|
||||
"[ResyncController] 无历史数据需要恢复: block_id={}",
|
||||
block_id
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let data_size = history_data.len();
|
||||
let data = BASE64.encode(&history_data);
|
||||
|
||||
app_handle
|
||||
.emit(
|
||||
event_names::TERMINAL_OUTPUT,
|
||||
TerminalOutputEvent {
|
||||
session_id: block_id.to_string(),
|
||||
data,
|
||||
},
|
||||
)
|
||||
.map_err(|e| TerminalError::Internal(format!("发送历史数据失败: {}", e)))?;
|
||||
|
||||
tracing::info!(
|
||||
"[ResyncController] 恢复历史数据: block_id={}, size={} bytes",
|
||||
block_id,
|
||||
data_size
|
||||
);
|
||||
|
||||
Ok(data_size)
|
||||
}
|
||||
|
||||
/// 停止并删除控制器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `registry`: 控制器注册表
|
||||
/// - `block_id`: Block ID
|
||||
/// - `graceful`: 是否优雅停止
|
||||
pub async fn stop_and_remove_controller(
|
||||
registry: &ControllerRegistry,
|
||||
block_id: &str,
|
||||
graceful: bool,
|
||||
) -> Result<(), TerminalError> {
|
||||
if let Some(ctrl) = registry.get(block_id).await {
|
||||
let mut ctrl_guard = ctrl.write().await;
|
||||
ctrl_guard.stop(graceful, "done".to_string()).await?;
|
||||
drop(ctrl_guard);
|
||||
registry.remove(block_id).await;
|
||||
|
||||
tracing::info!(
|
||||
"[ResyncController] 停止并删除控制器: block_id={}, graceful={}",
|
||||
block_id,
|
||||
graceful
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取控制器状态
|
||||
///
|
||||
/// # 参数
|
||||
/// - `registry`: 控制器注册表
|
||||
/// - `block_id`: Block ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 控制器运行时状态,如果控制器不存在返回 None
|
||||
pub async fn get_controller_status(
|
||||
registry: &ControllerRegistry,
|
||||
block_id: &str,
|
||||
) -> Option<BlockControllerRuntimeStatus> {
|
||||
if let Some(ctrl) = registry.get(block_id).await {
|
||||
let ctrl_guard = ctrl.read().await;
|
||||
Some(ctrl_guard.get_runtime_status())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否需要重同步
|
||||
///
|
||||
/// 根据当前控制器状态和块元数据判断是否需要重同步。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `status`: 当前控制器状态(如果存在)
|
||||
/// - `block_meta`: 块元数据
|
||||
///
|
||||
/// # 返回
|
||||
/// 需要重同步返回 true
|
||||
pub fn needs_resync(
|
||||
status: Option<&BlockControllerRuntimeStatus>,
|
||||
block_meta: &BlockMeta,
|
||||
) -> bool {
|
||||
let controller_name = block_meta.get_string("controller");
|
||||
|
||||
// 如果没有控制器状态,需要重同步
|
||||
let Some(status) = status else {
|
||||
return !controller_name.is_empty();
|
||||
};
|
||||
|
||||
// 如果控制器名称为空但有控制器,需要重同步(停止控制器)
|
||||
if controller_name.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果状态为 init 或 done,需要重同步
|
||||
if status.shell_proc_status == "init" || status.shell_proc_status == "done" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果连接变更,需要重同步
|
||||
if let Some(ref new_conn) = block_meta.connection {
|
||||
if status.shell_proc_conn_name.as_ref() != Some(new_conn) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 便捷函数:执行简单的重同步
|
||||
///
|
||||
/// 使用默认选项执行重同步。
|
||||
pub async fn resync_controller(
|
||||
registry: &ControllerRegistry,
|
||||
tab_id: &str,
|
||||
block_id: &str,
|
||||
block_meta: BlockMeta,
|
||||
rt_opts: Option<RuntimeOpts>,
|
||||
app_handle: tauri::AppHandle,
|
||||
block_file: Option<Arc<BlockFile>>,
|
||||
force: bool,
|
||||
) -> Result<ResyncResult, TerminalError> {
|
||||
let options = ResyncOptions::new().with_force(force);
|
||||
ResyncController::resync_controller(
|
||||
registry, tab_id, block_id, block_meta, rt_opts, app_handle, block_file, options,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
//! Shell 集成处理器
|
||||
//!
|
||||
//! 处理 Shell 集成功能,包括:
|
||||
//! - 当前工作目录跟踪
|
||||
//! - 命令执行状态管理
|
||||
//! - 命令时间记录
|
||||
//! - OSC 序列处理
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 处理 OSC 7 更新当前目录
|
||||
//! - 处理 OSC 52 剪贴板操作
|
||||
//! - 处理 OSC 133 命令提示符标记
|
||||
//! - 处理 OSC 16162 Wave 命令
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 6.5: 支持 bash、zsh、fish、pwsh 四种 Shell 类型
|
||||
//! - 6.6: Shell 集成状态变更事件通知
|
||||
//! - 6.8: 命令开始和结束时间记录
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::RwLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::osc_parser::{OSCParser, OSCSequence, PromptMarkType};
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::events::event_names;
|
||||
|
||||
/// Shell 类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ShellType {
|
||||
/// Bash shell
|
||||
Bash,
|
||||
/// Zsh shell
|
||||
Zsh,
|
||||
/// Fish shell
|
||||
Fish,
|
||||
/// PowerShell
|
||||
Pwsh,
|
||||
/// 未知 Shell
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ShellType {
|
||||
/// 从 Shell 路径推断 Shell 类型
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
let path_lower = path.to_lowercase();
|
||||
if path_lower.contains("bash") {
|
||||
Self::Bash
|
||||
} else if path_lower.contains("zsh") {
|
||||
Self::Zsh
|
||||
} else if path_lower.contains("fish") {
|
||||
Self::Fish
|
||||
} else if path_lower.contains("pwsh") || path_lower.contains("powershell") {
|
||||
Self::Pwsh
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Shell 名称
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Bash => "bash",
|
||||
Self::Zsh => "zsh",
|
||||
Self::Fish => "fish",
|
||||
Self::Pwsh => "pwsh",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 集成状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellIntegrationStatus {
|
||||
/// 就绪状态(等待用户输入)
|
||||
Ready,
|
||||
/// 正在执行命令
|
||||
RunningCommand,
|
||||
/// 未知状态
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for ShellIntegrationStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令执行信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandInfo {
|
||||
/// 命令开始时间(Unix 时间戳,毫秒)
|
||||
pub start_time: i64,
|
||||
/// 命令结束时间(Unix 时间戳,毫秒)
|
||||
pub end_time: Option<i64>,
|
||||
/// 命令持续时间(毫秒)
|
||||
pub duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
/// 创建新的命令信息
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
start_time: current_timestamp_ms(),
|
||||
end_time: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记命令结束
|
||||
pub fn finish(&mut self) {
|
||||
let end = current_timestamp_ms();
|
||||
self.end_time = Some(end);
|
||||
self.duration_ms = Some(end - self.start_time);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandInfo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 集成状态变更事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShellIntegrationEvent {
|
||||
/// Block ID
|
||||
pub block_id: String,
|
||||
/// 新状态
|
||||
pub status: ShellIntegrationStatus,
|
||||
/// 当前目录(如果有变更)
|
||||
pub current_dir: Option<String>,
|
||||
/// 命令信息(如果有)
|
||||
pub command_info: Option<CommandInfo>,
|
||||
}
|
||||
|
||||
/// Shell 集成处理器
|
||||
///
|
||||
/// 管理单个终端会话的 Shell 集成状态。
|
||||
pub struct ShellIntegration {
|
||||
/// Block ID
|
||||
block_id: String,
|
||||
/// Shell 类型
|
||||
shell_type: RwLock<ShellType>,
|
||||
/// 当前工作目录
|
||||
current_dir: RwLock<Option<String>>,
|
||||
/// 集成状态
|
||||
status: RwLock<ShellIntegrationStatus>,
|
||||
/// 当前命令信息
|
||||
current_command: RwLock<Option<CommandInfo>>,
|
||||
/// 上次命令开始时间
|
||||
last_command_start: AtomicI64,
|
||||
/// Tauri 应用句柄(可选)
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
impl ShellIntegration {
|
||||
/// 创建新的 Shell 集成处理器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: Block ID
|
||||
pub fn new(block_id: String) -> Self {
|
||||
Self {
|
||||
block_id,
|
||||
shell_type: RwLock::new(ShellType::Unknown),
|
||||
current_dir: RwLock::new(None),
|
||||
status: RwLock::new(ShellIntegrationStatus::Unknown),
|
||||
current_command: RwLock::new(None),
|
||||
last_command_start: AtomicI64::new(0),
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有 Tauri 应用句柄的 Shell 集成处理器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: Block ID
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
pub fn with_app_handle(block_id: String, app_handle: tauri::AppHandle) -> Self {
|
||||
Self {
|
||||
block_id,
|
||||
shell_type: RwLock::new(ShellType::Unknown),
|
||||
current_dir: RwLock::new(None),
|
||||
status: RwLock::new(ShellIntegrationStatus::Unknown),
|
||||
current_command: RwLock::new(None),
|
||||
last_command_start: AtomicI64::new(0),
|
||||
app_handle: Some(app_handle),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 Shell 类型
|
||||
///
|
||||
/// # 参数
|
||||
/// - `shell_type`: Shell 类型
|
||||
pub fn set_shell_type(&self, shell_type: ShellType) {
|
||||
let mut guard = self.shell_type.write().unwrap();
|
||||
*guard = shell_type;
|
||||
}
|
||||
|
||||
/// 从 Shell 路径设置 Shell 类型
|
||||
///
|
||||
/// # 参数
|
||||
/// - `path`: Shell 可执行文件路径
|
||||
pub fn set_shell_type_from_path(&self, path: &str) {
|
||||
self.set_shell_type(ShellType::from_path(path));
|
||||
}
|
||||
|
||||
/// 获取 Shell 类型
|
||||
pub fn get_shell_type(&self) -> ShellType {
|
||||
*self.shell_type.read().unwrap()
|
||||
}
|
||||
|
||||
/// 获取当前工作目录
|
||||
pub fn get_current_dir(&self) -> Option<String> {
|
||||
self.current_dir.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 获取集成状态
|
||||
pub fn get_status(&self) -> ShellIntegrationStatus {
|
||||
*self.status.read().unwrap()
|
||||
}
|
||||
|
||||
/// 获取当前命令信息
|
||||
pub fn get_current_command(&self) -> Option<CommandInfo> {
|
||||
self.current_command.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 处理 PTY 输出数据
|
||||
///
|
||||
/// 解析数据中的 OSC 序列并更新状态。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: PTY 输出数据
|
||||
///
|
||||
/// # 返回
|
||||
/// 处理的 OSC 序列数量
|
||||
pub fn process_output(&self, data: &[u8]) -> usize {
|
||||
let parsed = OSCParser::parse(data);
|
||||
let count = parsed.len();
|
||||
|
||||
for osc in parsed {
|
||||
if let Err(e) = self.process_osc(&osc.sequence) {
|
||||
tracing::warn!(
|
||||
"[ShellIntegration] 处理 OSC 序列失败: block_id={}, error={}",
|
||||
self.block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// 处理单个 OSC 序列
|
||||
///
|
||||
/// # 参数
|
||||
/// - `sequence`: OSC 序列
|
||||
///
|
||||
/// _Requirements: 6.1, 6.2, 6.3, 6.4_
|
||||
pub fn process_osc(&self, sequence: &OSCSequence) -> Result<(), TerminalError> {
|
||||
match sequence {
|
||||
OSCSequence::CurrentDirectory { hostname: _, path } => {
|
||||
self.update_current_dir(path.clone());
|
||||
}
|
||||
OSCSequence::Clipboard { selection, data } => {
|
||||
self.handle_clipboard(selection, data)?;
|
||||
}
|
||||
OSCSequence::PromptMark { mark_type } => {
|
||||
self.handle_prompt_mark(*mark_type);
|
||||
}
|
||||
OSCSequence::WaveCommand { command } => {
|
||||
self.handle_wave_command(command)?;
|
||||
}
|
||||
OSCSequence::Unknown { code, params } => {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 未知 OSC 序列: block_id={}, code={}, params={}",
|
||||
self.block_id,
|
||||
code,
|
||||
params
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新当前工作目录
|
||||
///
|
||||
/// _Requirements: 6.1_
|
||||
fn update_current_dir(&self, path: String) {
|
||||
let old_dir = {
|
||||
let mut guard = self.current_dir.write().unwrap();
|
||||
let old = guard.clone();
|
||||
*guard = Some(path.clone());
|
||||
old
|
||||
};
|
||||
|
||||
if old_dir.as_ref() != Some(&path) {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 目录变更: block_id={}, old={:?}, new={}",
|
||||
self.block_id,
|
||||
old_dir,
|
||||
path
|
||||
);
|
||||
|
||||
// 发送状态变更事件
|
||||
self.send_status_event(Some(path), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理剪贴板操作
|
||||
///
|
||||
/// _Requirements: 6.2_
|
||||
fn handle_clipboard(&self, selection: &str, data: &str) -> Result<(), TerminalError> {
|
||||
if data == "?" {
|
||||
// 查询请求,暂不支持
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 剪贴板查询请求: block_id={}, selection={}",
|
||||
self.block_id,
|
||||
selection
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 解码剪贴板数据
|
||||
if let Some(content) = OSCParser::decode_clipboard_data(data) {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 剪贴板写入: block_id={}, selection={}, len={}",
|
||||
self.block_id,
|
||||
selection,
|
||||
content.len()
|
||||
);
|
||||
|
||||
// 发送剪贴板事件到前端
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
let _ = app_handle.emit(
|
||||
event_names::CLIPBOARD_WRITE,
|
||||
serde_json::json!({
|
||||
"block_id": self.block_id,
|
||||
"selection": selection,
|
||||
"content": content,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理命令提示符标记
|
||||
///
|
||||
/// _Requirements: 6.3, 6.6, 6.8_
|
||||
fn handle_prompt_mark(&self, mark_type: PromptMarkType) {
|
||||
match mark_type {
|
||||
PromptMarkType::PromptStart => {
|
||||
// 提示符开始,命令已结束
|
||||
self.finish_command();
|
||||
self.set_status(ShellIntegrationStatus::Ready);
|
||||
}
|
||||
PromptMarkType::CommandStart => {
|
||||
// 用户开始输入命令
|
||||
// 状态保持 Ready
|
||||
}
|
||||
PromptMarkType::CommandExecuted => {
|
||||
// 命令开始执行
|
||||
self.start_command();
|
||||
self.set_status(ShellIntegrationStatus::RunningCommand);
|
||||
}
|
||||
PromptMarkType::CommandFinished => {
|
||||
// 命令执行完成
|
||||
self.finish_command();
|
||||
self.set_status(ShellIntegrationStatus::Ready);
|
||||
}
|
||||
PromptMarkType::Unknown(c) => {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 未知提示符标记: block_id={}, mark={}",
|
||||
self.block_id,
|
||||
c
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 Wave 命令
|
||||
///
|
||||
/// _Requirements: 6.4_
|
||||
fn handle_wave_command(&self, command: &str) -> Result<(), TerminalError> {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] Wave 命令: block_id={}, command={}",
|
||||
self.block_id,
|
||||
command
|
||||
);
|
||||
|
||||
// 解析命令
|
||||
let parts: Vec<&str> = command.splitn(2, ' ').collect();
|
||||
let cmd = parts.first().unwrap_or(&"");
|
||||
let args = parts.get(1).unwrap_or(&"");
|
||||
|
||||
match *cmd {
|
||||
"setcwd" => {
|
||||
// 设置当前目录
|
||||
if !args.is_empty() {
|
||||
self.update_current_dir(args.to_string());
|
||||
}
|
||||
}
|
||||
"setshell" => {
|
||||
// 设置 Shell 类型
|
||||
if !args.is_empty() {
|
||||
self.set_shell_type_from_path(args);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 未知 Wave 命令: block_id={}, cmd={}",
|
||||
self.block_id,
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置状态
|
||||
fn set_status(&self, new_status: ShellIntegrationStatus) {
|
||||
let old_status = {
|
||||
let mut guard = self.status.write().unwrap();
|
||||
let old = *guard;
|
||||
*guard = new_status;
|
||||
old
|
||||
};
|
||||
|
||||
if old_status != new_status {
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 状态变更: block_id={}, old={:?}, new={:?}",
|
||||
self.block_id,
|
||||
old_status,
|
||||
new_status
|
||||
);
|
||||
|
||||
// 发送状态变更事件
|
||||
self.send_status_event(None, self.get_current_command());
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始命令
|
||||
///
|
||||
/// _Requirements: 6.8_
|
||||
fn start_command(&self) {
|
||||
let now = current_timestamp_ms();
|
||||
self.last_command_start.store(now, Ordering::SeqCst);
|
||||
|
||||
let mut guard = self.current_command.write().unwrap();
|
||||
*guard = Some(CommandInfo::new());
|
||||
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 命令开始: block_id={}, time={}",
|
||||
self.block_id,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
/// 结束命令
|
||||
///
|
||||
/// _Requirements: 6.8_
|
||||
fn finish_command(&self) {
|
||||
let mut guard = self.current_command.write().unwrap();
|
||||
if let Some(ref mut cmd) = *guard {
|
||||
cmd.finish();
|
||||
tracing::debug!(
|
||||
"[ShellIntegration] 命令结束: block_id={}, duration_ms={:?}",
|
||||
self.block_id,
|
||||
cmd.duration_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送状态变更事件
|
||||
///
|
||||
/// _Requirements: 6.6_
|
||||
fn send_status_event(&self, current_dir: Option<String>, command_info: Option<CommandInfo>) {
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
let event = ShellIntegrationEvent {
|
||||
block_id: self.block_id.clone(),
|
||||
status: self.get_status(),
|
||||
current_dir: current_dir.or_else(|| self.get_current_dir()),
|
||||
command_info,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit(event_names::SHELL_INTEGRATION_STATUS, &event) {
|
||||
tracing::warn!(
|
||||
"[ShellIntegration] 发送状态事件失败: block_id={}, error={}",
|
||||
self.block_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
pub fn reset(&self) {
|
||||
{
|
||||
let mut guard = self.current_dir.write().unwrap();
|
||||
*guard = None;
|
||||
}
|
||||
{
|
||||
let mut guard = self.status.write().unwrap();
|
||||
*guard = ShellIntegrationStatus::Unknown;
|
||||
}
|
||||
{
|
||||
let mut guard = self.current_command.write().unwrap();
|
||||
*guard = None;
|
||||
}
|
||||
self.last_command_start.store(0, Ordering::SeqCst);
|
||||
|
||||
tracing::debug!("[ShellIntegration] 状态重置: block_id={}", self.block_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前时间戳(毫秒)
|
||||
fn current_timestamp_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_shell_type_from_path() {
|
||||
assert_eq!(ShellType::from_path("/bin/bash"), ShellType::Bash);
|
||||
assert_eq!(ShellType::from_path("/usr/bin/zsh"), ShellType::Zsh);
|
||||
assert_eq!(ShellType::from_path("/usr/local/bin/fish"), ShellType::Fish);
|
||||
assert_eq!(ShellType::from_path("/usr/bin/pwsh"), ShellType::Pwsh);
|
||||
assert_eq!(
|
||||
ShellType::from_path("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
ShellType::Pwsh
|
||||
);
|
||||
assert_eq!(ShellType::from_path("/bin/sh"), ShellType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_integration_status_default() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown);
|
||||
assert!(integration.get_current_dir().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_osc_7() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
let osc = OSCSequence::CurrentDirectory {
|
||||
hostname: Some("localhost".to_string()),
|
||||
path: "/home/user".to_string(),
|
||||
};
|
||||
|
||||
integration.process_osc(&osc).unwrap();
|
||||
assert_eq!(
|
||||
integration.get_current_dir(),
|
||||
Some("/home/user".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_osc_133_prompt_start() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
// 先设置为 RunningCommand
|
||||
let osc_exec = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::CommandExecuted,
|
||||
};
|
||||
integration.process_osc(&osc_exec).unwrap();
|
||||
assert_eq!(
|
||||
integration.get_status(),
|
||||
ShellIntegrationStatus::RunningCommand
|
||||
);
|
||||
|
||||
// 然后 PromptStart 应该切换到 Ready
|
||||
let osc_prompt = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::PromptStart,
|
||||
};
|
||||
integration.process_osc(&osc_prompt).unwrap();
|
||||
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_osc_133_command_executed() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
let osc = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::CommandExecuted,
|
||||
};
|
||||
|
||||
integration.process_osc(&osc).unwrap();
|
||||
assert_eq!(
|
||||
integration.get_status(),
|
||||
ShellIntegrationStatus::RunningCommand
|
||||
);
|
||||
assert!(integration.get_current_command().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_osc_133_command_finished() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
// 先执行命令
|
||||
let osc_exec = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::CommandExecuted,
|
||||
};
|
||||
integration.process_osc(&osc_exec).unwrap();
|
||||
|
||||
// 等待一小段时间
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
|
||||
// 命令结束
|
||||
let osc_finish = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::CommandFinished,
|
||||
};
|
||||
integration.process_osc(&osc_finish).unwrap();
|
||||
|
||||
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
|
||||
|
||||
let cmd_info = integration.get_current_command().unwrap();
|
||||
assert!(cmd_info.end_time.is_some());
|
||||
assert!(cmd_info.duration_ms.is_some());
|
||||
assert!(cmd_info.duration_ms.unwrap() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_wave_command_setcwd() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
let osc = OSCSequence::WaveCommand {
|
||||
command: "setcwd /home/user/projects".to_string(),
|
||||
};
|
||||
|
||||
integration.process_osc(&osc).unwrap();
|
||||
assert_eq!(
|
||||
integration.get_current_dir(),
|
||||
Some("/home/user/projects".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_wave_command_setshell() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
let osc = OSCSequence::WaveCommand {
|
||||
command: "setshell /usr/bin/zsh".to_string(),
|
||||
};
|
||||
|
||||
integration.process_osc(&osc).unwrap();
|
||||
assert_eq!(integration.get_shell_type(), ShellType::Zsh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_output() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
// 包含多个 OSC 序列的数据
|
||||
let data = b"Hello\x1b]7;file:///home/user\x07World\x1b]133;A\x07End";
|
||||
let count = integration.process_output(data);
|
||||
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(
|
||||
integration.get_current_dir(),
|
||||
Some("/home/user".to_string())
|
||||
);
|
||||
assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let integration = ShellIntegration::new("test-block".to_string());
|
||||
|
||||
// 设置一些状态
|
||||
let osc_dir = OSCSequence::CurrentDirectory {
|
||||
hostname: None,
|
||||
path: "/home/user".to_string(),
|
||||
};
|
||||
integration.process_osc(&osc_dir).unwrap();
|
||||
|
||||
let osc_exec = OSCSequence::PromptMark {
|
||||
mark_type: PromptMarkType::CommandExecuted,
|
||||
};
|
||||
integration.process_osc(&osc_exec).unwrap();
|
||||
|
||||
// 重置
|
||||
integration.reset();
|
||||
|
||||
assert!(integration.get_current_dir().is_none());
|
||||
assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown);
|
||||
assert!(integration.get_current_command().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_info() {
|
||||
let mut cmd = CommandInfo::new();
|
||||
assert!(cmd.start_time > 0);
|
||||
assert!(cmd.end_time.is_none());
|
||||
assert!(cmd.duration_ms.is_none());
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
cmd.finish();
|
||||
|
||||
assert!(cmd.end_time.is_some());
|
||||
assert!(cmd.duration_ms.is_some());
|
||||
assert!(cmd.duration_ms.unwrap() >= 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
//! Shell 集成脚本管理
|
||||
//!
|
||||
//! 提供各种 Shell 的集成脚本安装和管理功能。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 生成 Shell 集成脚本
|
||||
//! - 安装脚本到用户目录
|
||||
//! - 构建带集成的 Shell 启动命令
|
||||
//!
|
||||
//! ## 支持的 Shell
|
||||
//! - Bash (--rcfile)
|
||||
//! - Zsh (ZDOTDIR)
|
||||
//! - Fish (-C source)
|
||||
//! - PowerShell (pwsh)
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 17.5: 支持自定义 Shell 路径和参数
|
||||
//! - 17.6: 支持自定义初始化脚本
|
||||
//! - 17.7: 支持环境变量配置
|
||||
//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录
|
||||
//! - 17.9: bash 使用 --rcfile 加载集成脚本
|
||||
//! - 17.10: fish 使用 -C 参数 source 集成脚本
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::integration::shell_integration::ShellType;
|
||||
|
||||
/// Shell 集成脚本目录名
|
||||
const SHELL_INTEGRATION_DIR: &str = "shell-integration";
|
||||
|
||||
/// Bash 集成脚本内容
|
||||
const BASH_INTEGRATION_SCRIPT: &str = r#"# ProxyCast Shell Integration for Bash
|
||||
# This script provides shell integration features
|
||||
|
||||
# 保存原始 PS1
|
||||
if [ -z "$_PROXYCAST_ORIG_PS1" ]; then
|
||||
_PROXYCAST_ORIG_PS1="$PS1"
|
||||
fi
|
||||
|
||||
# OSC 7 - 报告当前工作目录
|
||||
__proxycast_osc7() {
|
||||
printf '\033]7;file://%s%s\033\\' "${HOSTNAME:-localhost}" "$PWD"
|
||||
}
|
||||
|
||||
# OSC 133 - 命令提示符标记
|
||||
__proxycast_prompt_start() {
|
||||
printf '\033]133;A\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_start() {
|
||||
printf '\033]133;B\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_executed() {
|
||||
printf '\033]133;C\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_finished() {
|
||||
printf '\033]133;D;%s\033\\' "$?"
|
||||
}
|
||||
|
||||
# 设置 PROMPT_COMMAND
|
||||
__proxycast_precmd() {
|
||||
local exit_code=$?
|
||||
__proxycast_command_finished
|
||||
__proxycast_osc7
|
||||
__proxycast_prompt_start
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
__proxycast_preexec() {
|
||||
__proxycast_command_executed
|
||||
}
|
||||
|
||||
# 安装 preexec 钩子(如果可用)
|
||||
if [ -n "$BASH_VERSION" ]; then
|
||||
# 使用 DEBUG trap 模拟 preexec
|
||||
__proxycast_debug_trap() {
|
||||
if [ -n "$COMP_LINE" ]; then
|
||||
return
|
||||
fi
|
||||
if [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ]; then
|
||||
return
|
||||
fi
|
||||
__proxycast_preexec
|
||||
}
|
||||
|
||||
trap '__proxycast_debug_trap' DEBUG
|
||||
fi
|
||||
|
||||
# 设置 PROMPT_COMMAND
|
||||
if [ -z "$PROMPT_COMMAND" ]; then
|
||||
PROMPT_COMMAND="__proxycast_precmd"
|
||||
else
|
||||
PROMPT_COMMAND="__proxycast_precmd;$PROMPT_COMMAND"
|
||||
fi
|
||||
|
||||
# 加载用户的 .bashrc(如果存在且我们是通过 --rcfile 启动的)
|
||||
if [ -n "$_PROXYCAST_LOAD_BASHRC" ] && [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# 标记集成已加载
|
||||
export PROXYCAST_SHELL_INTEGRATION=1
|
||||
"#;
|
||||
|
||||
/// Zsh 集成脚本内容 (.zshrc)
|
||||
const ZSH_INTEGRATION_SCRIPT: &str = r#"# ProxyCast Shell Integration for Zsh
|
||||
# This script provides shell integration features
|
||||
|
||||
# OSC 7 - 报告当前工作目录
|
||||
__proxycast_osc7() {
|
||||
printf '\033]7;file://%s%s\033\\' "${HOST:-localhost}" "$PWD"
|
||||
}
|
||||
|
||||
# OSC 133 - 命令提示符标记
|
||||
__proxycast_prompt_start() {
|
||||
printf '\033]133;A\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_start() {
|
||||
printf '\033]133;B\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_executed() {
|
||||
printf '\033]133;C\033\\'
|
||||
}
|
||||
|
||||
__proxycast_command_finished() {
|
||||
printf '\033]133;D;%s\033\\' "$?"
|
||||
}
|
||||
|
||||
# precmd 钩子 - 命令执行后
|
||||
__proxycast_precmd() {
|
||||
local exit_code=$?
|
||||
__proxycast_command_finished
|
||||
__proxycast_osc7
|
||||
__proxycast_prompt_start
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# preexec 钩子 - 命令执行前
|
||||
__proxycast_preexec() {
|
||||
__proxycast_command_executed
|
||||
}
|
||||
|
||||
# 注册钩子
|
||||
autoload -Uz add-zsh-hook
|
||||
add-zsh-hook precmd __proxycast_precmd
|
||||
add-zsh-hook preexec __proxycast_preexec
|
||||
|
||||
# 加载用户的原始配置
|
||||
if [ -n "$_PROXYCAST_ORIG_ZDOTDIR" ]; then
|
||||
if [ -f "$_PROXYCAST_ORIG_ZDOTDIR/.zshrc" ]; then
|
||||
source "$_PROXYCAST_ORIG_ZDOTDIR/.zshrc"
|
||||
fi
|
||||
elif [ -f "$HOME/.zshrc" ]; then
|
||||
source "$HOME/.zshrc"
|
||||
fi
|
||||
|
||||
# 标记集成已加载
|
||||
export PROXYCAST_SHELL_INTEGRATION=1
|
||||
"#;
|
||||
|
||||
/// Zsh .zshenv 内容(用于设置 ZDOTDIR)
|
||||
const ZSH_ZSHENV_SCRIPT: &str = r#"# ProxyCast Zsh Environment
|
||||
# 保存原始 ZDOTDIR
|
||||
if [ -z "$_PROXYCAST_ORIG_ZDOTDIR" ]; then
|
||||
export _PROXYCAST_ORIG_ZDOTDIR="${ZDOTDIR:-$HOME}"
|
||||
fi
|
||||
|
||||
# 加载原始 .zshenv
|
||||
if [ -f "$_PROXYCAST_ORIG_ZDOTDIR/.zshenv" ]; then
|
||||
source "$_PROXYCAST_ORIG_ZDOTDIR/.zshenv"
|
||||
fi
|
||||
"#;
|
||||
|
||||
/// Fish 集成脚本内容
|
||||
const FISH_INTEGRATION_SCRIPT: &str = r#"# ProxyCast Shell Integration for Fish
|
||||
# This script provides shell integration features
|
||||
|
||||
# OSC 7 - 报告当前工作目录
|
||||
function __proxycast_osc7 --on-variable PWD
|
||||
printf '\033]7;file://%s%s\033\\' (hostname) $PWD
|
||||
end
|
||||
|
||||
# OSC 133 - 命令提示符标记
|
||||
function __proxycast_prompt_start
|
||||
printf '\033]133;A\033\\'
|
||||
end
|
||||
|
||||
function __proxycast_command_executed
|
||||
printf '\033]133;C\033\\'
|
||||
end
|
||||
|
||||
function __proxycast_command_finished
|
||||
printf '\033]133;D;%s\033\\' $status
|
||||
end
|
||||
|
||||
# Fish 事件钩子
|
||||
function __proxycast_fish_prompt --on-event fish_prompt
|
||||
__proxycast_command_finished
|
||||
__proxycast_osc7
|
||||
__proxycast_prompt_start
|
||||
end
|
||||
|
||||
function __proxycast_fish_preexec --on-event fish_preexec
|
||||
__proxycast_command_executed
|
||||
end
|
||||
|
||||
# 初始化
|
||||
__proxycast_osc7
|
||||
|
||||
# 标记集成已加载
|
||||
set -gx PROXYCAST_SHELL_INTEGRATION 1
|
||||
"#;
|
||||
|
||||
/// PowerShell 集成脚本内容
|
||||
const PWSH_INTEGRATION_SCRIPT: &str = r#"# ProxyCast Shell Integration for PowerShell
|
||||
# This script provides shell integration features
|
||||
|
||||
# OSC 7 - 报告当前工作目录
|
||||
function Send-ProxyCastOsc7 {
|
||||
$hostname = [System.Net.Dns]::GetHostName()
|
||||
$pwd = $PWD.Path -replace '\\', '/'
|
||||
Write-Host -NoNewline "`e]7;file://$hostname$pwd`e\"
|
||||
}
|
||||
|
||||
# OSC 133 - 命令提示符标记
|
||||
function Send-ProxyCastPromptStart {
|
||||
Write-Host -NoNewline "`e]133;A`e\"
|
||||
}
|
||||
|
||||
function Send-ProxyCastCommandExecuted {
|
||||
Write-Host -NoNewline "`e]133;C`e\"
|
||||
}
|
||||
|
||||
function Send-ProxyCastCommandFinished {
|
||||
param([int]$ExitCode = 0)
|
||||
Write-Host -NoNewline "`e]133;D;$ExitCode`e\"
|
||||
}
|
||||
|
||||
# 保存原始 prompt 函数
|
||||
if (-not (Test-Path Function:\__ProxyCastOriginalPrompt)) {
|
||||
if (Test-Path Function:\prompt) {
|
||||
Copy-Item Function:\prompt Function:\__ProxyCastOriginalPrompt
|
||||
} else {
|
||||
function __ProxyCastOriginalPrompt { "PS $($PWD.Path)> " }
|
||||
}
|
||||
}
|
||||
|
||||
# 自定义 prompt 函数
|
||||
function prompt {
|
||||
$exitCode = $LASTEXITCODE
|
||||
Send-ProxyCastCommandFinished -ExitCode $exitCode
|
||||
Send-ProxyCastOsc7
|
||||
Send-ProxyCastPromptStart
|
||||
$LASTEXITCODE = $exitCode
|
||||
__ProxyCastOriginalPrompt
|
||||
}
|
||||
|
||||
# PSReadLine 钩子(如果可用)
|
||||
if (Get-Module -ListAvailable -Name PSReadLine) {
|
||||
$existingHandler = (Get-PSReadLineOption).AddToHistoryHandler
|
||||
Set-PSReadLineOption -AddToHistoryHandler {
|
||||
param([string]$line)
|
||||
Send-ProxyCastCommandExecuted
|
||||
if ($existingHandler) {
|
||||
return & $existingHandler $line
|
||||
}
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# 标记集成已加载
|
||||
$env:PROXYCAST_SHELL_INTEGRATION = "1"
|
||||
"#;
|
||||
|
||||
/// Shell 集成脚本管理器
|
||||
pub struct ShellScripts {
|
||||
/// 集成脚本目录
|
||||
integration_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ShellScripts {
|
||||
/// 创建新的 Shell 脚本管理器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_data_dir`: 应用数据目录
|
||||
///
|
||||
/// # 返回
|
||||
/// 新的 ShellScripts 实例
|
||||
pub fn new(app_data_dir: &Path) -> Self {
|
||||
Self {
|
||||
integration_dir: app_data_dir.join(SHELL_INTEGRATION_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取集成脚本目录
|
||||
pub fn integration_dir(&self) -> &Path {
|
||||
&self.integration_dir
|
||||
}
|
||||
|
||||
/// 确保集成脚本目录存在
|
||||
fn ensure_integration_dir(&self) -> Result<(), TerminalError> {
|
||||
if !self.integration_dir.exists() {
|
||||
fs::create_dir_all(&self.integration_dir)
|
||||
.map_err(|e| TerminalError::Internal(format!("创建集成脚本目录失败: {}", e)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装所有 Shell 集成脚本
|
||||
///
|
||||
/// 将集成脚本写入应用数据目录。
|
||||
///
|
||||
/// # 返回
|
||||
/// 成功返回 Ok(()), 失败返回错误
|
||||
pub fn install_all(&self) -> Result<(), TerminalError> {
|
||||
self.ensure_integration_dir()?;
|
||||
|
||||
// 安装 Bash 脚本
|
||||
self.install_bash_scripts()?;
|
||||
|
||||
// 安装 Zsh 脚本
|
||||
self.install_zsh_scripts()?;
|
||||
|
||||
// 安装 Fish 脚本
|
||||
self.install_fish_scripts()?;
|
||||
|
||||
// 安装 PowerShell 脚本
|
||||
self.install_pwsh_scripts()?;
|
||||
|
||||
tracing::info!(
|
||||
"[ShellScripts] 所有集成脚本已安装: dir={}",
|
||||
self.integration_dir.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Bash 集成脚本
|
||||
///
|
||||
/// _Requirements: 17.9_
|
||||
fn install_bash_scripts(&self) -> Result<(), TerminalError> {
|
||||
let bash_dir = self.integration_dir.join("bash");
|
||||
fs::create_dir_all(&bash_dir)
|
||||
.map_err(|e| TerminalError::Internal(format!("创建 bash 目录失败: {}", e)))?;
|
||||
|
||||
let script_path = bash_dir.join("proxycast.bash");
|
||||
fs::write(&script_path, BASH_INTEGRATION_SCRIPT)
|
||||
.map_err(|e| TerminalError::Internal(format!("写入 bash 脚本失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[ShellScripts] Bash 脚本已安装: {}", script_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Zsh 集成脚本
|
||||
///
|
||||
/// _Requirements: 17.8_
|
||||
fn install_zsh_scripts(&self) -> Result<(), TerminalError> {
|
||||
let zsh_dir = self.integration_dir.join("zsh");
|
||||
fs::create_dir_all(&zsh_dir)
|
||||
.map_err(|e| TerminalError::Internal(format!("创建 zsh 目录失败: {}", e)))?;
|
||||
|
||||
// 写入 .zshenv
|
||||
let zshenv_path = zsh_dir.join(".zshenv");
|
||||
fs::write(&zshenv_path, ZSH_ZSHENV_SCRIPT)
|
||||
.map_err(|e| TerminalError::Internal(format!("写入 .zshenv 失败: {}", e)))?;
|
||||
|
||||
// 写入 .zshrc
|
||||
let zshrc_path = zsh_dir.join(".zshrc");
|
||||
fs::write(&zshrc_path, ZSH_INTEGRATION_SCRIPT)
|
||||
.map_err(|e| TerminalError::Internal(format!("写入 .zshrc 失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[ShellScripts] Zsh 脚本已安装: {}", zsh_dir.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 Fish 集成脚本
|
||||
///
|
||||
/// _Requirements: 17.10_
|
||||
fn install_fish_scripts(&self) -> Result<(), TerminalError> {
|
||||
let fish_dir = self.integration_dir.join("fish");
|
||||
fs::create_dir_all(&fish_dir)
|
||||
.map_err(|e| TerminalError::Internal(format!("创建 fish 目录失败: {}", e)))?;
|
||||
|
||||
let script_path = fish_dir.join("proxycast.fish");
|
||||
fs::write(&script_path, FISH_INTEGRATION_SCRIPT)
|
||||
.map_err(|e| TerminalError::Internal(format!("写入 fish 脚本失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[ShellScripts] Fish 脚本已安装: {}", script_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 安装 PowerShell 集成脚本
|
||||
fn install_pwsh_scripts(&self) -> Result<(), TerminalError> {
|
||||
let pwsh_dir = self.integration_dir.join("pwsh");
|
||||
fs::create_dir_all(&pwsh_dir)
|
||||
.map_err(|e| TerminalError::Internal(format!("创建 pwsh 目录失败: {}", e)))?;
|
||||
|
||||
let script_path = pwsh_dir.join("proxycast.ps1");
|
||||
fs::write(&script_path, PWSH_INTEGRATION_SCRIPT)
|
||||
.map_err(|e| TerminalError::Internal(format!("写入 pwsh 脚本失败: {}", e)))?;
|
||||
|
||||
tracing::debug!(
|
||||
"[ShellScripts] PowerShell 脚本已安装: {}",
|
||||
script_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取 Bash 集成脚本路径
|
||||
pub fn bash_script_path(&self) -> PathBuf {
|
||||
self.integration_dir.join("bash").join("proxycast.bash")
|
||||
}
|
||||
|
||||
/// 获取 Zsh 集成目录路径(用于 ZDOTDIR)
|
||||
pub fn zsh_integration_dir(&self) -> PathBuf {
|
||||
self.integration_dir.join("zsh")
|
||||
}
|
||||
|
||||
/// 获取 Fish 集成脚本路径
|
||||
pub fn fish_script_path(&self) -> PathBuf {
|
||||
self.integration_dir.join("fish").join("proxycast.fish")
|
||||
}
|
||||
|
||||
/// 获取 PowerShell 集成脚本路径
|
||||
pub fn pwsh_script_path(&self) -> PathBuf {
|
||||
self.integration_dir.join("pwsh").join("proxycast.ps1")
|
||||
}
|
||||
|
||||
/// 检查集成脚本是否已安装
|
||||
pub fn is_installed(&self) -> bool {
|
||||
self.bash_script_path().exists()
|
||||
&& self.zsh_integration_dir().join(".zshrc").exists()
|
||||
&& self.fish_script_path().exists()
|
||||
&& self.pwsh_script_path().exists()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 启动配置
|
||||
///
|
||||
/// 包含启动 Shell 所需的命令和环境变量配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellLaunchConfig {
|
||||
/// Shell 可执行文件路径
|
||||
pub shell_path: String,
|
||||
/// Shell 参数
|
||||
pub args: Vec<String>,
|
||||
/// 环境变量
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ShellLaunchConfig {
|
||||
/// 创建新的 Shell 启动配置
|
||||
pub fn new(shell_path: String) -> Self {
|
||||
Self {
|
||||
shell_path,
|
||||
args: Vec::new(),
|
||||
env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加参数
|
||||
pub fn arg(mut self, arg: impl Into<String>) -> Self {
|
||||
self.args.push(arg.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加环境变量
|
||||
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.env.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 启动配置构建器
|
||||
///
|
||||
/// 根据 Shell 类型和集成脚本路径构建启动配置。
|
||||
pub struct ShellLaunchBuilder {
|
||||
/// Shell 脚本管理器
|
||||
scripts: ShellScripts,
|
||||
/// Block ID(用于环境变量)
|
||||
block_id: String,
|
||||
}
|
||||
|
||||
impl ShellLaunchBuilder {
|
||||
/// 创建新的启动配置构建器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_data_dir`: 应用数据目录
|
||||
/// - `block_id`: Block ID
|
||||
pub fn new(app_data_dir: &Path, block_id: String) -> Self {
|
||||
Self {
|
||||
scripts: ShellScripts::new(app_data_dir),
|
||||
block_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保集成脚本已安装
|
||||
pub fn ensure_scripts_installed(&self) -> Result<(), TerminalError> {
|
||||
if !self.scripts.is_installed() {
|
||||
self.scripts.install_all()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构建 Shell 启动配置
|
||||
///
|
||||
/// 根据 Shell 类型构建带有集成脚本的启动配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `shell_path`: Shell 可执行文件路径
|
||||
/// - `custom_env`: 自定义环境变量
|
||||
///
|
||||
/// # 返回
|
||||
/// Shell 启动配置
|
||||
///
|
||||
/// _Requirements: 17.5, 17.6, 17.7, 17.8, 17.9, 17.10_
|
||||
pub fn build(
|
||||
&self,
|
||||
shell_path: &str,
|
||||
custom_env: Option<&HashMap<String, String>>,
|
||||
) -> Result<ShellLaunchConfig, TerminalError> {
|
||||
// 确保脚本已安装
|
||||
self.ensure_scripts_installed()?;
|
||||
|
||||
let shell_type = ShellType::from_path(shell_path);
|
||||
let mut config = ShellLaunchConfig::new(shell_path.to_string());
|
||||
|
||||
// 设置通用环境变量
|
||||
config = self.set_common_env(config);
|
||||
|
||||
// 根据 Shell 类型设置特定配置
|
||||
config = match shell_type {
|
||||
ShellType::Bash => self.configure_bash(config)?,
|
||||
ShellType::Zsh => self.configure_zsh(config)?,
|
||||
ShellType::Fish => self.configure_fish(config)?,
|
||||
ShellType::Pwsh => self.configure_pwsh(config)?,
|
||||
ShellType::Unknown => {
|
||||
tracing::warn!(
|
||||
"[ShellLaunchBuilder] 未知 Shell 类型,不加载集成脚本: {}",
|
||||
shell_path
|
||||
);
|
||||
config
|
||||
}
|
||||
};
|
||||
|
||||
// 添加自定义环境变量
|
||||
if let Some(env) = custom_env {
|
||||
for (key, value) in env {
|
||||
config.env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 设置通用环境变量
|
||||
///
|
||||
/// 设置所有 Shell 类型共用的环境变量,包括:
|
||||
/// - TERM: 终端类型
|
||||
/// - COLORTERM: 颜色支持
|
||||
/// - PROXYCAST_BLOCKID: 块 ID(与 WAVETERM_BLOCKID 兼容)
|
||||
/// - WAVETERM_BLOCKID: Waveterm 兼容的块 ID
|
||||
/// - PROXYCAST_VERSION: 应用版本
|
||||
/// - LANG: 语言设置(如果未设置)
|
||||
///
|
||||
/// _Requirements: 17.2, 17.7_
|
||||
fn set_common_env(&self, config: ShellLaunchConfig) -> ShellLaunchConfig {
|
||||
let mut config = config
|
||||
.env("TERM", "xterm-256color")
|
||||
.env("COLORTERM", "truecolor")
|
||||
.env("PROXYCAST_BLOCKID", &self.block_id)
|
||||
// Waveterm 兼容性
|
||||
.env("WAVETERM_BLOCKID", &self.block_id)
|
||||
.env("PROXYCAST_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// 设置 LANG(如果未设置)
|
||||
if std::env::var("LANG").is_err() {
|
||||
config = config.env("LANG", "en_US.UTF-8");
|
||||
}
|
||||
|
||||
// 设置 LC_ALL(如果未设置)
|
||||
if std::env::var("LC_ALL").is_err() {
|
||||
config = config.env("LC_ALL", "en_US.UTF-8");
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// 配置 Bash 启动
|
||||
///
|
||||
/// 使用 --rcfile 参数加载集成脚本。
|
||||
///
|
||||
/// _Requirements: 17.9_
|
||||
fn configure_bash(
|
||||
&self,
|
||||
config: ShellLaunchConfig,
|
||||
) -> Result<ShellLaunchConfig, TerminalError> {
|
||||
let script_path = self.scripts.bash_script_path();
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!(
|
||||
"[ShellLaunchBuilder] Bash 集成脚本不存在: {}",
|
||||
script_path.display()
|
||||
);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let script_path_str = script_path.to_string_lossy().to_string();
|
||||
|
||||
Ok(config
|
||||
.arg("--rcfile")
|
||||
.arg(&script_path_str)
|
||||
.env("_PROXYCAST_LOAD_BASHRC", "1"))
|
||||
}
|
||||
|
||||
/// 配置 Zsh 启动
|
||||
///
|
||||
/// 使用 ZDOTDIR 环境变量指向集成目录。
|
||||
///
|
||||
/// _Requirements: 17.8_
|
||||
fn configure_zsh(&self, config: ShellLaunchConfig) -> Result<ShellLaunchConfig, TerminalError> {
|
||||
let zsh_dir = self.scripts.zsh_integration_dir();
|
||||
|
||||
if !zsh_dir.join(".zshrc").exists() {
|
||||
tracing::warn!(
|
||||
"[ShellLaunchBuilder] Zsh 集成脚本不存在: {}",
|
||||
zsh_dir.display()
|
||||
);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let zsh_dir_str = zsh_dir.to_string_lossy().to_string();
|
||||
|
||||
Ok(config.env("ZDOTDIR", &zsh_dir_str))
|
||||
}
|
||||
|
||||
/// 配置 Fish 启动
|
||||
///
|
||||
/// 使用 -C 参数 source 集成脚本。
|
||||
///
|
||||
/// _Requirements: 17.10_
|
||||
fn configure_fish(
|
||||
&self,
|
||||
config: ShellLaunchConfig,
|
||||
) -> Result<ShellLaunchConfig, TerminalError> {
|
||||
let script_path = self.scripts.fish_script_path();
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!(
|
||||
"[ShellLaunchBuilder] Fish 集成脚本不存在: {}",
|
||||
script_path.display()
|
||||
);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let source_cmd = format!("source {}", script_path.to_string_lossy());
|
||||
|
||||
Ok(config.arg("-C").arg(&source_cmd))
|
||||
}
|
||||
|
||||
/// 配置 PowerShell 启动
|
||||
fn configure_pwsh(
|
||||
&self,
|
||||
config: ShellLaunchConfig,
|
||||
) -> Result<ShellLaunchConfig, TerminalError> {
|
||||
let script_path = self.scripts.pwsh_script_path();
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!(
|
||||
"[ShellLaunchBuilder] PowerShell 集成脚本不存在: {}",
|
||||
script_path.display()
|
||||
);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
let script_path_str = script_path.to_string_lossy().to_string();
|
||||
|
||||
// PowerShell 使用 -NoExit 保持会话,-Command 执行脚本
|
||||
Ok(config
|
||||
.arg("-NoExit")
|
||||
.arg("-Command")
|
||||
.arg(format!(". '{}'", script_path_str)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端环境变量配置
|
||||
///
|
||||
/// 提供终端环境变量的配置和管理功能。
|
||||
///
|
||||
/// ## 标准环境变量
|
||||
/// - `TERM`: 终端类型(默认 xterm-256color)
|
||||
/// - `COLORTERM`: 颜色支持(默认 truecolor)
|
||||
/// - `PROXYCAST_BLOCKID`: 块 ID
|
||||
/// - `WAVETERM_BLOCKID`: Waveterm 兼容的块 ID
|
||||
/// - `PROXYCAST_VERSION`: 应用版本
|
||||
/// - `LANG`: 语言设置
|
||||
/// - `LC_ALL`: 区域设置
|
||||
///
|
||||
/// _Requirements: 17.2, 17.7_
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TerminalEnvConfig {
|
||||
/// 环境变量映射
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl TerminalEnvConfig {
|
||||
/// 创建新的环境变量配置
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有默认环境变量的配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 包含默认环境变量的配置
|
||||
///
|
||||
/// _Requirements: 17.2_
|
||||
pub fn with_defaults(block_id: &str) -> Self {
|
||||
let mut config = Self::new();
|
||||
|
||||
// 终端类型
|
||||
config.set("TERM", "xterm-256color");
|
||||
config.set("COLORTERM", "truecolor");
|
||||
|
||||
// 块标识
|
||||
config.set("PROXYCAST_BLOCKID", block_id);
|
||||
config.set("WAVETERM_BLOCKID", block_id); // Waveterm 兼容
|
||||
|
||||
// 版本信息
|
||||
config.set("PROXYCAST_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// 语言设置(如果未设置)
|
||||
if std::env::var("LANG").is_err() {
|
||||
config.set("LANG", "en_US.UTF-8");
|
||||
}
|
||||
if std::env::var("LC_ALL").is_err() {
|
||||
config.set("LC_ALL", "en_US.UTF-8");
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// 设置环境变量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `key`: 环境变量名
|
||||
/// - `value`: 环境变量值
|
||||
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.env.insert(key.into(), value.into());
|
||||
}
|
||||
|
||||
/// 获取环境变量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `key`: 环境变量名
|
||||
///
|
||||
/// # 返回
|
||||
/// 环境变量值(如果存在)
|
||||
pub fn get(&self, key: &str) -> Option<&String> {
|
||||
self.env.get(key)
|
||||
}
|
||||
|
||||
/// 移除环境变量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `key`: 环境变量名
|
||||
///
|
||||
/// # 返回
|
||||
/// 被移除的值(如果存在)
|
||||
pub fn remove(&mut self, key: &str) -> Option<String> {
|
||||
self.env.remove(key)
|
||||
}
|
||||
|
||||
/// 合并自定义环境变量
|
||||
///
|
||||
/// 自定义环境变量会覆盖已有的同名变量。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `custom_env`: 自定义环境变量
|
||||
///
|
||||
/// _Requirements: 17.7_
|
||||
pub fn merge(&mut self, custom_env: &HashMap<String, String>) {
|
||||
for (key, value) in custom_env {
|
||||
self.env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有环境变量
|
||||
///
|
||||
/// # 返回
|
||||
/// 环境变量映射的引用
|
||||
pub fn all(&self) -> &HashMap<String, String> {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// 转换为 HashMap
|
||||
///
|
||||
/// # 返回
|
||||
/// 环境变量的 HashMap
|
||||
pub fn into_map(self) -> HashMap<String, String> {
|
||||
self.env
|
||||
}
|
||||
|
||||
/// 检查是否包含指定的环境变量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `key`: 环境变量名
|
||||
///
|
||||
/// # 返回
|
||||
/// 是否包含该环境变量
|
||||
pub fn contains(&self, key: &str) -> bool {
|
||||
self.env.contains_key(key)
|
||||
}
|
||||
|
||||
/// 获取环境变量数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.env.len()
|
||||
}
|
||||
|
||||
/// 检查是否为空
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.env.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_shell_scripts_install() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let scripts = ShellScripts::new(temp_dir.path());
|
||||
|
||||
scripts.install_all().unwrap();
|
||||
|
||||
assert!(scripts.bash_script_path().exists());
|
||||
assert!(scripts.zsh_integration_dir().join(".zshrc").exists());
|
||||
assert!(scripts.zsh_integration_dir().join(".zshenv").exists());
|
||||
assert!(scripts.fish_script_path().exists());
|
||||
assert!(scripts.pwsh_script_path().exists());
|
||||
assert!(scripts.is_installed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_builder_bash() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
|
||||
|
||||
let config = builder.build("/bin/bash", None).unwrap();
|
||||
|
||||
assert_eq!(config.shell_path, "/bin/bash");
|
||||
assert!(config.args.contains(&"--rcfile".to_string()));
|
||||
assert!(config.env.contains_key("TERM"));
|
||||
assert!(config.env.contains_key("PROXYCAST_BLOCKID"));
|
||||
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
|
||||
assert_eq!(
|
||||
config.env.get("_PROXYCAST_LOAD_BASHRC"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_builder_zsh() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
|
||||
|
||||
let config = builder.build("/bin/zsh", None).unwrap();
|
||||
|
||||
assert_eq!(config.shell_path, "/bin/zsh");
|
||||
assert!(config.env.contains_key("ZDOTDIR"));
|
||||
assert!(config.env.contains_key("TERM"));
|
||||
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_builder_fish() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
|
||||
|
||||
let config = builder.build("/usr/bin/fish", None).unwrap();
|
||||
|
||||
assert_eq!(config.shell_path, "/usr/bin/fish");
|
||||
assert!(config.args.contains(&"-C".to_string()));
|
||||
assert!(config.env.contains_key("TERM"));
|
||||
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_builder_pwsh() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
|
||||
|
||||
let config = builder.build("/usr/bin/pwsh", None).unwrap();
|
||||
|
||||
assert_eq!(config.shell_path, "/usr/bin/pwsh");
|
||||
assert!(config.args.contains(&"-NoExit".to_string()));
|
||||
assert!(config.args.contains(&"-Command".to_string()));
|
||||
assert!(config.env.contains_key("TERM"));
|
||||
assert!(config.env.contains_key("WAVETERM_BLOCKID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_builder_custom_env() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string());
|
||||
|
||||
let mut custom_env = HashMap::new();
|
||||
custom_env.insert("MY_VAR".to_string(), "my_value".to_string());
|
||||
|
||||
let config = builder.build("/bin/bash", Some(&custom_env)).unwrap();
|
||||
|
||||
assert_eq!(config.env.get("MY_VAR"), Some(&"my_value".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_launch_config_builder_pattern() {
|
||||
let config = ShellLaunchConfig::new("/bin/bash".to_string())
|
||||
.arg("--login")
|
||||
.arg("-i")
|
||||
.env("FOO", "bar")
|
||||
.env("BAZ", "qux");
|
||||
|
||||
assert_eq!(config.shell_path, "/bin/bash");
|
||||
assert_eq!(config.args, vec!["--login", "-i"]);
|
||||
assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
|
||||
assert_eq!(config.env.get("BAZ"), Some(&"qux".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_env_config_defaults() {
|
||||
let config = TerminalEnvConfig::with_defaults("test-block");
|
||||
|
||||
assert_eq!(config.get("TERM"), Some(&"xterm-256color".to_string()));
|
||||
assert_eq!(config.get("COLORTERM"), Some(&"truecolor".to_string()));
|
||||
assert_eq!(
|
||||
config.get("PROXYCAST_BLOCKID"),
|
||||
Some(&"test-block".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
config.get("WAVETERM_BLOCKID"),
|
||||
Some(&"test-block".to_string())
|
||||
);
|
||||
assert!(config.contains("PROXYCAST_VERSION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_env_config_merge() {
|
||||
let mut config = TerminalEnvConfig::with_defaults("test-block");
|
||||
|
||||
let mut custom = HashMap::new();
|
||||
custom.insert("MY_VAR".to_string(), "my_value".to_string());
|
||||
custom.insert("TERM".to_string(), "xterm".to_string()); // 覆盖默认值
|
||||
|
||||
config.merge(&custom);
|
||||
|
||||
assert_eq!(config.get("MY_VAR"), Some(&"my_value".to_string()));
|
||||
assert_eq!(config.get("TERM"), Some(&"xterm".to_string())); // 被覆盖
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_env_config_operations() {
|
||||
let mut config = TerminalEnvConfig::new();
|
||||
|
||||
config.set("KEY1", "value1");
|
||||
config.set("KEY2", "value2");
|
||||
|
||||
assert_eq!(config.len(), 2);
|
||||
assert!(!config.is_empty());
|
||||
assert!(config.contains("KEY1"));
|
||||
assert!(!config.contains("KEY3"));
|
||||
|
||||
let removed = config.remove("KEY1");
|
||||
assert_eq!(removed, Some("value1".to_string()));
|
||||
assert_eq!(config.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
//! - `events` - Tauri 事件定义
|
||||
//! - `pty_session` - PTY 会话封装
|
||||
//! - `session_manager` - 会话管理器
|
||||
//! - `persistence` - 持久化存储(块文件、会话元数据)
|
||||
//! - `block_controller` - 块控制器抽象层
|
||||
//! - `connections` - 连接模块(本地 PTY、SSH、WSL)
|
||||
//! - `integration` - 集成模块(Shell 集成、OSC 解析、状态重同步)
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//! ```ignore
|
||||
@@ -17,8 +21,12 @@
|
||||
//! manager.write_to_session(&session_id, b"ls -la\n").await?;
|
||||
//! ```
|
||||
|
||||
pub mod block_controller;
|
||||
pub mod connections;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod integration;
|
||||
pub mod persistence;
|
||||
pub mod pty_session;
|
||||
pub mod session_manager;
|
||||
|
||||
@@ -26,7 +34,17 @@ pub mod session_manager;
|
||||
mod tests;
|
||||
|
||||
// 重新导出常用类型
|
||||
pub use block_controller::{
|
||||
BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, ControllerRegistry,
|
||||
ControllerStatusEvent, RuntimeOpts, ShellController, TermSize, CONTROLLER_STATUS_EVENT,
|
||||
};
|
||||
pub use connections::ShellProc;
|
||||
pub use error::TerminalError;
|
||||
pub use events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
|
||||
pub use integration::{
|
||||
resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE,
|
||||
TERMINAL_SOFT_RESET_SEQUENCE,
|
||||
};
|
||||
pub use persistence::{BlockFile, SessionMetadataStore, SessionRecord};
|
||||
pub use pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS};
|
||||
pub use session_manager::{SessionMetadata, TerminalSessionManager};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 终端持久化模块
|
||||
|
||||
提供终端会话数据的持久化存储能力。
|
||||
|
||||
## 模块结构
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | 模块入口,导出公共类型 |
|
||||
| `block_file.rs` | 块文件循环缓冲存储 |
|
||||
| `session_store.rs` | 会话元数据 SQLite 存储 |
|
||||
|
||||
## 功能
|
||||
|
||||
### BlockFile - 块文件存储
|
||||
|
||||
- 终端输出历史的文件存储
|
||||
- 循环缓冲策略(超过最大大小时覆盖旧数据)
|
||||
- 默认最大大小 256KB
|
||||
- 支持读取、追加、截断操作
|
||||
|
||||
### SessionMetadataStore - 会话元数据存储
|
||||
|
||||
- 会话元数据的 SQLite 存储
|
||||
- 支持 CRUD 操作
|
||||
- 支持按状态、标签页查询
|
||||
- 支持会话恢复
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use proxycast_lib::terminal::persistence::{BlockFile, SessionMetadataStore, SessionRecord};
|
||||
|
||||
// 创建块文件
|
||||
let base_dir = BlockFile::default_base_dir()?;
|
||||
let block_file = BlockFile::with_default_size("session-123", &base_dir)?;
|
||||
|
||||
// 追加数据
|
||||
block_file.append_data(b"Hello, World!")?;
|
||||
|
||||
// 读取数据
|
||||
let data = block_file.read_all()?;
|
||||
|
||||
// 创建会话存储
|
||||
let store = SessionMetadataStore::new(db_connection);
|
||||
store.init_tables()?;
|
||||
|
||||
// 保存会话记录
|
||||
let record = SessionRecord::new(
|
||||
"session-123".to_string(),
|
||||
"block-123".to_string(),
|
||||
"tab-1".to_string(),
|
||||
"shell".to_string(),
|
||||
None,
|
||||
);
|
||||
store.save(&record)?;
|
||||
```
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 3.1, 3.2, 3.3, 3.4, 3.7 - 块文件存储
|
||||
- Requirements 3.5, 3.9 - 会话元数据存储
|
||||
@@ -0,0 +1,335 @@
|
||||
//! 块文件循环缓冲存储
|
||||
//!
|
||||
//! 实现终端输出历史的文件存储,使用循环缓冲策略管理文件大小。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 循环缓冲写入(超过最大大小时覆盖旧数据)
|
||||
//! - 文件读取和截断
|
||||
//! - 可配置最大文件大小
|
||||
//!
|
||||
//! ## 设计说明
|
||||
//! 采用简单的循环缓冲策略:当文件大小超过配置的最大值时,
|
||||
//! 保留最新的数据,丢弃最旧的数据。
|
||||
//!
|
||||
//! _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7_
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::terminal::error::TerminalError;
|
||||
|
||||
/// 默认终端块文件最大大小 (256KB)
|
||||
pub const DEFAULT_TERM_MAX_FILE_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// 块文件管理器
|
||||
///
|
||||
/// 管理单个终端会话的输出历史文件,使用循环缓冲策略。
|
||||
pub struct BlockFile {
|
||||
/// 块 ID(通常是会话 ID)
|
||||
block_id: String,
|
||||
/// 文件路径
|
||||
file_path: PathBuf,
|
||||
/// 最大文件大小
|
||||
max_size: usize,
|
||||
/// 当前写入位置(用于循环缓冲)
|
||||
write_pos: AtomicUsize,
|
||||
/// 当前文件大小
|
||||
current_size: AtomicUsize,
|
||||
/// 是否已经开始循环(文件已满过一次)
|
||||
is_wrapped: RwLock<bool>,
|
||||
/// 文件句柄(用于写入)
|
||||
file: RwLock<Option<File>>,
|
||||
}
|
||||
|
||||
impl BlockFile {
|
||||
/// 创建新的块文件
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID(通常是会话 ID)
|
||||
/// - `base_dir`: 基础目录路径
|
||||
/// - `max_size`: 最大文件大小(字节)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(BlockFile)`: 创建成功
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 3.1, 3.3_
|
||||
pub fn new(block_id: &str, base_dir: &PathBuf, max_size: usize) -> Result<Self, TerminalError> {
|
||||
let file_path = base_dir.join(format!("{}.block", block_id));
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
TerminalError::BlockFileError(format!("无法创建目录 {:?}: {}", parent, e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// 检查文件是否已存在,获取当前大小
|
||||
let (current_size, is_wrapped) = if file_path.exists() {
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("无法读取文件元数据: {}", e)))?;
|
||||
let size = metadata.len() as usize;
|
||||
// 如果文件大小已经达到最大值,说明已经循环过
|
||||
(size, size >= max_size)
|
||||
} else {
|
||||
(0, false)
|
||||
};
|
||||
|
||||
// 打开或创建文件
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&file_path)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("无法打开文件: {}", e)))?;
|
||||
|
||||
tracing::debug!(
|
||||
"[BlockFile] 创建块文件: {} (max_size: {}, current_size: {})",
|
||||
block_id,
|
||||
max_size,
|
||||
current_size
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
block_id: block_id.to_string(),
|
||||
file_path,
|
||||
max_size,
|
||||
write_pos: AtomicUsize::new(current_size),
|
||||
current_size: AtomicUsize::new(current_size),
|
||||
is_wrapped: RwLock::new(is_wrapped),
|
||||
file: RwLock::new(Some(file)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用默认最大大小创建块文件
|
||||
///
|
||||
/// # 参数
|
||||
/// - `block_id`: 块 ID
|
||||
/// - `base_dir`: 基础目录路径
|
||||
pub fn with_default_size(block_id: &str, base_dir: &PathBuf) -> Result<Self, TerminalError> {
|
||||
Self::new(block_id, base_dir, DEFAULT_TERM_MAX_FILE_SIZE)
|
||||
}
|
||||
|
||||
/// 获取块文件存储的默认基础目录
|
||||
pub fn default_base_dir() -> Result<PathBuf, TerminalError> {
|
||||
let home = dirs::home_dir()
|
||||
.ok_or_else(|| TerminalError::BlockFileError("无法获取主目录".to_string()))?;
|
||||
Ok(home.join(".proxycast").join("terminal_blocks"))
|
||||
}
|
||||
|
||||
/// 获取块 ID
|
||||
pub fn block_id(&self) -> &str {
|
||||
&self.block_id
|
||||
}
|
||||
|
||||
/// 获取文件路径
|
||||
pub fn file_path(&self) -> &PathBuf {
|
||||
&self.file_path
|
||||
}
|
||||
|
||||
/// 获取最大文件大小
|
||||
pub fn max_size(&self) -> usize {
|
||||
self.max_size
|
||||
}
|
||||
|
||||
/// 获取当前文件大小
|
||||
///
|
||||
/// _Requirements: 3.3_
|
||||
pub fn size(&self) -> usize {
|
||||
self.current_size.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 追加数据到块文件
|
||||
///
|
||||
/// 使用循环缓冲策略:当文件大小超过最大值时,覆盖最旧的数据。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `data`: 要追加的数据
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(())`: 追加成功
|
||||
/// - `Err(TerminalError)`: 追加失败
|
||||
///
|
||||
/// _Requirements: 3.2, 3.4_
|
||||
pub fn append_data(&self, data: &[u8]) -> Result<(), TerminalError> {
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut file_guard = self.file.write();
|
||||
let file = file_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
|
||||
|
||||
// 如果数据本身就超过最大大小,只保留最后 max_size 字节
|
||||
let data_to_write = if data.len() >= self.max_size {
|
||||
&data[data.len() - self.max_size..]
|
||||
} else {
|
||||
data
|
||||
};
|
||||
|
||||
let current_size = self.current_size.load(Ordering::Relaxed);
|
||||
let new_total = current_size + data_to_write.len();
|
||||
|
||||
if new_total <= self.max_size {
|
||||
// 文件未满,直接追加
|
||||
file.seek(SeekFrom::End(0))
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {}", e)))?;
|
||||
file.write_all(data_to_write)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("写入失败: {}", e)))?;
|
||||
file.flush()
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {}", e)))?;
|
||||
self.current_size.store(new_total, Ordering::Relaxed);
|
||||
self.write_pos.store(new_total, Ordering::Relaxed);
|
||||
} else {
|
||||
// 文件将超过最大大小,需要使用循环缓冲策略
|
||||
// 策略:读取现有数据,保留最新的部分,然后重写文件
|
||||
self.apply_circular_buffer(file, data_to_write)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用循环缓冲策略
|
||||
///
|
||||
/// 当新数据会导致文件超过最大大小时调用。
|
||||
/// 保留最新的数据,丢弃最旧的数据。
|
||||
fn apply_circular_buffer(&self, file: &mut File, new_data: &[u8]) -> Result<(), TerminalError> {
|
||||
// 读取现有数据
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {}", e)))?;
|
||||
|
||||
let current_size = self.current_size.load(Ordering::Relaxed);
|
||||
let mut existing_data = vec![0u8; current_size];
|
||||
file.read_exact(&mut existing_data)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("读取失败: {}", e)))?;
|
||||
|
||||
// 合并数据
|
||||
let mut combined = existing_data;
|
||||
combined.extend_from_slice(new_data);
|
||||
|
||||
// 只保留最后 max_size 字节
|
||||
let final_data = if combined.len() > self.max_size {
|
||||
&combined[combined.len() - self.max_size..]
|
||||
} else {
|
||||
&combined[..]
|
||||
};
|
||||
|
||||
// 重写文件
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {}", e)))?;
|
||||
file.write_all(final_data)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("写入失败: {}", e)))?;
|
||||
file.set_len(final_data.len() as u64)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("截断失败: {}", e)))?;
|
||||
file.flush()
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {}", e)))?;
|
||||
|
||||
self.current_size.store(final_data.len(), Ordering::Relaxed);
|
||||
self.write_pos.store(final_data.len(), Ordering::Relaxed);
|
||||
*self.is_wrapped.write() = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取所有数据
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(Vec<u8>)`: 文件中的所有数据
|
||||
/// - `Err(TerminalError)`: 读取失败
|
||||
///
|
||||
/// _Requirements: 3.6_
|
||||
pub fn read_all(&self) -> Result<Vec<u8>, TerminalError> {
|
||||
let mut file_guard = self.file.write();
|
||||
let file = file_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
|
||||
|
||||
let current_size = self.current_size.load(Ordering::Relaxed);
|
||||
if current_size == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {}", e)))?;
|
||||
|
||||
let mut data = vec![0u8; current_size];
|
||||
file.read_exact(&mut data)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("读取失败: {}", e)))?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 截断文件(清空内容)
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(())`: 截断成功
|
||||
/// - `Err(TerminalError)`: 截断失败
|
||||
///
|
||||
/// _Requirements: 3.7_
|
||||
pub fn truncate(&self) -> Result<(), TerminalError> {
|
||||
let mut file_guard = self.file.write();
|
||||
let file = file_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?;
|
||||
|
||||
file.set_len(0)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("截断失败: {}", e)))?;
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {}", e)))?;
|
||||
file.flush()
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {}", e)))?;
|
||||
|
||||
self.current_size.store(0, Ordering::Relaxed);
|
||||
self.write_pos.store(0, Ordering::Relaxed);
|
||||
*self.is_wrapped.write() = false;
|
||||
|
||||
tracing::debug!("[BlockFile] 截断块文件: {}", self.block_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除块文件
|
||||
///
|
||||
/// 关闭文件句柄并删除文件。
|
||||
pub fn delete(self) -> Result<(), TerminalError> {
|
||||
// 先关闭文件句柄
|
||||
{
|
||||
let mut file_guard = self.file.write();
|
||||
*file_guard = None;
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
if self.file_path.exists() {
|
||||
fs::remove_file(&self.file_path)
|
||||
.map_err(|e| TerminalError::BlockFileError(format!("删除文件失败: {}", e)))?;
|
||||
}
|
||||
|
||||
tracing::debug!("[BlockFile] 删除块文件: {}", self.block_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查文件是否存在
|
||||
pub fn exists(&self) -> bool {
|
||||
self.file_path.exists()
|
||||
}
|
||||
|
||||
/// 检查是否已经循环过(文件曾经满过)
|
||||
pub fn is_wrapped(&self) -> bool {
|
||||
*self.is_wrapped.read()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BlockFile {
|
||||
fn drop(&mut self) {
|
||||
// 确保文件句柄被正确关闭
|
||||
let mut file_guard = self.file.write();
|
||||
if let Some(ref mut file) = *file_guard {
|
||||
let _ = file.flush();
|
||||
}
|
||||
*file_guard = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 终端持久化模块
|
||||
//!
|
||||
//! 提供终端会话数据的持久化存储能力。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `block_file` - 块文件循环缓冲存储
|
||||
//! - `session_store` - 会话元数据 SQLite 存储
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 终端输出历史的文件存储(循环缓冲)
|
||||
//! - 会话元数据的数据库存储
|
||||
//! - 会话恢复支持
|
||||
|
||||
pub mod block_file;
|
||||
pub mod session_store;
|
||||
|
||||
pub use block_file::BlockFile;
|
||||
pub use session_store::{SessionMetadataStore, SessionRecord};
|
||||
@@ -0,0 +1,428 @@
|
||||
//! 会话元数据存储
|
||||
//!
|
||||
//! 使用 SQLite 存储终端会话的元数据信息。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 会话元数据的 CRUD 操作
|
||||
//! - 会话状态查询
|
||||
//! - 会话恢复支持
|
||||
//!
|
||||
//! _Requirements: 3.5, 3.9_
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::terminal::error::TerminalError;
|
||||
|
||||
/// 会话记录(存储在 SQLite)
|
||||
///
|
||||
/// _Requirements: 3.5_
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionRecord {
|
||||
/// 会话 ID
|
||||
pub id: String,
|
||||
/// 块 ID(用于关联 BlockFile)
|
||||
pub block_id: String,
|
||||
/// 标签页 ID
|
||||
pub tab_id: String,
|
||||
/// 控制器类型(shell/cmd)
|
||||
pub controller_type: String,
|
||||
/// 连接名称(本地/SSH/WSL)
|
||||
pub connection: Option<String>,
|
||||
/// 会话状态(running/done/error)
|
||||
pub status: String,
|
||||
/// 创建时间(Unix 时间戳,毫秒)
|
||||
pub created_at: i64,
|
||||
/// 更新时间(Unix 时间戳,毫秒)
|
||||
pub updated_at: i64,
|
||||
/// 退出码
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
impl SessionRecord {
|
||||
/// 创建新的会话记录
|
||||
pub fn new(
|
||||
id: String,
|
||||
block_id: String,
|
||||
tab_id: String,
|
||||
controller_type: String,
|
||||
connection: Option<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Self {
|
||||
id,
|
||||
block_id,
|
||||
tab_id,
|
||||
controller_type,
|
||||
connection,
|
||||
status: "running".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
exit_code: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话元数据存储服务
|
||||
///
|
||||
/// 提供会话元数据的 SQLite 存储和查询功能。
|
||||
///
|
||||
/// _Requirements: 3.5, 3.9_
|
||||
pub struct SessionMetadataStore {
|
||||
db: DbConnection,
|
||||
}
|
||||
|
||||
impl SessionMetadataStore {
|
||||
/// 创建新的会话存储服务
|
||||
pub fn new(db: DbConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 初始化数据库表
|
||||
///
|
||||
/// 创建 terminal_sessions 表(如果不存在)。
|
||||
pub fn init_tables(&self) -> Result<(), TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS terminal_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
block_id TEXT NOT NULL,
|
||||
tab_id TEXT NOT NULL,
|
||||
controller_type TEXT NOT NULL,
|
||||
connection TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
exit_code INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("创建表失败: {}", e)))?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_block_id ON terminal_sessions(block_id)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_tab_id ON terminal_sessions(tab_id)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_terminal_sessions_status ON terminal_sessions(status)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[SessionStore] 数据库表初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存会话记录
|
||||
///
|
||||
/// 如果记录已存在则更新,否则插入新记录。
|
||||
///
|
||||
/// _Requirements: 3.5_
|
||||
pub fn save(&self, record: &SessionRecord) -> Result<(), TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO terminal_sessions
|
||||
(id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
record.id,
|
||||
record.block_id,
|
||||
record.tab_id,
|
||||
record.controller_type,
|
||||
record.connection,
|
||||
record.status,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
record.exit_code,
|
||||
],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("保存会话失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[SessionStore] 保存会话: {}", record.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据 ID 获取会话记录
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<SessionRecord>, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
|
||||
FROM terminal_sessions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
block_id: row.get(1)?,
|
||||
tab_id: row.get(2)?,
|
||||
controller_type: row.get(3)?,
|
||||
connection: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
exit_code: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {}", e)))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 根据块 ID 获取会话记录
|
||||
pub fn get_by_block_id(&self, block_id: &str) -> Result<Option<SessionRecord>, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
|
||||
FROM terminal_sessions WHERE block_id = ?1",
|
||||
params![block_id],
|
||||
|row| {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
block_id: row.get(1)?,
|
||||
tab_id: row.get(2)?,
|
||||
controller_type: row.get(3)?,
|
||||
connection: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
exit_code: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {}", e)))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取所有会话记录
|
||||
pub fn get_all(&self) -> Result<Vec<SessionRecord>, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
|
||||
FROM terminal_sessions ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {}", e)))?;
|
||||
|
||||
let records = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
block_id: row.get(1)?,
|
||||
tab_id: row.get(2)?,
|
||||
controller_type: row.get(3)?,
|
||||
connection: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
exit_code: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {}", e)))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {}", e)))?;
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 获取指定状态的会话记录
|
||||
pub fn get_by_status(&self, status: &str) -> Result<Vec<SessionRecord>, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
|
||||
FROM terminal_sessions WHERE status = ?1 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {}", e)))?;
|
||||
|
||||
let records = stmt
|
||||
.query_map(params![status], |row| {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
block_id: row.get(1)?,
|
||||
tab_id: row.get(2)?,
|
||||
controller_type: row.get(3)?,
|
||||
connection: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
exit_code: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {}", e)))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {}", e)))?;
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 获取指定标签页的会话记录
|
||||
pub fn get_by_tab_id(&self, tab_id: &str) -> Result<Vec<SessionRecord>, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code
|
||||
FROM terminal_sessions WHERE tab_id = ?1 ORDER BY created_at DESC",
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {}", e)))?;
|
||||
|
||||
let records = stmt
|
||||
.query_map(params![tab_id], |row| {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
block_id: row.get(1)?,
|
||||
tab_id: row.get(2)?,
|
||||
controller_type: row.get(3)?,
|
||||
connection: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
exit_code: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {}", e)))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {}", e)))?;
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 更新会话状态
|
||||
///
|
||||
/// _Requirements: 3.9_
|
||||
pub fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
exit_code: Option<i32>,
|
||||
) -> Result<(), TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let now = Utc::now().timestamp_millis();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE terminal_sessions SET status = ?1, exit_code = ?2, updated_at = ?3 WHERE id = ?4",
|
||||
params![status, exit_code, now, id],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("更新会话状态失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[SessionStore] 更新会话状态: {} -> {}", id, status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除会话记录
|
||||
pub fn delete(&self, id: &str) -> Result<(), TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
conn.execute("DELETE FROM terminal_sessions WHERE id = ?1", params![id])
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[SessionStore] 删除会话: {}", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除指定标签页的所有会话记录
|
||||
pub fn delete_by_tab_id(&self, tab_id: &str) -> Result<usize, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let count = conn
|
||||
.execute(
|
||||
"DELETE FROM terminal_sessions WHERE tab_id = ?1",
|
||||
params![tab_id],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {}", e)))?;
|
||||
|
||||
tracing::debug!("[SessionStore] 删除标签页 {} 的 {} 个会话", tab_id, count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 清理已完成的旧会话
|
||||
///
|
||||
/// 删除状态为 "done" 且创建时间早于指定时间的会话。
|
||||
pub fn cleanup_old_sessions(&self, before_timestamp: i64) -> Result<usize, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let count = conn
|
||||
.execute(
|
||||
"DELETE FROM terminal_sessions WHERE status = 'done' AND created_at < ?1",
|
||||
params![before_timestamp],
|
||||
)
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("清理会话失败: {}", e)))?;
|
||||
|
||||
if count > 0 {
|
||||
tracing::info!("[SessionStore] 清理了 {} 个旧会话", count);
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 获取会话数量
|
||||
pub fn count(&self) -> Result<usize, TerminalError> {
|
||||
let conn = self
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {}", e)))?;
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM terminal_sessions", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|e| TerminalError::DatabaseError(format!("查询会话数量失败: {}", e)))?;
|
||||
|
||||
Ok(count as usize)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
//! 终端会话管理器
|
||||
//!
|
||||
//! 管理所有终端会话的生命周期,提供会话的创建、查询、销毁功能。
|
||||
//! 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。
|
||||
//!
|
||||
//! ## 功能
|
||||
//! - 维护活跃会话的 HashMap
|
||||
//! - 生成唯一的会话 ID
|
||||
//! - 提供线程安全的会话访问
|
||||
//! - 会话创建、恢复、关闭的完整流程
|
||||
//! - 集成 BlockController 进行进程管理
|
||||
//! - 集成 BlockFile 进行输出持久化
|
||||
//! - 集成 SessionMetadataStore 进行元数据存储
|
||||
//! - 支持会话状态生命周期管理
|
||||
//!
|
||||
//! ## Requirements
|
||||
//! - 3.1: 终端会话创建时创建对应的 Block_File
|
||||
//! - 3.5: 应用启动时从数据库加载已保存的会话元数据
|
||||
//! - 3.6: 用户请求恢复会话时从 Block_File 读取历史数据并重建会话
|
||||
//! - 3.8: Block_File 读取失败时返回错误并允许创建新会话
|
||||
//! - 3.9: 会话关闭时更新会话元数据状态为已完成
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
@@ -16,15 +26,27 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::DbConnection;
|
||||
|
||||
use super::block_controller::ControllerRegistry;
|
||||
use super::error::TerminalError;
|
||||
use super::events::SessionStatus;
|
||||
use super::persistence::{BlockFile, SessionMetadataStore, SessionRecord};
|
||||
use super::pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS};
|
||||
|
||||
/// 会话元数据
|
||||
/// 会话元数据(用于前端展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMetadata {
|
||||
/// 会话 ID
|
||||
pub id: String,
|
||||
/// 块 ID(与会话 ID 相同)
|
||||
pub block_id: String,
|
||||
/// 标签页 ID
|
||||
pub tab_id: String,
|
||||
/// 控制器类型
|
||||
pub controller_type: String,
|
||||
/// 连接名称
|
||||
pub connection: Option<String>,
|
||||
/// 会话状态
|
||||
pub status: SessionStatus,
|
||||
/// 创建时间(Unix 时间戳,毫秒)
|
||||
@@ -33,100 +55,261 @@ pub struct SessionMetadata {
|
||||
pub rows: u16,
|
||||
/// 终端列数
|
||||
pub cols: u16,
|
||||
/// 退出码
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
impl SessionMetadata {
|
||||
/// 从 SessionRecord 创建
|
||||
pub fn from_record(record: &SessionRecord, rows: u16, cols: u16) -> Self {
|
||||
let status = match record.status.as_str() {
|
||||
"running" => SessionStatus::Running,
|
||||
"done" => SessionStatus::Done,
|
||||
"error" => SessionStatus::Error,
|
||||
_ => SessionStatus::Connecting,
|
||||
};
|
||||
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
block_id: record.block_id.clone(),
|
||||
tab_id: record.tab_id.clone(),
|
||||
controller_type: record.controller_type.clone(),
|
||||
connection: record.connection.clone(),
|
||||
status,
|
||||
created_at: record.created_at,
|
||||
rows,
|
||||
cols,
|
||||
exit_code: record.exit_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部会话数据
|
||||
struct SessionData {
|
||||
session: PtySession,
|
||||
/// 会话元数据
|
||||
metadata: SessionMetadata,
|
||||
/// 块文件存储
|
||||
block_file: Arc<BlockFile>,
|
||||
/// 旧版 PTY 会话(兼容模式)
|
||||
legacy_pty: Option<PtySession>,
|
||||
}
|
||||
|
||||
/// 终端会话管理器
|
||||
///
|
||||
/// 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。
|
||||
///
|
||||
/// ## 架构说明
|
||||
/// - 使用 ControllerRegistry 管理所有 BlockController
|
||||
/// - 使用 SessionMetadataStore 持久化会话元数据
|
||||
/// - 使用 BlockFile 持久化终端输出
|
||||
/// - 支持新旧两种模式:
|
||||
/// - 新模式:使用 BlockController + BlockFile
|
||||
/// - 兼容模式:使用旧版 PtySession
|
||||
pub struct TerminalSessionManager {
|
||||
/// 会话映射表
|
||||
sessions: Arc<RwLock<HashMap<String, SessionData>>>,
|
||||
/// 控制器注册表
|
||||
controller_registry: Arc<ControllerRegistry>,
|
||||
/// 会话元数据存储
|
||||
session_store: Option<Arc<SessionMetadataStore>>,
|
||||
/// 块文件基础目录
|
||||
block_file_base_dir: PathBuf,
|
||||
/// Tauri 应用句柄
|
||||
app_handle: tauri::AppHandle,
|
||||
}
|
||||
|
||||
impl TerminalSessionManager {
|
||||
/// 创建新的会话管理器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
pub fn new(app_handle: tauri::AppHandle) -> Self {
|
||||
tracing::info!("[终端] 会话管理器已初始化");
|
||||
let block_file_base_dir = BlockFile::default_base_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from(".proxycast/terminal_blocks"));
|
||||
|
||||
tracing::info!(
|
||||
"[终端] 会话管理器已初始化,块文件目录: {:?}",
|
||||
block_file_base_dir
|
||||
);
|
||||
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
controller_registry: Arc::new(ControllerRegistry::new()),
|
||||
session_store: None,
|
||||
block_file_base_dir,
|
||||
app_handle,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建新的终端会话(使用默认大小)
|
||||
/// 创建带数据库连接的会话管理器
|
||||
///
|
||||
/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。
|
||||
/// # 参数
|
||||
/// - `app_handle`: Tauri 应用句柄
|
||||
/// - `db`: 数据库连接
|
||||
///
|
||||
/// _Requirements: 3.5_
|
||||
pub fn with_database(
|
||||
app_handle: tauri::AppHandle,
|
||||
db: DbConnection,
|
||||
) -> Result<Self, TerminalError> {
|
||||
let mut manager = Self::new(app_handle);
|
||||
|
||||
// 创建会话存储服务
|
||||
let session_store = SessionMetadataStore::new(db);
|
||||
session_store.init_tables()?;
|
||||
|
||||
manager.session_store = Some(Arc::new(session_store));
|
||||
|
||||
tracing::info!("[终端] 会话管理器已初始化(带数据库支持)");
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
/// 获取控制器注册表
|
||||
pub fn controller_registry(&self) -> &Arc<ControllerRegistry> {
|
||||
&self.controller_registry
|
||||
}
|
||||
|
||||
/// 获取会话存储服务
|
||||
pub fn session_store(&self) -> Option<&Arc<SessionMetadataStore>> {
|
||||
self.session_store.as_ref()
|
||||
}
|
||||
|
||||
/// 创建新的终端会话
|
||||
///
|
||||
/// 使用默认大小 (24x80) 创建 PTY 会话。
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(String)`: 会话 ID
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 3.1_
|
||||
pub async fn create_session(&self) -> Result<String, TerminalError> {
|
||||
self.create_session_with_size(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 创建新的终端会话(指定大小)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `rows`: 终端行数
|
||||
/// - `cols`: 终端列数
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(String)`: 会话 ID
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 3.1_
|
||||
pub async fn create_session_with_size(
|
||||
&self,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> Result<String, TerminalError> {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let block_id = session_id.clone();
|
||||
let tab_id = "default".to_string(); // TODO: 支持多标签页
|
||||
|
||||
let session = PtySession::new(session_id.clone(), self.app_handle.clone())?;
|
||||
tracing::info!("[终端] 创建会话 {}, 大小: {}x{}", session_id, cols, rows);
|
||||
|
||||
// 创建块文件
|
||||
let block_file = BlockFile::with_default_size(&block_id, &self.block_file_base_dir)?;
|
||||
let block_file = Arc::new(block_file);
|
||||
|
||||
// 创建旧版 PTY 会话(兼容模式)
|
||||
let pty_session =
|
||||
PtySession::with_size(session_id.clone(), rows, cols, self.app_handle.clone())?;
|
||||
|
||||
// 创建会话元数据
|
||||
let metadata = SessionMetadata {
|
||||
id: session_id.clone(),
|
||||
block_id: block_id.clone(),
|
||||
tab_id: tab_id.clone(),
|
||||
controller_type: "shell".to_string(),
|
||||
connection: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now().timestamp_millis(),
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
rows,
|
||||
cols,
|
||||
exit_code: None,
|
||||
};
|
||||
|
||||
let data = SessionData { session, metadata };
|
||||
// 保存到数据库
|
||||
if let Some(store) = &self.session_store {
|
||||
let record = SessionRecord {
|
||||
id: session_id.clone(),
|
||||
block_id: block_id.clone(),
|
||||
tab_id: tab_id.clone(),
|
||||
controller_type: "shell".to_string(),
|
||||
connection: None,
|
||||
status: "running".to_string(),
|
||||
created_at: metadata.created_at,
|
||||
updated_at: metadata.created_at,
|
||||
exit_code: None,
|
||||
};
|
||||
store.save(&record)?;
|
||||
}
|
||||
|
||||
self.sessions.write().await.insert(session_id.clone(), data);
|
||||
// 创建会话数据
|
||||
let session_data = SessionData {
|
||||
metadata,
|
||||
block_file,
|
||||
legacy_pty: Some(pty_session),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[终端] 创建会话: {} (默认大小 {}x{})",
|
||||
session_id,
|
||||
DEFAULT_COLS,
|
||||
DEFAULT_ROWS
|
||||
);
|
||||
// 添加到会话映射表
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.clone(), session_data);
|
||||
|
||||
tracing::info!("[终端] 会话 {} 创建成功", session_id);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// 向会话发送输入
|
||||
/// 向会话写入数据(Base64 编码)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
/// - `data`: 输入数据
|
||||
/// - `data`: Base64 编码的数据
|
||||
///
|
||||
/// _Requirements: 3.2_
|
||||
pub async fn write_to_session_base64(
|
||||
&self,
|
||||
session_id: &str,
|
||||
data: &str,
|
||||
) -> Result<(), TerminalError> {
|
||||
let decoded = BASE64
|
||||
.decode(data)
|
||||
.map_err(|e| TerminalError::WriteFailed(format!("Base64 解码失败: {}", e)))?;
|
||||
self.write_to_session(session_id, &decoded).await
|
||||
}
|
||||
|
||||
/// 向会话写入数据
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
/// - `data`: 原始数据
|
||||
///
|
||||
/// _Requirements: 3.2_
|
||||
pub async fn write_to_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
data: &[u8],
|
||||
) -> Result<(), TerminalError> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session_data = sessions
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
session_data.session.write(data)
|
||||
// 使用旧版 PTY 会话写入
|
||||
if let Some(pty) = &session.legacy_pty {
|
||||
pty.write(data)?;
|
||||
}
|
||||
|
||||
// 同时写入块文件(用于持久化)
|
||||
session.block_file.append_data(data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 向会话发送 Base64 编码的输入
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
/// - `data_base64`: Base64 编码的输入数据
|
||||
pub async fn write_to_session_base64(
|
||||
&self,
|
||||
session_id: &str,
|
||||
data_base64: &str,
|
||||
) -> Result<(), TerminalError> {
|
||||
let data = BASE64
|
||||
.decode(data_base64)
|
||||
.map_err(|e| TerminalError::Base64DecodeFailed(e.to_string()))?;
|
||||
self.write_to_session(session_id, &data).await
|
||||
}
|
||||
|
||||
/// 调整会话大小
|
||||
/// 调整会话终端大小
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
@@ -139,13 +322,20 @@ impl TerminalSessionManager {
|
||||
cols: u16,
|
||||
) -> Result<(), TerminalError> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session_data = sessions
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
session_data.session.resize(rows, cols)?;
|
||||
session_data.metadata.rows = rows;
|
||||
session_data.metadata.cols = cols;
|
||||
// 使用旧版 PTY 会话调整大小
|
||||
if let Some(pty) = &session.legacy_pty {
|
||||
pty.resize(rows, cols)?;
|
||||
}
|
||||
|
||||
// 更新元数据
|
||||
session.metadata.rows = rows;
|
||||
session.metadata.cols = cols;
|
||||
|
||||
tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", session_id, cols, rows);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -154,42 +344,32 @@ impl TerminalSessionManager {
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
///
|
||||
/// _Requirements: 3.9_
|
||||
pub async fn close_session(&self, session_id: &str) -> Result<(), TerminalError> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
let session_data = sessions
|
||||
.remove(session_id)
|
||||
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
session_data.session.close().await?;
|
||||
if let Some(mut session) = sessions.remove(session_id) {
|
||||
// 关闭旧版 PTY 会话
|
||||
if let Some(pty) = session.legacy_pty.take() {
|
||||
pty.close().await?;
|
||||
}
|
||||
|
||||
// 更新数据库状态
|
||||
if let Some(store) = &self.session_store {
|
||||
store.update_status(session_id, "done", None)?;
|
||||
}
|
||||
|
||||
tracing::info!("[终端] 会话 {} 已关闭", session_id);
|
||||
}
|
||||
|
||||
tracing::info!("[终端] 关闭会话: {}", session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取会话的输出历史数据(Base64 编码)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(String)`: Base64 编码的输出历史
|
||||
/// - `Err(TerminalError)`: 会话不存在
|
||||
pub async fn get_session_history(&self, session_id: &str) -> Result<String, TerminalError> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session_data = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
Ok(session_data.session.get_output_history())
|
||||
}
|
||||
|
||||
/// 获取所有会话列表
|
||||
pub async fn list_sessions(&self) -> Vec<SessionMetadata> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.values()
|
||||
.map(|data| data.metadata.clone())
|
||||
.collect()
|
||||
sessions.values().map(|s| s.metadata.clone()).collect()
|
||||
}
|
||||
|
||||
/// 获取单个会话信息
|
||||
@@ -198,11 +378,95 @@ impl TerminalSessionManager {
|
||||
/// - `session_id`: 会话 ID
|
||||
pub async fn get_session(&self, session_id: &str) -> Option<SessionMetadata> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.get(session_id).map(|data| data.metadata.clone())
|
||||
sessions.get(session_id).map(|s| s.metadata.clone())
|
||||
}
|
||||
|
||||
/// 获取活跃会话数量
|
||||
pub async fn session_count(&self) -> usize {
|
||||
self.sessions.read().await.len()
|
||||
/// 恢复会话(从持久化存储)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `session_id`: 会话 ID
|
||||
///
|
||||
/// _Requirements: 3.6, 3.8_
|
||||
pub async fn restore_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<SessionMetadata, TerminalError> {
|
||||
// 从数据库加载会话记录
|
||||
let store = self
|
||||
.session_store
|
||||
.as_ref()
|
||||
.ok_or_else(|| TerminalError::DatabaseError("会话存储未初始化".to_string()))?;
|
||||
|
||||
let record = store
|
||||
.get_by_id(session_id)?
|
||||
.ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
// 检查块文件是否存在
|
||||
let block_file_path = self
|
||||
.block_file_base_dir
|
||||
.join(format!("{}.block", session_id));
|
||||
if !block_file_path.exists() {
|
||||
return Err(TerminalError::BlockFileError(format!(
|
||||
"块文件不存在: {:?}",
|
||||
block_file_path
|
||||
)));
|
||||
}
|
||||
|
||||
// 创建块文件引用
|
||||
let block_file = BlockFile::with_default_size(&record.block_id, &self.block_file_base_dir)?;
|
||||
let block_file = Arc::new(block_file);
|
||||
|
||||
// 读取历史数据
|
||||
let _history = block_file.read_all()?;
|
||||
|
||||
// 创建新的 PTY 会话
|
||||
let rows = DEFAULT_ROWS;
|
||||
let cols = DEFAULT_COLS;
|
||||
let pty_session =
|
||||
PtySession::with_size(session_id.to_string(), rows, cols, self.app_handle.clone())?;
|
||||
|
||||
// 创建会话元数据
|
||||
let metadata = SessionMetadata::from_record(&record, rows, cols);
|
||||
|
||||
// 创建会话数据
|
||||
let session_data = SessionData {
|
||||
metadata: metadata.clone(),
|
||||
block_file,
|
||||
legacy_pty: Some(pty_session),
|
||||
};
|
||||
|
||||
// 添加到会话映射表
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.to_string(), session_data);
|
||||
|
||||
// 更新数据库状态
|
||||
store.update_status(session_id, "running", None)?;
|
||||
|
||||
tracing::info!("[终端] 会话 {} 已恢复", session_id);
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// 加载所有已保存的会话(应用启动时调用)
|
||||
///
|
||||
/// _Requirements: 3.5_
|
||||
pub async fn load_saved_sessions(&self) -> Result<Vec<SessionMetadata>, TerminalError> {
|
||||
let store = match &self.session_store {
|
||||
Some(s) => s,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let records = store.get_all()?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for record in records {
|
||||
// 只加载运行中的会话
|
||||
if record.status == "running" {
|
||||
let metadata = SessionMetadata::from_record(&record, DEFAULT_ROWS, DEFAULT_COLS);
|
||||
result.push(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[终端] 加载了 {} 个已保存的会话", result.len());
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,3 +208,147 @@ mod tests {
|
||||
assert_eq!(event_names::TERMINAL_STATUS, "terminal:status");
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 属性测试 - ShellController
|
||||
// ========================================================================
|
||||
|
||||
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
|
||||
/// **Validates: Requirements 1.2, 1.3**
|
||||
///
|
||||
/// *对于任意* 控制器创建请求,如果请求指定 controller_type 为 "shell",
|
||||
/// 则创建的控制器实例的 controller_type 字段应为 "shell";
|
||||
/// 如果请求指定为 "cmd",则应为 "cmd"。
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::super::block_controller::{BlockControllerRuntimeStatus, BlockMeta};
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// 生成有效的控制器类型
|
||||
fn arb_controller_type() -> impl Strategy<Value = String> {
|
||||
prop_oneof![Just("shell".to_string()), Just("cmd".to_string()),]
|
||||
}
|
||||
|
||||
/// 生成有效的 block_id
|
||||
fn arb_block_id() -> impl Strategy<Value = String> {
|
||||
"[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}".prop_map(|s| s.to_string())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
|
||||
/// **Validates: Requirements 1.2, 1.3**
|
||||
///
|
||||
/// 测试 BlockControllerRuntimeStatus 的创建和状态一致性
|
||||
#[test]
|
||||
fn prop_controller_type_consistency(
|
||||
block_id in arb_block_id(),
|
||||
_controller_type in arb_controller_type(),
|
||||
) {
|
||||
// 创建运行时状态
|
||||
let status = BlockControllerRuntimeStatus::new(block_id.clone());
|
||||
|
||||
// 验证初始状态
|
||||
prop_assert_eq!(&status.block_id, &block_id);
|
||||
prop_assert_eq!(status.version, 0);
|
||||
prop_assert_eq!(&status.shell_proc_status, "init");
|
||||
prop_assert!(status.shell_proc_conn_name.is_none());
|
||||
prop_assert_eq!(status.shell_proc_exit_code, 0);
|
||||
|
||||
// 验证状态检查方法
|
||||
prop_assert!(status.is_init());
|
||||
prop_assert!(!status.is_running());
|
||||
prop_assert!(!status.is_done());
|
||||
}
|
||||
|
||||
/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性**
|
||||
/// **Validates: Requirements 1.2, 1.3**
|
||||
///
|
||||
/// 测试 BlockMeta 的控制器类型字段一致性
|
||||
#[test]
|
||||
fn prop_block_meta_controller_type_consistency(
|
||||
controller_type in arb_controller_type(),
|
||||
) {
|
||||
// 创建 BlockMeta
|
||||
let meta = BlockMeta {
|
||||
controller: Some(controller_type.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 验证 get_string 返回正确的控制器类型
|
||||
prop_assert_eq!(meta.get_string("controller"), controller_type);
|
||||
}
|
||||
|
||||
/// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性**
|
||||
/// **Validates: Requirements 1.7**
|
||||
///
|
||||
/// 测试状态转换的有效性
|
||||
#[test]
|
||||
fn prop_controller_status_transitions(
|
||||
block_id in arb_block_id(),
|
||||
) {
|
||||
// 创建初始状态
|
||||
let mut status = BlockControllerRuntimeStatus::new(block_id.clone());
|
||||
prop_assert!(status.is_init());
|
||||
|
||||
// 模拟状态转换到 running
|
||||
status.shell_proc_status = "running".to_string();
|
||||
prop_assert!(status.is_running());
|
||||
prop_assert!(!status.is_init());
|
||||
prop_assert!(!status.is_done());
|
||||
|
||||
// 模拟状态转换到 done
|
||||
status.shell_proc_status = "done".to_string();
|
||||
prop_assert!(status.is_done());
|
||||
prop_assert!(!status.is_init());
|
||||
prop_assert!(!status.is_running());
|
||||
}
|
||||
|
||||
/// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性**
|
||||
/// **Validates: Requirements 1.7**
|
||||
///
|
||||
/// 测试版本号递增
|
||||
#[test]
|
||||
fn prop_controller_version_increment(
|
||||
block_id in arb_block_id(),
|
||||
increments in 1..100usize,
|
||||
) {
|
||||
let mut status = BlockControllerRuntimeStatus::new(block_id);
|
||||
prop_assert_eq!(status.version, 0);
|
||||
|
||||
// 模拟多次状态更新
|
||||
for i in 1..=increments {
|
||||
status.version = i as i32;
|
||||
prop_assert_eq!(status.version, i as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 BlockMeta 默认值
|
||||
#[test]
|
||||
fn test_block_meta_defaults() {
|
||||
let meta = BlockMeta::default();
|
||||
assert!(meta.controller.is_none());
|
||||
assert!(meta.connection.is_none());
|
||||
assert!(meta.cmd.is_none());
|
||||
assert!(meta.cmd_args.is_none());
|
||||
assert!(meta.cmd_cwd.is_none());
|
||||
assert!(meta.cmd_env.is_none());
|
||||
assert!(meta.cmd_run_on_start.is_none());
|
||||
assert!(meta.cmd_run_once.is_none());
|
||||
assert!(meta.cmd_clear_on_start.is_none());
|
||||
assert!(meta.cmd_close_on_exit.is_none());
|
||||
}
|
||||
|
||||
/// 测试 BlockMeta get_string 默认值
|
||||
#[test]
|
||||
fn test_block_meta_get_string_defaults() {
|
||||
let meta = BlockMeta::default();
|
||||
assert_eq!(meta.get_string("controller"), "");
|
||||
assert_eq!(meta.get_string("connection"), "");
|
||||
assert_eq!(meta.get_string("cmd"), "");
|
||||
assert_eq!(meta.get_string("cmd_cwd"), "");
|
||||
assert_eq!(meta.get_string("term_mode"), "term");
|
||||
assert_eq!(meta.get_string("term_theme"), "");
|
||||
assert_eq!(meta.get_string("unknown_field"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { AgentChatPage } from "./components/agent";
|
||||
import { PluginUIRenderer } from "./components/plugins/PluginUIRenderer";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { TerminalPage } from "./components/terminal/TerminalPage";
|
||||
import { flowEventManager } from "./lib/flowEventManager";
|
||||
import { OnboardingWizard, useOnboardingState } from "./components/onboarding";
|
||||
import { ConnectConfirmDialog } from "./components/connect";
|
||||
@@ -42,6 +43,7 @@ type Page =
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
| "terminal"
|
||||
| `plugin:${string}`;
|
||||
|
||||
const AppContainer = styled.div`
|
||||
@@ -183,6 +185,13 @@ function App() {
|
||||
return (
|
||||
<AgentChatPage onNavigate={(page) => setCurrentPage(page as Page)} />
|
||||
);
|
||||
case "terminal":
|
||||
// 终端页面需要全屏显示
|
||||
return (
|
||||
<FullscreenWrapper>
|
||||
<TerminalPage />
|
||||
</FullscreenWrapper>
|
||||
);
|
||||
case "tools":
|
||||
return (
|
||||
<PageWrapper>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Moon,
|
||||
Sun,
|
||||
Activity,
|
||||
Terminal,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
@@ -34,6 +35,7 @@ type Page =
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
| "terminal"
|
||||
| `plugin:${string}`;
|
||||
|
||||
interface AppSidebarProps {
|
||||
@@ -130,6 +132,7 @@ const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [
|
||||
{ id: "agent", label: "AI Agent", icon: Bot },
|
||||
{ id: "api-server", label: "API Server", icon: Globe },
|
||||
{ id: "provider-pool", label: "凭证池", icon: Database },
|
||||
{ id: "terminal", label: "终端", icon: Terminal },
|
||||
{ id: "tools", label: "工具", icon: Wrench },
|
||||
{ id: "plugins", label: "插件中心", icon: Puzzle },
|
||||
];
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Globe,
|
||||
Activity,
|
||||
FileCode,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { PluginInstallDialog } from "./PluginInstallDialog";
|
||||
import { PluginUninstallDialog } from "./PluginUninstallDialog";
|
||||
@@ -106,14 +105,6 @@ interface RecommendedPlugin {
|
||||
* 推荐插件列表
|
||||
*/
|
||||
const recommendedPlugins: RecommendedPlugin[] = [
|
||||
{
|
||||
id: "terminal-plugin",
|
||||
name: "终端",
|
||||
description: "本地 PTY 和 SSH 终端模拟器,支持多标签页和搜索功能",
|
||||
icon: Terminal,
|
||||
downloadUrl:
|
||||
"https://github.com/aiclientproxy/terminal/releases/latest/download/terminal-plugin.zip",
|
||||
},
|
||||
{
|
||||
id: "machine-id-tool",
|
||||
name: "机器码管理工具",
|
||||
@@ -521,9 +512,11 @@ function PluginItem({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
{plugin.version && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
v{plugin.version}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
{getStatusIcon(plugin.status)}
|
||||
{getStatusText(plugin.status)}
|
||||
@@ -655,9 +648,11 @@ function InstalledPluginItem({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{plugin.version}
|
||||
</span>
|
||||
{plugin.version && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
v{plugin.version}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
|
||||
@@ -9,19 +9,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { PluginUIRenderer, type Page } from "./PluginUIRenderer";
|
||||
|
||||
// Mock MachineIdTool 组件
|
||||
vi.mock("@/components/tools/machine-id/MachineIdTool", () => ({
|
||||
MachineIdTool: (_props: { onNavigate: (page: Page) => void }) => (
|
||||
<div data-testid="machine-id-tool">MachineIdTool Mock</div>
|
||||
),
|
||||
}));
|
||||
import { PluginUIRenderer } from "./PluginUIRenderer";
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
AlertCircle: () => <span data-testid="alert-circle-icon">AlertCircle</span>,
|
||||
Package: () => <span data-testid="package-icon">Package</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">Loader2</span>,
|
||||
}));
|
||||
|
||||
// Mock tauri invoke
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
describe("PluginUIRenderer", () => {
|
||||
@@ -31,26 +30,8 @@ describe("PluginUIRenderer", () => {
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
describe("内置插件组件渲染", () => {
|
||||
it("应该正确渲染 machine-id-tool 插件", () => {
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer
|
||||
pluginId="machine-id-tool"
|
||||
onNavigate={mockNavigate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 验证 MachineIdTool 组件被渲染
|
||||
const machineIdTool = container.querySelector(
|
||||
'[data-testid="machine-id-tool"]',
|
||||
);
|
||||
expect(machineIdTool).not.toBeNull();
|
||||
expect(machineIdTool?.textContent).toBe("MachineIdTool Mock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("未知插件处理", () => {
|
||||
it("应该为未知插件显示 '插件未找到' 提示", () => {
|
||||
it("应该为未知插件显示加载中或未找到提示", async () => {
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer
|
||||
pluginId="unknown-plugin"
|
||||
@@ -58,22 +39,31 @@ describe("PluginUIRenderer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// 验证显示插件未找到提示
|
||||
expect(container.textContent).toContain("插件未找到");
|
||||
expect(container.textContent).toContain("unknown-plugin");
|
||||
expect(container.textContent).toContain("未安装或不存在");
|
||||
// 等待异步操作完成
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// 验证显示插件未找到提示或加载中
|
||||
const text = container.textContent || "";
|
||||
expect(text.includes("插件未找到") || text.includes("加载")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("应该为空字符串 pluginId 显示 '插件未找到' 提示", () => {
|
||||
it("应该为空字符串 pluginId 显示相应提示", async () => {
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer pluginId="" onNavigate={mockNavigate} />,
|
||||
);
|
||||
|
||||
// 验证显示插件未找到提示
|
||||
expect(container.textContent).toContain("插件未找到");
|
||||
// 等待异步操作完成
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// 验证显示相应提示
|
||||
expect(container.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("应该为随机 pluginId 显示 '插件未找到' 提示", () => {
|
||||
it("应该为随机 pluginId 显示相应提示", async () => {
|
||||
const randomPluginId = `random-plugin-${Date.now()}`;
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer
|
||||
@@ -82,35 +72,13 @@ describe("PluginUIRenderer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// 验证显示插件未找到提示,并包含插件 ID
|
||||
expect(container.textContent).toContain("插件未找到");
|
||||
expect(container.textContent).toContain(randomPluginId);
|
||||
});
|
||||
});
|
||||
// 等待异步操作完成
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
describe("插件 ID 大小写敏感性", () => {
|
||||
it("应该区分大小写 - 'Machine-Id-Tool' 应该显示未找到", () => {
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer
|
||||
pluginId="Machine-Id-Tool"
|
||||
onNavigate={mockNavigate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 验证大小写不匹配时显示未找到
|
||||
expect(container.textContent).toContain("插件未找到");
|
||||
});
|
||||
|
||||
it("应该区分大小写 - 'MACHINE-ID-TOOL' 应该显示未找到", () => {
|
||||
const { container } = renderComponent(
|
||||
<PluginUIRenderer
|
||||
pluginId="MACHINE-ID-TOOL"
|
||||
onNavigate={mockNavigate}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 验证大小写不匹配时显示未找到
|
||||
expect(container.textContent).toContain("插件未找到");
|
||||
// 验证显示相应提示
|
||||
expect(container.textContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { AlertCircle, Package, Loader2 } from "lucide-react";
|
||||
import { AlertCircle, Package, Loader2, ExternalLink } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { MachineIdTool } from "@/components/tools/machine-id/MachineIdTool";
|
||||
import { BrowserInterceptorTool } from "@/components/tools/browser-interceptor/BrowserInterceptorTool";
|
||||
import { FlowMonitorPage } from "@/pages";
|
||||
import { ConfigManagementPage } from "@/components/config/ConfigManagementPage";
|
||||
import { TerminalPage } from "@/components/terminal";
|
||||
import { PluginUIRenderer as DynamicPluginRenderer } from "@/lib/plugin-loader/PluginUIRenderer";
|
||||
import { usePluginSDK } from "@/lib/plugin-sdk";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* 页面类型定义
|
||||
@@ -105,14 +105,75 @@ function PluginLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件启动器组件
|
||||
* 用于显示没有嵌入式 UI 的插件(如 binary 类型)
|
||||
*/
|
||||
function PluginLauncher({
|
||||
pluginId,
|
||||
manifest,
|
||||
}: {
|
||||
pluginId: string;
|
||||
manifest: PluginManifest;
|
||||
}) {
|
||||
const [launching, setLaunching] = useState(false);
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setLaunching(true);
|
||||
try {
|
||||
// 调用后端启动插件
|
||||
await invoke("launch_plugin_ui", { pluginId });
|
||||
} catch (err) {
|
||||
console.error("启动插件失败:", err);
|
||||
} finally {
|
||||
setLaunching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 space-y-6">
|
||||
<div className="p-6 bg-primary/10 rounded-full">
|
||||
<Package className="w-16 h-16 text-primary" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{manifest.ui?.title || manifest.name}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md">
|
||||
{manifest.ui?.description || manifest.description || ""}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">版本 {manifest.version}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleLaunch}
|
||||
disabled={launching}
|
||||
className="gap-2"
|
||||
>
|
||||
{launching ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
启动中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
打开 {manifest.ui?.title || manifest.name}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置插件组件映射
|
||||
* 注意: machine-id-tool 已移除,改为从插件包动态加载
|
||||
*/
|
||||
const builtinPluginComponents: Record<
|
||||
string,
|
||||
React.ComponentType<{ onNavigate?: (page: Page) => void }>
|
||||
> = {
|
||||
"machine-id-tool": MachineIdTool,
|
||||
"browser-interception": BrowserInterceptorTool,
|
||||
"flow-monitor": FlowMonitorPage,
|
||||
"config-switch": ConfigManagementPage,
|
||||
@@ -130,6 +191,23 @@ interface InstalledPlugin {
|
||||
ui_entry?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件清单信息(从 plugin.json 读取)
|
||||
*/
|
||||
interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
plugin_type?: "script" | "native" | "binary";
|
||||
ui?: {
|
||||
surfaces?: string[];
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
entry?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态插件渲染器
|
||||
* 用于加载外部安装的插件 UI
|
||||
@@ -171,6 +249,9 @@ export function PluginUIRenderer({
|
||||
}: PluginUIRendererProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pluginInfo, setPluginInfo] = useState<InstalledPlugin | null>(null);
|
||||
const [pluginManifest, setPluginManifest] = useState<PluginManifest | null>(
|
||||
null,
|
||||
);
|
||||
const [pluginsDir, setPluginsDir] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -194,28 +275,51 @@ export function PluginUIRenderer({
|
||||
const dir = await invoke<string>("get_plugins_dir");
|
||||
setPluginsDir(dir);
|
||||
|
||||
// 检查插件是否已安装
|
||||
const installed = await invoke<boolean>("is_plugin_installed", {
|
||||
pluginId,
|
||||
});
|
||||
// 首先尝试读取插件清单
|
||||
const manifest = await invoke<PluginManifest | null>(
|
||||
"read_plugin_manifest_cmd",
|
||||
{
|
||||
pluginId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
setPluginInfo(null);
|
||||
if (manifest) {
|
||||
setPluginManifest(manifest);
|
||||
|
||||
// 检查数据库中是否已注册
|
||||
const installed = await invoke<boolean>("is_plugin_installed", {
|
||||
pluginId,
|
||||
});
|
||||
|
||||
if (installed) {
|
||||
// 从数据库获取插件信息
|
||||
const plugins = await invoke<InstalledPlugin[]>(
|
||||
"list_installed_plugins",
|
||||
);
|
||||
const plugin = plugins.find((p) => p.id === pluginId);
|
||||
|
||||
if (plugin) {
|
||||
setPluginInfo(plugin);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 插件存在于文件系统中但未在数据库注册,创建临时的插件信息
|
||||
setPluginInfo({
|
||||
id: pluginId,
|
||||
name: manifest.name,
|
||||
install_path: `${dir}/${pluginId}`,
|
||||
has_ui: !!manifest.ui,
|
||||
ui_entry: undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件信息
|
||||
const plugins = await invoke<InstalledPlugin[]>(
|
||||
"list_installed_plugins",
|
||||
);
|
||||
const plugin = plugins.find((p) => p.id === pluginId);
|
||||
|
||||
if (plugin) {
|
||||
setPluginInfo(plugin);
|
||||
} else {
|
||||
setPluginInfo(null);
|
||||
}
|
||||
// 插件不存在
|
||||
setPluginInfo(null);
|
||||
setPluginManifest(null);
|
||||
} catch (err) {
|
||||
console.error("检查插件失败:", err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -248,16 +352,24 @@ export function PluginUIRenderer({
|
||||
}
|
||||
|
||||
// 插件未安装
|
||||
if (!pluginInfo) {
|
||||
if (!pluginInfo || !pluginManifest) {
|
||||
return <PluginNotFound pluginId={pluginId} />;
|
||||
}
|
||||
|
||||
// 检查插件是否有嵌入式 UI(ui.entry 配置)
|
||||
const hasEmbeddedUI = pluginManifest.ui?.entry;
|
||||
|
||||
// 对于 binary 类型的插件,如果没有嵌入式 UI,显示启动器
|
||||
if (pluginManifest.plugin_type === "binary" && !hasEmbeddedUI) {
|
||||
return <PluginLauncher pluginId={pluginId} manifest={pluginManifest} />;
|
||||
}
|
||||
|
||||
// 动态加载插件 UI
|
||||
return (
|
||||
<DynamicPluginUIRenderer
|
||||
pluginId={pluginId}
|
||||
pluginsDir={pluginsDir}
|
||||
uiEntry={pluginInfo.ui_entry}
|
||||
uiEntry={hasEmbeddedUI || pluginInfo.ui_entry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file ConnectionStatusIndicator.tsx
|
||||
* @description 连接状态指示器组件
|
||||
* @module components/terminal/ConnectionStatusIndicator
|
||||
*
|
||||
* 显示终端连接状态,包括连接中、已连接、断开、错误等状态。
|
||||
* 提供重连按钮。
|
||||
*
|
||||
* _Requirements: 7.3, 7.4, 7.5_
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { ConnStatus, ShellProcStatus } from "@/lib/terminal/store";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 组件属性 */
|
||||
export interface ConnectionStatusIndicatorProps {
|
||||
/** 连接状态 */
|
||||
connStatus: ConnStatus;
|
||||
/** Shell 进程状态 */
|
||||
shellProcStatus: ShellProcStatus;
|
||||
/** 退出码 */
|
||||
exitCode?: number;
|
||||
/** 重连回调
|
||||
* _Requirements: 7.5_
|
||||
*/
|
||||
onReconnect?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
/** 连接中图标 */
|
||||
const ConnectingIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={`${className || "w-4 h-4"} animate-spin`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 已连接图标 */
|
||||
const ConnectedIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 断开连接图标 */
|
||||
const DisconnectedIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55" />
|
||||
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39" />
|
||||
<path d="M10.71 5.05A16 16 0 0 1 22.58 9" />
|
||||
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<line x1="12" y1="20" x2="12.01" y2="20" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 错误图标 */
|
||||
const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 重连图标 */
|
||||
const ReconnectIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 连接状态指示器组件
|
||||
*
|
||||
* _Requirements: 7.3, 7.4, 7.5_
|
||||
*/
|
||||
export const ConnectionStatusIndicator: React.FC<
|
||||
ConnectionStatusIndicatorProps
|
||||
> = ({ connStatus, shellProcStatus, exitCode, onReconnect }) => {
|
||||
// 判断是否需要显示指示器
|
||||
const shouldShow =
|
||||
connStatus.status !== "connected" ||
|
||||
shellProcStatus === "done" ||
|
||||
connStatus.error;
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = () => {
|
||||
// Shell 进程已完成
|
||||
if (shellProcStatus === "done") {
|
||||
const exitCodeText =
|
||||
exitCode !== undefined && exitCode !== 0
|
||||
? ` (退出码: ${exitCode})`
|
||||
: "";
|
||||
return {
|
||||
icon: <ConnectedIcon className="w-4 h-4 text-gray-400" />,
|
||||
text: `进程已结束${exitCodeText}`,
|
||||
color: "text-gray-400",
|
||||
bgColor: "bg-gray-800/80",
|
||||
showReconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 连接错误
|
||||
// _Requirements: 7.3_
|
||||
if (connStatus.status === "error" || connStatus.error) {
|
||||
return {
|
||||
icon: <ErrorIcon className="w-4 h-4 text-red-400" />,
|
||||
text: connStatus.error || "连接错误",
|
||||
color: "text-red-400",
|
||||
bgColor: "bg-red-900/80",
|
||||
showReconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
// _Requirements: 7.4_
|
||||
if (connStatus.status === "disconnected") {
|
||||
return {
|
||||
icon: <DisconnectedIcon className="w-4 h-4 text-yellow-400" />,
|
||||
text: "连接已断开",
|
||||
color: "text-yellow-400",
|
||||
bgColor: "bg-yellow-900/80",
|
||||
showReconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 连接中
|
||||
if (connStatus.status === "connecting") {
|
||||
return {
|
||||
icon: <ConnectingIcon className="w-4 h-4 text-blue-400" />,
|
||||
text: "连接中...",
|
||||
color: "text-blue-400",
|
||||
bgColor: "bg-blue-900/80",
|
||||
showReconnect: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化
|
||||
if (connStatus.status === "init") {
|
||||
return {
|
||||
icon: <ConnectingIcon className="w-4 h-4 text-gray-400" />,
|
||||
text: "初始化中...",
|
||||
color: "text-gray-400",
|
||||
bgColor: "bg-gray-800/80",
|
||||
showReconnect: false,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
if (!statusInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-connection-status ${statusInfo.bgColor} ${statusInfo.color}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusInfo.icon}
|
||||
<span className="text-sm">{statusInfo.text}</span>
|
||||
</div>
|
||||
|
||||
{/* 重连按钮
|
||||
* _Requirements: 7.5_
|
||||
*/}
|
||||
{statusInfo.showReconnect && onReconnect && (
|
||||
<button
|
||||
className="terminal-reconnect-btn"
|
||||
onClick={onReconnect}
|
||||
title="重新连接"
|
||||
>
|
||||
<ReconnectIcon className="w-4 h-4" />
|
||||
<span>重连</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatusIndicator;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file MultiInputIndicator.tsx
|
||||
* @description 多输入模式指示器组件
|
||||
* @module components/terminal/MultiInputIndicator
|
||||
*
|
||||
* 显示多输入模式状态,允许用户切换多输入模式。
|
||||
*
|
||||
* _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 组件属性 */
|
||||
export interface MultiInputIndicatorProps {
|
||||
/** 切换多输入模式回调
|
||||
* _Requirements: 10.4_
|
||||
*/
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
/** 多输入图标 */
|
||||
const MultiInputIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
<path d="M6 8h.01" />
|
||||
<path d="M10 8h.01" />
|
||||
<path d="M14 8h.01" />
|
||||
<path d="M18 8h.01" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 多输入模式指示器组件
|
||||
*
|
||||
* 当多输入模式启用时显示,点击可禁用多输入模式。
|
||||
*
|
||||
* _Requirements: 10.3, 10.4_
|
||||
*/
|
||||
export const MultiInputIndicator: React.FC<MultiInputIndicatorProps> = ({
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="terminal-multi-input-indicator">
|
||||
<button
|
||||
className="terminal-multi-input-btn"
|
||||
onClick={onToggle}
|
||||
title="点击禁用多输入模式"
|
||||
>
|
||||
<MultiInputIcon className="w-4 h-4" />
|
||||
<span>Multi Input ON</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiInputIndicator;
|
||||
@@ -33,15 +33,36 @@
|
||||
|
||||
- **PTY 会话管理**: 后端预创建,前端连接
|
||||
- **实时输入输出**: 通过 Tauri Events 实现
|
||||
- **自适应大小**: 自动调整终端尺寸,同步到后端
|
||||
- **自适应大小**: 自动调整终端尺寸,同步到后端(防抖处理)
|
||||
- **xterm.js 渲染**: 高性能终端渲染
|
||||
- **WebGL 渲染**: 可选的 WebGL 加速渲染(默认启用)
|
||||
- **Unicode 11 支持**: 宽字符正确显示
|
||||
- **多标签页**: 支持多个终端会话
|
||||
- **终端搜索**: 支持正则、大小写、全词匹配
|
||||
- **主题切换**: 多种预设主题
|
||||
- **IME 支持**: 正确处理输入法组合状态
|
||||
- **连接状态显示**: 显示连接状态指示器和重连按钮
|
||||
- **上下文菜单**: 右键菜单支持复制、粘贴、URL 打开
|
||||
- **多输入模式**: 同时向多个终端发送输入
|
||||
- **Jotai 状态管理**: 使用 TermViewModel 管理终端状态
|
||||
- **VDOM 模式**: 支持在终端内嵌入 React 组件
|
||||
- **贴纸系统**: 支持在终端上显示可定位的贴纸标注
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块导出
|
||||
- `TerminalPage.tsx` - 终端页面组件(多标签页管理)
|
||||
- `termwrap.ts` - 终端封装类(连接模式)
|
||||
- `TerminalView.tsx` - 终端视图组件(使用 Jotai 原子状态)
|
||||
- `TerminalSearch.tsx` - 终端搜索组件
|
||||
- `TerminalContextMenu.tsx` - 终端上下文菜单组件
|
||||
- `ConnectionStatusIndicator.tsx` - 连接状态指示器组件
|
||||
- `MultiInputIndicator.tsx` - 多输入模式指示器组件
|
||||
- `VDomModeSwitch.tsx` - VDOM 模式切换组件
|
||||
- `VDomView.tsx` - VDOM 视图组件
|
||||
- `SubBlock.tsx` - VDOM 子块组件
|
||||
- `Sticker.tsx` - 终端贴纸组件
|
||||
- `StickerLayer.tsx` - 终端贴纸层组件
|
||||
- `termwrap.ts` - 终端封装类(连接模式,WebGL/Unicode11 支持)
|
||||
- `fitaddon.ts` - 自定义 FitAddon
|
||||
- `terminal.css` - 终端样式(Tokyo Night 主题)
|
||||
|
||||
@@ -50,7 +71,16 @@
|
||||
- `@xterm/xterm` - 终端渲染
|
||||
- `@xterm/addon-fit` - 自适应大小
|
||||
- `@xterm/addon-web-links` - 链接支持
|
||||
- `@xterm/addon-search` - 搜索功能
|
||||
- `@xterm/addon-webgl` - WebGL 渲染加速
|
||||
- `@xterm/addon-unicode11` - Unicode 11 宽字符支持
|
||||
- `@tauri-apps/plugin-shell` - Tauri Shell 插件(URL 打开)
|
||||
- `jotai` - 原子化状态管理
|
||||
- `@/lib/terminal-api` - Tauri 终端 API
|
||||
- `@/lib/terminal/themes` - 终端主题配置
|
||||
- `@/lib/terminal/store` - 终端状态管理
|
||||
- `@/lib/terminal/vdom` - VDOM 状态管理
|
||||
- `@/lib/terminal/stickers` - 贴纸状态管理
|
||||
|
||||
## 使用方式
|
||||
|
||||
@@ -62,6 +92,38 @@ const builtinPluginComponents = {
|
||||
};
|
||||
```
|
||||
|
||||
### VDOM 模式使用
|
||||
|
||||
```tsx
|
||||
import { TerminalView } from "@/components/terminal";
|
||||
|
||||
// 启用 VDOM 模式切换
|
||||
<TerminalView
|
||||
blockId="session-1"
|
||||
tabId="tab-1"
|
||||
showModeSwitch={true}
|
||||
initialTermMode="term"
|
||||
onModeChange={(mode) => console.log("模式切换:", mode)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 贴纸系统使用
|
||||
|
||||
```tsx
|
||||
import { useSetAtom } from "jotai";
|
||||
import { addStickerAtom } from "@/lib/terminal/stickers";
|
||||
|
||||
// 添加贴纸
|
||||
const addSticker = useSetAtom(addStickerAtom);
|
||||
addSticker({
|
||||
blockId: "session-1",
|
||||
position: { row: 5, col: 10 },
|
||||
contentType: "text",
|
||||
text: "重要标记",
|
||||
draggable: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @file Sticker.tsx
|
||||
* @description 终端贴纸组件
|
||||
* @module components/terminal/Sticker
|
||||
*
|
||||
* 单个贴纸的渲染组件,支持文本、图标、徽章等内容类型。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState, useRef } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
type Sticker as StickerType,
|
||||
type TerminalDimensions,
|
||||
charGridToPixel,
|
||||
removeStickerAtom,
|
||||
moveStickerAtom,
|
||||
DEFAULT_STICKER_STYLE,
|
||||
} from "@/lib/terminal/stickers";
|
||||
import { X, AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface StickerProps {
|
||||
/** 贴纸数据 */
|
||||
sticker: StickerType;
|
||||
/** 终端尺寸信息 */
|
||||
dimensions: TerminalDimensions;
|
||||
/** 点击回调 */
|
||||
onClick?: (sticker: StickerType) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 徽章变体样式
|
||||
// ============================================================================
|
||||
|
||||
const BADGE_VARIANTS = {
|
||||
default: {
|
||||
backgroundColor: "rgba(122, 162, 247, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
success: {
|
||||
backgroundColor: "rgba(158, 206, 106, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: "rgba(224, 175, 104, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
error: {
|
||||
backgroundColor: "rgba(247, 118, 142, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
info: {
|
||||
backgroundColor: "rgba(125, 207, 255, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 贴纸组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端贴纸组件
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
export const Sticker: React.FC<StickerProps> = ({
|
||||
sticker,
|
||||
dimensions,
|
||||
onClick,
|
||||
}) => {
|
||||
const removeSticker = useSetAtom(removeStickerAtom);
|
||||
const moveSticker = useSetAtom(moveStickerAtom);
|
||||
|
||||
// 拖拽状态
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{
|
||||
x: number;
|
||||
y: number;
|
||||
row: number;
|
||||
col: number;
|
||||
} | null>(null);
|
||||
|
||||
// 计算像素位置
|
||||
// _Requirements: 15.2, 15.4_
|
||||
const pixelPosition = useMemo(
|
||||
() => charGridToPixel(sticker.position, dimensions),
|
||||
[sticker.position, dimensions],
|
||||
);
|
||||
|
||||
// 合并样式
|
||||
const mergedStyle = useMemo(
|
||||
() => ({
|
||||
...DEFAULT_STICKER_STYLE,
|
||||
...sticker.style,
|
||||
}),
|
||||
[sticker.style],
|
||||
);
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
removeSticker({ blockId: sticker.blockId, stickerId: sticker.id });
|
||||
},
|
||||
[removeSticker, sticker.blockId, sticker.id],
|
||||
);
|
||||
|
||||
// 处理点击
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.(sticker);
|
||||
}, [onClick, sticker]);
|
||||
|
||||
// 处理拖拽开始
|
||||
// _Requirements: 15.2_
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!sticker.draggable) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
row: sticker.position.row,
|
||||
col: sticker.position.col,
|
||||
};
|
||||
|
||||
// 添加全局事件监听
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const deltaX = moveEvent.clientX - dragStartRef.current.x;
|
||||
const deltaY = moveEvent.clientY - dragStartRef.current.y;
|
||||
|
||||
const newCol =
|
||||
dragStartRef.current.col + Math.round(deltaX / dimensions.charWidth);
|
||||
const newRow =
|
||||
dragStartRef.current.row + Math.round(deltaY / dimensions.charHeight);
|
||||
|
||||
moveSticker({
|
||||
blockId: sticker.blockId,
|
||||
stickerId: sticker.id,
|
||||
newPosition: { row: newRow, col: newCol },
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[sticker, dimensions, moveSticker],
|
||||
);
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = () => {
|
||||
switch (sticker.contentType) {
|
||||
case "text":
|
||||
return <span className="sticker-text">{sticker.text}</span>;
|
||||
|
||||
case "icon":
|
||||
return renderIcon();
|
||||
|
||||
case "badge":
|
||||
return renderBadge();
|
||||
|
||||
case "custom":
|
||||
return (
|
||||
<span className="sticker-custom">{sticker.customComponentId}</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染图标
|
||||
const renderIcon = () => {
|
||||
if (!sticker.icon) return null;
|
||||
|
||||
const iconProps = {
|
||||
size: sticker.icon.size ?? 16,
|
||||
color: sticker.icon.color ?? mergedStyle.color,
|
||||
};
|
||||
|
||||
// 简单的图标映射
|
||||
switch (sticker.icon.name) {
|
||||
case "alert-circle":
|
||||
return <AlertCircle {...iconProps} />;
|
||||
case "check-circle":
|
||||
return <CheckCircle {...iconProps} />;
|
||||
case "info":
|
||||
return <Info {...iconProps} />;
|
||||
case "alert-triangle":
|
||||
return <AlertTriangle {...iconProps} />;
|
||||
default:
|
||||
return <Info {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染徽章
|
||||
const renderBadge = () => {
|
||||
if (!sticker.badge) return null;
|
||||
|
||||
const variant = sticker.badge.variant ?? "default";
|
||||
const variantStyle = BADGE_VARIANTS[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className="sticker-badge"
|
||||
style={{
|
||||
backgroundColor: variantStyle.backgroundColor,
|
||||
color: variantStyle.color,
|
||||
}}
|
||||
>
|
||||
{sticker.badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-sticker ${isDragging ? "dragging" : ""} ${sticker.draggable ? "draggable" : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: pixelPosition.top,
|
||||
left: pixelPosition.left,
|
||||
backgroundColor: mergedStyle.backgroundColor,
|
||||
color: mergedStyle.color,
|
||||
borderColor: mergedStyle.borderColor,
|
||||
borderWidth: mergedStyle.borderWidth,
|
||||
borderRadius: mergedStyle.borderRadius,
|
||||
padding: mergedStyle.padding,
|
||||
fontSize: mergedStyle.fontSize,
|
||||
fontWeight: mergedStyle.fontWeight,
|
||||
opacity: mergedStyle.opacity,
|
||||
maxWidth: mergedStyle.maxWidthChars
|
||||
? mergedStyle.maxWidthChars * dimensions.charWidth
|
||||
: undefined,
|
||||
maxHeight: mergedStyle.maxHeightRows
|
||||
? mergedStyle.maxHeightRows * dimensions.charHeight
|
||||
: undefined,
|
||||
zIndex: 20,
|
||||
cursor: sticker.draggable
|
||||
? isDragging
|
||||
? "grabbing"
|
||||
: "grab"
|
||||
: "default",
|
||||
userSelect: "none",
|
||||
transition: isDragging ? "none" : "top 0.1s, left 0.1s",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleDragStart}
|
||||
title={sticker.tooltip}
|
||||
>
|
||||
{/* 内容 */}
|
||||
<div className="sticker-content">{renderContent()}</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{sticker.closable && (
|
||||
<button
|
||||
className="sticker-close-btn"
|
||||
onClick={handleClose}
|
||||
aria-label="关闭贴纸"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sticker;
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file StickerLayer.tsx
|
||||
* @description 终端贴纸层组件
|
||||
* @module components/terminal/StickerLayer
|
||||
*
|
||||
* 管理和渲染终端上的所有贴纸。
|
||||
* 作为覆盖层放置在终端容器上方。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback, useMemo } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Sticker } from "./Sticker";
|
||||
import {
|
||||
type Sticker as StickerType,
|
||||
type TerminalDimensions,
|
||||
getStickersForBlockAtom,
|
||||
getTerminalDimensionsAtom,
|
||||
updateTerminalDimensionsAtom,
|
||||
DEFAULT_TERMINAL_DIMENSIONS,
|
||||
} from "@/lib/terminal/stickers";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface StickerLayerProps {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 终端容器引用(用于计算尺寸) */
|
||||
terminalRef?: React.RefObject<HTMLDivElement>;
|
||||
/** 字符宽度(像素) */
|
||||
charWidth?: number;
|
||||
/** 字符高度(像素) */
|
||||
charHeight?: number;
|
||||
/** 终端行数 */
|
||||
rows?: number;
|
||||
/** 终端列数 */
|
||||
cols?: number;
|
||||
/** 贴纸点击回调 */
|
||||
onStickerClick?: (sticker: StickerType) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StickerLayer 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端贴纸层组件
|
||||
*
|
||||
* 渲染指定终端块的所有贴纸。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
export const StickerLayer: React.FC<StickerLayerProps> = ({
|
||||
blockId,
|
||||
terminalRef,
|
||||
charWidth,
|
||||
charHeight,
|
||||
rows,
|
||||
cols,
|
||||
onStickerClick,
|
||||
}) => {
|
||||
// 获取贴纸列表
|
||||
const getStickersForBlock = useAtomValue(getStickersForBlockAtom);
|
||||
const stickers = useMemo(
|
||||
() => getStickersForBlock(blockId),
|
||||
[getStickersForBlock, blockId],
|
||||
);
|
||||
|
||||
// 获取终端尺寸
|
||||
const getTerminalDimensions = useAtomValue(getTerminalDimensionsAtom);
|
||||
const storedDimensions = useMemo(
|
||||
() => getTerminalDimensions(blockId),
|
||||
[getTerminalDimensions, blockId],
|
||||
);
|
||||
|
||||
// 更新终端尺寸
|
||||
const updateDimensions = useSetAtom(updateTerminalDimensionsAtom);
|
||||
|
||||
// 计算实际使用的尺寸
|
||||
// _Requirements: 15.4_
|
||||
const dimensions: TerminalDimensions = useMemo(() => {
|
||||
// 优先使用 props 传入的值
|
||||
if (charWidth && charHeight && rows && cols) {
|
||||
return {
|
||||
charWidth,
|
||||
charHeight,
|
||||
rows,
|
||||
cols,
|
||||
paddingLeft: DEFAULT_TERMINAL_DIMENSIONS.paddingLeft,
|
||||
paddingTop: DEFAULT_TERMINAL_DIMENSIONS.paddingTop,
|
||||
};
|
||||
}
|
||||
// 否则使用存储的值
|
||||
return storedDimensions;
|
||||
}, [charWidth, charHeight, rows, cols, storedDimensions]);
|
||||
|
||||
// 从终端容器计算尺寸
|
||||
// _Requirements: 15.4_
|
||||
useEffect(() => {
|
||||
if (!terminalRef?.current) return;
|
||||
|
||||
const calculateDimensions = () => {
|
||||
const container = terminalRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 尝试从 xterm 获取尺寸信息
|
||||
const xtermScreen = container.querySelector(".xterm-screen");
|
||||
const xtermRows = container.querySelector(".xterm-rows");
|
||||
|
||||
if (xtermScreen && xtermRows) {
|
||||
// 获取第一个字符单元格来计算字符尺寸
|
||||
const firstRow = xtermRows.querySelector(".xterm-row");
|
||||
if (firstRow) {
|
||||
const firstChar = firstRow.querySelector("span");
|
||||
if (firstChar) {
|
||||
const charRect = firstChar.getBoundingClientRect();
|
||||
const newDimensions: TerminalDimensions = {
|
||||
charWidth:
|
||||
charRect.width || DEFAULT_TERMINAL_DIMENSIONS.charWidth,
|
||||
charHeight:
|
||||
charRect.height || DEFAULT_TERMINAL_DIMENSIONS.charHeight,
|
||||
rows:
|
||||
rows ??
|
||||
Math.floor(xtermScreen.clientHeight / (charRect.height || 17)),
|
||||
cols:
|
||||
cols ??
|
||||
Math.floor(xtermScreen.clientWidth / (charRect.width || 8)),
|
||||
paddingLeft: DEFAULT_TERMINAL_DIMENSIONS.paddingLeft,
|
||||
paddingTop: DEFAULT_TERMINAL_DIMENSIONS.paddingTop,
|
||||
};
|
||||
|
||||
updateDimensions({ blockId, dimensions: newDimensions });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始计算
|
||||
calculateDimensions();
|
||||
|
||||
// 监听大小变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateDimensions();
|
||||
});
|
||||
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [terminalRef, blockId, rows, cols, updateDimensions]);
|
||||
|
||||
// 处理贴纸点击
|
||||
const handleStickerClick = useCallback(
|
||||
(sticker: StickerType) => {
|
||||
onStickerClick?.(sticker);
|
||||
},
|
||||
[onStickerClick],
|
||||
);
|
||||
|
||||
// 如果没有贴纸,不渲染任何内容
|
||||
if (stickers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="terminal-sticker-layer"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{stickers.map((sticker) => (
|
||||
<div key={sticker.id} style={{ pointerEvents: "auto" }}>
|
||||
<Sticker
|
||||
sticker={sticker}
|
||||
dimensions={dimensions}
|
||||
onClick={handleStickerClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickerLayer;
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* @file SubBlock.tsx
|
||||
* @description VDOM 子块组件
|
||||
* @module components/terminal/SubBlock
|
||||
*
|
||||
* 渲染终端内嵌的 VDOM 块。
|
||||
*
|
||||
* _Requirements: 14.3, 14.4, 14.5_
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
type VDomBlock,
|
||||
type VDomContext,
|
||||
removeVDomBlockAtom,
|
||||
setVDomBlockFocusAtom,
|
||||
updateVDomBlockAtom,
|
||||
} from "@/lib/terminal/vdom";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface SubBlockProps {
|
||||
/** VDOM 块实例 */
|
||||
block: VDomBlock;
|
||||
/** 终端块 ID */
|
||||
terminalBlockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** VDOM 上下文 */
|
||||
context: VDomContext;
|
||||
/** 块索引(用于键盘导航) */
|
||||
index?: number;
|
||||
/** 总块数(用于键盘导航) */
|
||||
totalBlocks?: number;
|
||||
/** 导航到上一个块 */
|
||||
onNavigatePrev?: () => void;
|
||||
/** 导航到下一个块 */
|
||||
onNavigateNext?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 内置 VDOM 组件注册表
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 内置 VDOM 组件
|
||||
*
|
||||
* 可以通过 component 名称引用这些组件。
|
||||
*/
|
||||
const builtinComponents: Record<
|
||||
string,
|
||||
React.FC<{ block: VDomBlock; context: VDomContext }>
|
||||
> = {
|
||||
// 占位符组件
|
||||
placeholder: ({ block }) => (
|
||||
<div className="subblock-placeholder">
|
||||
<p>VDOM 块: {block.config.id}</p>
|
||||
<p>组件: {block.config.component}</p>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 加载中组件
|
||||
loading: () => (
|
||||
<div className="subblock-loading">
|
||||
<div className="subblock-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 错误组件
|
||||
error: ({ block }) => (
|
||||
<div className="subblock-error">
|
||||
<span className="subblock-error-icon">⚠️</span>
|
||||
<span>{block.error ?? "发生错误"}</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 示例:信息卡片组件
|
||||
infoCard: ({ block }) => (
|
||||
<div className="subblock-info-card">
|
||||
<h4>{(block.config.props?.title as string) ?? "信息"}</h4>
|
||||
<p>{(block.config.props?.content as string) ?? "无内容"}</p>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 示例:按钮组组件
|
||||
buttonGroup: ({ block, context }) => {
|
||||
const buttons =
|
||||
(block.config.props?.buttons as Array<{
|
||||
label: string;
|
||||
action: string;
|
||||
}>) ?? [];
|
||||
return (
|
||||
<div className="subblock-button-group">
|
||||
{buttons.map((btn, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="subblock-action-btn"
|
||||
onClick={() => {
|
||||
context.sendEvent({
|
||||
type: "block:update",
|
||||
blockId: block.config.id,
|
||||
data: { action: btn.action },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义组件注册表
|
||||
*
|
||||
* 允许外部注册自定义 VDOM 组件。
|
||||
*/
|
||||
const customComponents: Map<
|
||||
string,
|
||||
React.FC<{ block: VDomBlock; context: VDomContext }>
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* 注册自定义 VDOM 组件
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function registerVDomComponent(
|
||||
name: string,
|
||||
component: React.FC<{ block: VDomBlock; context: VDomContext }>,
|
||||
): void {
|
||||
customComponents.set(name, component);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销自定义 VDOM 组件
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function unregisterVDomComponent(name: string): void {
|
||||
customComponents.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 VDOM 组件
|
||||
*/
|
||||
function getVDomComponent(
|
||||
componentName: string,
|
||||
): React.FC<{ block: VDomBlock; context: VDomContext }> | null {
|
||||
// 优先查找自定义组件
|
||||
const custom = customComponents.get(componentName);
|
||||
if (custom) return custom;
|
||||
|
||||
// 然后查找内置组件
|
||||
return builtinComponents[componentName] ?? null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 焦点管理工具函数
|
||||
// _Requirements: 14.4_
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取元素内所有可聚焦元素
|
||||
*/
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const focusableSelectors = [
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"a[href]",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ");
|
||||
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(focusableSelectors),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 焦点陷阱 Hook
|
||||
*
|
||||
* 将焦点限制在容器内,支持 Tab 键循环导航。
|
||||
*/
|
||||
function useFocusTrap(
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
const focusableElements = getFocusableElements(container);
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab:向后导航
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab:向前导航
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [containerRef, enabled]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SubBlock 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 子块组件
|
||||
*
|
||||
* 渲染单个 VDOM 块,支持焦点管理和关闭操作。
|
||||
*
|
||||
* _Requirements: 14.3, 14.4, 14.5_
|
||||
*/
|
||||
export const SubBlock: React.FC<SubBlockProps> = ({
|
||||
block,
|
||||
terminalBlockId,
|
||||
tabId: _tabId,
|
||||
context,
|
||||
index = 0,
|
||||
totalBlocks = 1,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
className = "",
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocusTrapEnabled, setIsFocusTrapEnabled] = useState(false);
|
||||
|
||||
// 操作原子
|
||||
const removeBlock = useSetAtom(removeVDomBlockAtom);
|
||||
const setBlockFocus = useSetAtom(setVDomBlockFocusAtom);
|
||||
const updateBlock = useSetAtom(updateVDomBlockAtom);
|
||||
|
||||
// 启用焦点陷阱
|
||||
useFocusTrap(containerRef, isFocusTrapEnabled && block.focused);
|
||||
|
||||
// 处理关闭
|
||||
// _Requirements: 14.5_
|
||||
const handleClose = useCallback(() => {
|
||||
if (!block.config.closable) return;
|
||||
|
||||
removeBlock({ terminalBlockId, blockId: block.config.id });
|
||||
context.closeBlock(block.config.id);
|
||||
}, [
|
||||
block.config.id,
|
||||
block.config.closable,
|
||||
terminalBlockId,
|
||||
removeBlock,
|
||||
context,
|
||||
]);
|
||||
|
||||
// 处理聚焦
|
||||
// _Requirements: 14.4_
|
||||
const handleFocus = useCallback(() => {
|
||||
setBlockFocus({ terminalBlockId, blockId: block.config.id });
|
||||
}, [block.config.id, terminalBlockId, setBlockFocus]);
|
||||
|
||||
// 处理失焦
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
// 检查焦点是否移出了块
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setBlockFocus({ terminalBlockId, blockId: null });
|
||||
}
|
||||
},
|
||||
[terminalBlockId, setBlockFocus],
|
||||
);
|
||||
|
||||
// 键盘事件处理
|
||||
// _Requirements: 14.4_
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
// Escape 键关闭块
|
||||
if (block.config.closable) {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
case "ArrowLeft":
|
||||
// 向上/左导航到上一个块
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
onNavigatePrev?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
case "ArrowRight":
|
||||
// 向下/右导航到下一个块
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
onNavigateNext?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "f":
|
||||
// Ctrl/Cmd + F 启用焦点陷阱
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
setIsFocusTrapEnabled((prev) => !prev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[block.config.closable, handleClose, onNavigatePrev, onNavigateNext],
|
||||
);
|
||||
|
||||
// 块加载完成后更新状态
|
||||
useEffect(() => {
|
||||
if (block.status === "loading") {
|
||||
// 模拟加载完成
|
||||
const timer = setTimeout(() => {
|
||||
updateBlock({
|
||||
terminalBlockId,
|
||||
blockId: block.config.id,
|
||||
updates: { status: "ready" },
|
||||
});
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [block.config.id, block.status, terminalBlockId, updateBlock]);
|
||||
|
||||
// 聚焦时自动滚动到视图
|
||||
useEffect(() => {
|
||||
if (block.focused && containerRef.current) {
|
||||
containerRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [block.focused]);
|
||||
|
||||
// 获取要渲染的组件
|
||||
const Component = getVDomComponent(block.config.component);
|
||||
|
||||
// 计算样式
|
||||
const style: React.CSSProperties = {};
|
||||
if (block.config.position) {
|
||||
const { top, left, bottom, right } = block.config.position;
|
||||
if (top !== undefined) style.top = top;
|
||||
if (left !== undefined) style.left = left;
|
||||
if (bottom !== undefined) style.bottom = bottom;
|
||||
if (right !== undefined) style.right = right;
|
||||
}
|
||||
if (block.config.size) {
|
||||
const { width, height, minWidth, minHeight, maxWidth, maxHeight } =
|
||||
block.config.size;
|
||||
if (width !== undefined) style.width = width;
|
||||
if (height !== undefined) style.height = height;
|
||||
if (minWidth !== undefined) style.minWidth = minWidth;
|
||||
if (minHeight !== undefined) style.minHeight = minHeight;
|
||||
if (maxWidth !== undefined) style.maxWidth = maxWidth;
|
||||
if (maxHeight !== undefined) style.maxHeight = maxHeight;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`subblock ${block.focused ? "focused" : ""} ${isFocusTrapEnabled ? "focus-trapped" : ""} ${className}`}
|
||||
style={style}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={block.config.title ?? `VDOM 块 ${index + 1}`}
|
||||
aria-describedby={block.config.id}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-block-id={block.config.id}
|
||||
data-block-type={block.config.type}
|
||||
data-block-index={index}
|
||||
>
|
||||
{/* 块头部 */}
|
||||
{(block.config.title || block.config.closable) && (
|
||||
<div className="subblock-header">
|
||||
{block.config.title && (
|
||||
<span className="subblock-title">{block.config.title}</span>
|
||||
)}
|
||||
<div className="subblock-header-actions">
|
||||
{/* 焦点陷阱指示器 */}
|
||||
{isFocusTrapEnabled && (
|
||||
<span
|
||||
className="subblock-focus-indicator"
|
||||
title="焦点已锁定 (Ctrl+F 解锁)"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
{/* 块索引指示器 */}
|
||||
{totalBlocks > 1 && (
|
||||
<span className="subblock-index">
|
||||
{index + 1}/{totalBlocks}
|
||||
</span>
|
||||
)}
|
||||
{block.config.closable && (
|
||||
<button
|
||||
className="subblock-close-btn"
|
||||
onClick={handleClose}
|
||||
title="关闭 (Esc)"
|
||||
aria-label="关闭块"
|
||||
>
|
||||
<CloseIcon className="subblock-close-icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 块内容 */}
|
||||
<div className="subblock-content" id={block.config.id}>
|
||||
{block.status === "loading" && (
|
||||
<div className="subblock-loading">
|
||||
<div className="subblock-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{block.status === "error" && (
|
||||
<div className="subblock-error">
|
||||
<span className="subblock-error-icon">⚠️</span>
|
||||
<span>{block.error ?? "发生错误"}</span>
|
||||
</div>
|
||||
)}
|
||||
{block.status === "ready" && Component && (
|
||||
<Component block={block} context={context} />
|
||||
)}
|
||||
{block.status === "ready" && !Component && (
|
||||
<div className="subblock-placeholder">
|
||||
<p>未找到组件: {block.config.component}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SubBlockContainer 组件
|
||||
// ============================================================================
|
||||
|
||||
export interface SubBlockContainerProps {
|
||||
/** VDOM 块列表 */
|
||||
blocks: VDomBlock[];
|
||||
/** 终端块 ID */
|
||||
terminalBlockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** VDOM 上下文 */
|
||||
context: VDomContext;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VDOM 子块容器
|
||||
*
|
||||
* 渲染多个 VDOM 块,支持键盘导航。
|
||||
*
|
||||
* _Requirements: 14.3, 14.4_
|
||||
*/
|
||||
export const SubBlockContainer: React.FC<SubBlockContainerProps> = ({
|
||||
blocks,
|
||||
terminalBlockId,
|
||||
tabId,
|
||||
context,
|
||||
className = "",
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const setBlockFocus = useSetAtom(setVDomBlockFocusAtom);
|
||||
|
||||
// 导航到指定索引的块
|
||||
const navigateToBlock = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= blocks.length) return;
|
||||
|
||||
const targetBlock = blocks[index];
|
||||
setBlockFocus({ terminalBlockId, blockId: targetBlock.config.id });
|
||||
|
||||
// 聚焦对应的 DOM 元素
|
||||
const blockElement = containerRef.current?.querySelector(
|
||||
`[data-block-index="${index}"]`,
|
||||
) as HTMLElement | null;
|
||||
blockElement?.focus();
|
||||
},
|
||||
[blocks, terminalBlockId, setBlockFocus],
|
||||
);
|
||||
|
||||
// 获取当前聚焦块的索引
|
||||
const _getFocusedIndex = useCallback(() => {
|
||||
return blocks.findIndex((b) => b.focused);
|
||||
}, [blocks]);
|
||||
|
||||
// 导航到上一个块
|
||||
const handleNavigatePrev = useCallback(
|
||||
(currentIndex: number) => {
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : blocks.length - 1;
|
||||
navigateToBlock(prevIndex);
|
||||
},
|
||||
[blocks.length, navigateToBlock],
|
||||
);
|
||||
|
||||
// 导航到下一个块
|
||||
const handleNavigateNext = useCallback(
|
||||
(currentIndex: number) => {
|
||||
const nextIndex = currentIndex < blocks.length - 1 ? currentIndex + 1 : 0;
|
||||
navigateToBlock(nextIndex);
|
||||
},
|
||||
[blocks.length, navigateToBlock],
|
||||
);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`subblock-container ${className}`}
|
||||
role="list"
|
||||
aria-label="VDOM 块列表"
|
||||
>
|
||||
{blocks.map((block, index) => (
|
||||
<SubBlock
|
||||
key={block.config.id}
|
||||
block={block}
|
||||
terminalBlockId={terminalBlockId}
|
||||
tabId={tabId}
|
||||
context={context}
|
||||
index={index}
|
||||
totalBlocks={blocks.length}
|
||||
onNavigatePrev={() => handleNavigatePrev(index)}
|
||||
onNavigateNext={() => handleNavigateNext(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubBlock;
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @file TerminalContextMenu.tsx
|
||||
* @description 终端上下文菜单组件
|
||||
* @module components/terminal/TerminalContextMenu
|
||||
*
|
||||
* 提供终端右键菜单功能,包括复制、粘贴、URL 打开等。
|
||||
*
|
||||
* _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 菜单位置 */
|
||||
export interface ContextMenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** 组件属性 */
|
||||
export interface TerminalContextMenuProps {
|
||||
/** 菜单位置 */
|
||||
position: ContextMenuPosition;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 复制回调
|
||||
* _Requirements: 13.2_
|
||||
*/
|
||||
onCopy: () => void;
|
||||
/** 粘贴回调
|
||||
* _Requirements: 13.2_
|
||||
*/
|
||||
onPaste: () => void;
|
||||
/** 选中的文本 */
|
||||
selectedText: string;
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
/** 菜单项 */
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
/** 复制图标 */
|
||||
const CopyIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 粘贴图标 */
|
||||
const PasteIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 链接图标 */
|
||||
const LinkIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 清空图标 */
|
||||
const ClearIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 全选图标 */
|
||||
const SelectAllIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className || "w-4 h-4"}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="15" x2="21" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检测文本是否为 URL
|
||||
*
|
||||
* _Requirements: 13.4_
|
||||
*/
|
||||
function detectUrl(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
|
||||
// URL 正则表达式
|
||||
const urlPattern =
|
||||
/^(https?:\/\/|ftp:\/\/|file:\/\/)?[\w-]+(\.[\w-]+)+([\w-.,@?^=%&:/~+#]*[\w-@?^=%&/~+#])?$/i;
|
||||
|
||||
if (urlPattern.test(trimmed)) {
|
||||
// 如果没有协议,添加 https://
|
||||
if (!/^(https?|ftp|file):\/\//i.test(trimmed)) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端上下文菜单组件
|
||||
*
|
||||
* _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
|
||||
*/
|
||||
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
position,
|
||||
onClose,
|
||||
onCopy,
|
||||
onPaste,
|
||||
selectedText,
|
||||
blockId: _blockId,
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 检测选中文本是否为 URL
|
||||
// _Requirements: 13.4_
|
||||
const detectedUrl = useMemo(
|
||||
() => (selectedText ? detectUrl(selectedText) : null),
|
||||
[selectedText],
|
||||
);
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// 调整菜单位置,确保不超出视口
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
// 右边界检查
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = viewportWidth - rect.width - 8;
|
||||
}
|
||||
|
||||
// 下边界检查
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = viewportHeight - rect.height - 8;
|
||||
}
|
||||
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
// 打开 URL
|
||||
// _Requirements: 13.4_
|
||||
const handleOpenUrl = useCallback(async () => {
|
||||
if (detectedUrl) {
|
||||
try {
|
||||
await open(detectedUrl);
|
||||
} catch (err) {
|
||||
console.error("[TerminalContextMenu] 打开 URL 失败:", err);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}, [detectedUrl, onClose]);
|
||||
|
||||
// 构建菜单项
|
||||
const menuItems: MenuItem[] = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
// 复制
|
||||
// _Requirements: 13.2_
|
||||
items.push({
|
||||
id: "copy",
|
||||
label: "复制",
|
||||
icon: <CopyIcon />,
|
||||
shortcut: "⌘C",
|
||||
disabled: !selectedText,
|
||||
onClick: onCopy,
|
||||
});
|
||||
|
||||
// 粘贴
|
||||
// _Requirements: 13.2_
|
||||
items.push({
|
||||
id: "paste",
|
||||
label: "粘贴",
|
||||
icon: <PasteIcon />,
|
||||
shortcut: "⌘V",
|
||||
onClick: onPaste,
|
||||
});
|
||||
|
||||
// 分隔线
|
||||
items.push({
|
||||
id: "divider-1",
|
||||
label: "",
|
||||
onClick: () => {},
|
||||
divider: true,
|
||||
});
|
||||
|
||||
// 打开 URL(如果选中的是 URL)
|
||||
// _Requirements: 13.4_
|
||||
if (detectedUrl) {
|
||||
items.push({
|
||||
id: "open-url",
|
||||
label: "打开链接",
|
||||
icon: <LinkIcon />,
|
||||
onClick: handleOpenUrl,
|
||||
});
|
||||
|
||||
items.push({
|
||||
id: "divider-2",
|
||||
label: "",
|
||||
onClick: () => {},
|
||||
divider: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 全选
|
||||
items.push({
|
||||
id: "select-all",
|
||||
label: "全选",
|
||||
icon: <SelectAllIcon />,
|
||||
shortcut: "⌘A",
|
||||
onClick: () => {
|
||||
// TODO: 实现全选功能
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// 清空终端
|
||||
items.push({
|
||||
id: "clear",
|
||||
label: "清空终端",
|
||||
icon: <ClearIcon />,
|
||||
shortcut: "⌘K",
|
||||
onClick: () => {
|
||||
// TODO: 实现清空终端功能
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [selectedText, detectedUrl, onCopy, onPaste, handleOpenUrl, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="terminal-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item) =>
|
||||
item.divider ? (
|
||||
<div key={item.id} className="terminal-context-menu-divider" />
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`terminal-context-menu-item ${item.disabled ? "disabled" : ""}`}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="terminal-context-menu-icon">{item.icon}</span>
|
||||
)}
|
||||
<span className="terminal-context-menu-label">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="terminal-context-menu-shortcut">
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalContextMenu;
|
||||
@@ -1,16 +1,14 @@
|
||||
/**
|
||||
* @file TerminalPage.tsx
|
||||
* @description 内置终端页面组件 - 后端预创建架构
|
||||
* @description 内置终端页面组件 - 对齐 waveterm 单容器架构
|
||||
* @module components/terminal/TerminalPage
|
||||
*
|
||||
* ## 架构说明
|
||||
* PTY 在后端预创建,前端只负责连接。
|
||||
* 新建终端时先调用后端创建会话,成功后再创建 UI 组件。
|
||||
* ## 架构说明(对齐 waveterm)
|
||||
* - 只有一个 term-connectelem(终端容器)
|
||||
* - 切换标签页时,销毁旧的 TermWrap,创建新的 TermWrap
|
||||
* - 这样可以避免多个终端容器导致的布局问题
|
||||
*
|
||||
* ## 功能特性
|
||||
* - 多标签页管理
|
||||
* - 终端搜索 (Ctrl+F)
|
||||
* - 主题切换
|
||||
* _Requirements: 8.7, 8.8, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
@@ -27,6 +25,10 @@ import {
|
||||
getThemeList,
|
||||
saveThemePreference,
|
||||
loadThemePreference,
|
||||
saveFontSizePreference,
|
||||
loadFontSizePreference,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
} from "@/lib/terminal/themes";
|
||||
import "./terminal.css";
|
||||
|
||||
@@ -153,7 +155,9 @@ const SearchButton: React.FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 主题选择器 */
|
||||
/** 主题选择器
|
||||
* _Requirements: 12.1, 12.4_
|
||||
*/
|
||||
const ThemeSelector: React.FC<{
|
||||
currentTheme: ThemeName;
|
||||
onThemeChange: (theme: ThemeName) => void;
|
||||
@@ -216,6 +220,67 @@ const ThemeSelector: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/** 字体大小调整器
|
||||
* _Requirements: 8.8_
|
||||
*/
|
||||
const FontSizeControl: React.FC<{
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
}> = ({ fontSize, onFontSizeChange }) => {
|
||||
const handleDecrease = () => {
|
||||
if (fontSize > MIN_FONT_SIZE) {
|
||||
onFontSizeChange(fontSize - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncrease = () => {
|
||||
if (fontSize < MAX_FONT_SIZE) {
|
||||
onFontSizeChange(fontSize + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="terminal-font-size-control">
|
||||
<button
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleDecrease}
|
||||
disabled={fontSize <= MIN_FONT_SIZE}
|
||||
title="减小字体 (Ctrl+-)"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="terminal-font-size-value" title="字体大小">
|
||||
{fontSize}
|
||||
</span>
|
||||
<button
|
||||
className="terminal-font-size-btn"
|
||||
onClick={handleIncrease}
|
||||
disabled={fontSize >= MAX_FONT_SIZE}
|
||||
title="增大字体 (Ctrl++)"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 标签页组件 */
|
||||
const TabItem: React.FC<{
|
||||
tab: Tab;
|
||||
@@ -256,6 +321,8 @@ const TerminalTabs: React.FC<{
|
||||
onSearchClick: () => void;
|
||||
currentTheme: ThemeName;
|
||||
onThemeChange: (theme: ThemeName) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
isCreating?: boolean;
|
||||
}> = ({
|
||||
tabs,
|
||||
@@ -266,6 +333,8 @@ const TerminalTabs: React.FC<{
|
||||
onSearchClick,
|
||||
currentTheme,
|
||||
onThemeChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
isCreating,
|
||||
}) => (
|
||||
<div className="terminal-tabbar" role="tablist">
|
||||
@@ -281,6 +350,7 @@ const TerminalTabs: React.FC<{
|
||||
))}
|
||||
</div>
|
||||
<SearchButton onClick={onSearchClick} />
|
||||
<FontSizeControl fontSize={fontSize} onFontSizeChange={onFontSizeChange} />
|
||||
<ThemeSelector currentTheme={currentTheme} onThemeChange={onThemeChange} />
|
||||
<NewTabButton onClick={onNewTab} disabled={isCreating} />
|
||||
</div>
|
||||
@@ -305,99 +375,7 @@ const EmptyTabsPlaceholder: React.FC<{
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 终端视图组件 - 连接模式
|
||||
// ============================================================================
|
||||
|
||||
interface TerminalViewProps {
|
||||
/** 会话 ID(必须) */
|
||||
sessionId: string;
|
||||
/** 状态变化回调 */
|
||||
onStatusChange: (status: SessionStatus) => void;
|
||||
/** 是否自动聚焦 */
|
||||
autoFocus?: boolean;
|
||||
/** 是否可见(用于多标签页切换) */
|
||||
visible?: boolean;
|
||||
/** 主题名称 */
|
||||
themeName?: ThemeName;
|
||||
/** TermWrap 引用回调 */
|
||||
onTermWrapRef?: (termWrap: TermWrap | null) => void;
|
||||
}
|
||||
|
||||
const TerminalView: React.FC<TerminalViewProps> = ({
|
||||
sessionId,
|
||||
onStatusChange,
|
||||
autoFocus,
|
||||
visible = true,
|
||||
themeName,
|
||||
onTermWrapRef,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termWrapRef = useRef<TermWrap | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const callbacksRef = useRef({ onStatusChange, onTermWrapRef });
|
||||
callbacksRef.current = { onStatusChange, onTermWrapRef };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 创建 TermWrap 实例
|
||||
const termWrap = new TermWrap(sessionId, container, {
|
||||
onStatusChange: (status) => callbacksRef.current.onStatusChange(status),
|
||||
themeName,
|
||||
});
|
||||
|
||||
termWrapRef.current = termWrap;
|
||||
callbacksRef.current.onTermWrapRef?.(termWrap);
|
||||
|
||||
// 设置 ResizeObserver
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
termWrap.handleResize_debounced();
|
||||
});
|
||||
rszObs.observe(container);
|
||||
resizeObserverRef.current = rszObs;
|
||||
|
||||
// 自动聚焦
|
||||
if (autoFocus) {
|
||||
setTimeout(() => termWrap.focus(), 10);
|
||||
}
|
||||
|
||||
return () => {
|
||||
termWrap.dispose();
|
||||
rszObs.disconnect();
|
||||
callbacksRef.current.onTermWrapRef?.(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
// 主题变化时更新
|
||||
useEffect(() => {
|
||||
if (themeName && termWrapRef.current) {
|
||||
termWrapRef.current.setTheme(themeName);
|
||||
}
|
||||
}, [themeName]);
|
||||
|
||||
// 当可见性变化时,触发 resize 和聚焦
|
||||
useEffect(() => {
|
||||
if (visible && termWrapRef.current) {
|
||||
termWrapRef.current.handleResize_debounced();
|
||||
if (autoFocus) {
|
||||
termWrapRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, [visible, autoFocus, themeName]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`terminal-container ${visible ? "" : "terminal-hidden"}`}
|
||||
onClick={() => termWrapRef.current?.focus()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// 主组件 - 对齐 waveterm 单容器架构
|
||||
// ============================================================================
|
||||
|
||||
export function TerminalPage() {
|
||||
@@ -409,45 +387,23 @@ export function TerminalPage() {
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeName>(
|
||||
loadThemePreference(),
|
||||
);
|
||||
// 字体大小状态
|
||||
// _Requirements: 8.8_
|
||||
const [fontSize, setFontSize] = useState<number>(loadFontSizePreference());
|
||||
const tabIdCounter = useRef(0);
|
||||
const termWrapRefs = useRef<Map<string, TermWrap>>(new Map());
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 调试:打印布局链高度
|
||||
useEffect(() => {
|
||||
if (pageRef.current) {
|
||||
let el: HTMLElement | null = pageRef.current;
|
||||
const heights: string[] = [];
|
||||
while (el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
heights.push(
|
||||
`${el.className?.slice(0, 30) || el.tagName}: ${style.height}`,
|
||||
);
|
||||
el = el.parentElement;
|
||||
}
|
||||
console.log("[TerminalPage] 布局链高度:", heights);
|
||||
}
|
||||
}, [tabs.length]);
|
||||
// 单一终端容器引用(对齐 waveterm 的 connectElemRef)
|
||||
const connectElemRef = useRef<HTMLDivElement>(null);
|
||||
// 当前 TermWrap 实例引用(对齐 waveterm 的 model.termRef)
|
||||
const termWrapRef = useRef<TermWrap | null>(null);
|
||||
|
||||
// 获取当前活动的标签页
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// 获取当前活动的 TermWrap
|
||||
const getActiveTermWrap = useCallback(() => {
|
||||
if (!activeTabId) return null;
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!tab) return null;
|
||||
return termWrapRefs.current.get(tab.sessionId) ?? null;
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
// 处理 TermWrap 引用
|
||||
const handleTermWrapRef = useCallback(
|
||||
(sessionId: string, termWrap: TermWrap | null) => {
|
||||
if (termWrap) {
|
||||
termWrapRefs.current.set(sessionId, termWrap);
|
||||
} else {
|
||||
termWrapRefs.current.delete(sessionId);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
return termWrapRef.current;
|
||||
}, []);
|
||||
|
||||
// 创建新终端
|
||||
const handleNewTerminal = useCallback(async () => {
|
||||
@@ -521,10 +477,21 @@ export function TerminalPage() {
|
||||
const handleThemeChange = useCallback((theme: ThemeName) => {
|
||||
setCurrentTheme(theme);
|
||||
saveThemePreference(theme);
|
||||
// 更新所有终端的主题
|
||||
termWrapRefs.current.forEach((termWrap) => {
|
||||
termWrap.setTheme(theme);
|
||||
});
|
||||
// 更新当前终端的主题
|
||||
if (termWrapRef.current) {
|
||||
termWrapRef.current.setTheme(theme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 字体大小变化
|
||||
// _Requirements: 8.8_
|
||||
const handleFontSizeChange = useCallback((size: number) => {
|
||||
setFontSize(size);
|
||||
saveFontSizePreference(size);
|
||||
// 更新当前终端的字体大小
|
||||
if (termWrapRef.current) {
|
||||
termWrapRef.current.setFontSize(size);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 搜索功能
|
||||
@@ -570,11 +537,28 @@ export function TerminalPage() {
|
||||
e.preventDefault();
|
||||
setShowSearch(true);
|
||||
}
|
||||
// Ctrl++ 或 Ctrl+= 增大字体
|
||||
// _Requirements: 8.8_
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "=")) {
|
||||
e.preventDefault();
|
||||
handleFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE));
|
||||
}
|
||||
// Ctrl+- 减小字体
|
||||
// _Requirements: 8.8_
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "-") {
|
||||
e.preventDefault();
|
||||
handleFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE));
|
||||
}
|
||||
// Ctrl+0 重置字体大小
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "0") {
|
||||
e.preventDefault();
|
||||
handleFontSizeChange(14);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
}, [fontSize, handleFontSizeChange]);
|
||||
|
||||
// 清除错误
|
||||
useEffect(() => {
|
||||
@@ -584,6 +568,173 @@ export function TerminalPage() {
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// 首次挂载时自动创建一个终端
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0 && !isCreating) {
|
||||
handleNewTerminal();
|
||||
}
|
||||
// 只在首次挂载时执行
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// 核心:当活动标签页变化时,重新创建 TermWrap(对齐 waveterm)
|
||||
// ============================================================================
|
||||
|
||||
// 键盘事件处理器(对齐 waveterm 的 handleTerminalKeydown)
|
||||
// 返回 true = 允许事件传递到终端
|
||||
// 返回 false = 阻止事件传递到终端(已处理)
|
||||
const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => {
|
||||
// 只处理 keydown 事件
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const termWrap = termWrapRef.current;
|
||||
if (!termWrap) return true;
|
||||
|
||||
const isMac = /mac/i.test(navigator.userAgent);
|
||||
|
||||
// Shift+End - 滚动到底部
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "End"
|
||||
) {
|
||||
termWrap.terminal.scrollToBottom();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+Home - 滚动到顶部
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Home"
|
||||
) {
|
||||
termWrap.terminal.scrollToLine(0);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cmd+End (macOS) - 滚动到底部
|
||||
if (
|
||||
isMac &&
|
||||
e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.key === "End"
|
||||
) {
|
||||
termWrap.terminal.scrollToBottom();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cmd+Home (macOS) - 滚动到顶部
|
||||
if (
|
||||
isMac &&
|
||||
e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.key === "Home"
|
||||
) {
|
||||
termWrap.terminal.scrollToLine(0);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+PageDown - 向下滚动一页
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "PageDown"
|
||||
) {
|
||||
termWrap.terminal.scrollPages(1);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+PageUp - 向上滚动一页
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "PageUp"
|
||||
) {
|
||||
termWrap.terminal.scrollPages(-1);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 未处理的事件,允许传递到终端
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = connectElemRef.current;
|
||||
if (!container || !activeTab) {
|
||||
// 没有活动标签页,清理旧的 TermWrap
|
||||
if (termWrapRef.current) {
|
||||
termWrapRef.current.dispose();
|
||||
termWrapRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 销毁旧的 TermWrap
|
||||
if (termWrapRef.current) {
|
||||
termWrapRef.current.dispose();
|
||||
termWrapRef.current = null;
|
||||
}
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = "";
|
||||
|
||||
// 创建新的 TermWrap(对齐 waveterm)
|
||||
const termWrap = new TermWrap(activeTab.sessionId, container, {
|
||||
onStatusChange: (status) => handleStatusChange(activeTab.id, status),
|
||||
themeName: currentTheme,
|
||||
fontSize: fontSize,
|
||||
keydownHandler: handleTerminalKeydown,
|
||||
});
|
||||
|
||||
termWrapRef.current = termWrap;
|
||||
|
||||
// 设置 ResizeObserver(对齐 waveterm)
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
termWrap.handleResize_debounced();
|
||||
});
|
||||
rszObs.observe(container);
|
||||
|
||||
// 异步初始化终端(对齐 waveterm 的 fireAndForget)
|
||||
termWrap.initTerminal().catch(console.error);
|
||||
|
||||
// 自动聚焦
|
||||
setTimeout(() => termWrap.focus(), 10);
|
||||
|
||||
return () => {
|
||||
termWrap.dispose();
|
||||
rszObs.disconnect();
|
||||
};
|
||||
// 注意:handleTerminalKeydown 使用 useCallback 且无依赖,不会导致重新创建
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab?.sessionId, currentTheme, fontSize, handleStatusChange]);
|
||||
|
||||
// 没有标签页时显示空状态
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
@@ -597,6 +748,8 @@ export function TerminalPage() {
|
||||
onSearchClick={() => setShowSearch(true)}
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
@@ -615,10 +768,7 @@ export function TerminalPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
className="flex flex-col w-full h-full overflow-hidden relative terminal-bg"
|
||||
>
|
||||
<div className="view-term terminal-bg">
|
||||
{/* 标签栏 */}
|
||||
<TerminalTabs
|
||||
tabs={tabs}
|
||||
@@ -629,6 +779,8 @@ export function TerminalPage() {
|
||||
onSearchClick={() => setShowSearch(true)}
|
||||
currentTheme={currentTheme}
|
||||
onThemeChange={handleThemeChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
@@ -642,24 +794,12 @@ export function TerminalPage() {
|
||||
onClearSearch={handleClearSearch}
|
||||
/>
|
||||
|
||||
{/* 终端视图 - 只渲染当前活动的终端(参考 waveterm) */}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
{tabs
|
||||
.filter((tab) => tab.id === activeTabId)
|
||||
.map((tab) => (
|
||||
<TerminalView
|
||||
key={tab.id}
|
||||
sessionId={tab.sessionId}
|
||||
onStatusChange={(status) => handleStatusChange(tab.id, status)}
|
||||
visible={true}
|
||||
autoFocus={true}
|
||||
themeName={currentTheme}
|
||||
onTermWrapRef={(termWrap) =>
|
||||
handleTermWrapRef(tab.sessionId, termWrap)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 单一终端容器(对齐 waveterm 的 term-connectelem) */}
|
||||
<div
|
||||
ref={connectElemRef}
|
||||
className="term-connectelem"
|
||||
onClick={() => termWrapRef.current?.focus()}
|
||||
/>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
@@ -670,5 +810,4 @@ export function TerminalPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TerminalPage;
|
||||
|
||||
@@ -5,25 +5,39 @@
|
||||
*
|
||||
* 提供终端内搜索功能的 UI 组件。
|
||||
* 支持正则表达式、大小写敏感、全词匹配等选项。
|
||||
*
|
||||
* _Requirements: 8.3, 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { ISearchOptions } from "@xterm/addon-search";
|
||||
|
||||
/** 搜索结果信息 */
|
||||
export interface SearchResultInfo {
|
||||
/** 当前匹配索引(从 1 开始) */
|
||||
currentIndex: number;
|
||||
/** 总匹配数 */
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 搜索组件属性 */
|
||||
export interface TerminalSearchProps {
|
||||
/** 是否显示 */
|
||||
visible: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 搜索回调 */
|
||||
/** 搜索回调,返回是否找到匹配 */
|
||||
onSearch: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 搜索下一个 */
|
||||
/** 搜索下一个,返回是否找到匹配 */
|
||||
onSearchNext: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 搜索上一个 */
|
||||
/** 搜索上一个,返回是否找到匹配 */
|
||||
onSearchPrevious: (term: string, options: ISearchOptions) => boolean;
|
||||
/** 清除搜索 */
|
||||
onClearSearch: () => void;
|
||||
/** 搜索结果信息(可选,用于显示匹配计数)
|
||||
* _Requirements: 11.4, 11.5_
|
||||
*/
|
||||
searchResultInfo?: SearchResultInfo;
|
||||
}
|
||||
|
||||
/** 搜索图标 */
|
||||
@@ -90,6 +104,8 @@ const ChevronDownIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
|
||||
/**
|
||||
* 终端搜索组件
|
||||
*
|
||||
* _Requirements: 8.3, 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
*/
|
||||
export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
visible,
|
||||
@@ -98,10 +114,17 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
onSearchNext,
|
||||
onSearchPrevious,
|
||||
onClearSearch,
|
||||
searchResultInfo,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
// 区分大小写选项
|
||||
// _Requirements: 11.2_
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
// 全词匹配选项
|
||||
// _Requirements: 11.3_
|
||||
const [wholeWord, setWholeWord] = useState(false);
|
||||
// 正则表达式选项
|
||||
// _Requirements: 11.1_
|
||||
const [regex, setRegex] = useState(false);
|
||||
const [hasResults, setHasResults] = useState<boolean | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -113,6 +136,16 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
wholeWord,
|
||||
regex,
|
||||
incremental: true,
|
||||
// 启用装饰器以高亮所有匹配项
|
||||
// _Requirements: 11.4_
|
||||
decorations: {
|
||||
matchBackground: "#7aa2f7",
|
||||
matchBorder: "#7aa2f7",
|
||||
matchOverviewRuler: "#7aa2f7",
|
||||
activeMatchBackground: "#ff9e64",
|
||||
activeMatchBorder: "#ff9e64",
|
||||
activeMatchColorOverviewRuler: "#ff9e64",
|
||||
},
|
||||
}),
|
||||
[caseSensitive, wholeWord, regex],
|
||||
);
|
||||
@@ -129,6 +162,7 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
}, [searchTerm, getSearchOptions, onSearch, onClearSearch]);
|
||||
|
||||
// 搜索下一个
|
||||
// _Requirements: 11.6_
|
||||
const handleNext = useCallback(() => {
|
||||
if (!searchTerm) return;
|
||||
const found = onSearchNext(searchTerm, getSearchOptions());
|
||||
@@ -136,6 +170,7 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
}, [searchTerm, getSearchOptions, onSearchNext]);
|
||||
|
||||
// 搜索上一个
|
||||
// _Requirements: 11.6_
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (!searchTerm) return;
|
||||
const found = onSearchPrevious(searchTerm, getSearchOptions());
|
||||
@@ -143,6 +178,7 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
}, [searchTerm, getSearchOptions, onSearchPrevious]);
|
||||
|
||||
// 关闭搜索
|
||||
// _Requirements: 11.7_
|
||||
const handleClose = useCallback(() => {
|
||||
onClearSearch();
|
||||
setSearchTerm("");
|
||||
@@ -162,6 +198,21 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
handleNext();
|
||||
}
|
||||
}
|
||||
// Alt+C 切换大小写敏感
|
||||
if (e.altKey && e.key === "c") {
|
||||
e.preventDefault();
|
||||
setCaseSensitive((prev) => !prev);
|
||||
}
|
||||
// Alt+W 切换全词匹配
|
||||
if (e.altKey && e.key === "w") {
|
||||
e.preventDefault();
|
||||
setWholeWord((prev) => !prev);
|
||||
}
|
||||
// Alt+R 切换正则表达式
|
||||
if (e.altKey && e.key === "r") {
|
||||
e.preventDefault();
|
||||
setRegex((prev) => !prev);
|
||||
}
|
||||
},
|
||||
[handleClose, handleNext, handlePrevious],
|
||||
);
|
||||
@@ -181,6 +232,26 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 渲染搜索结果计数
|
||||
// _Requirements: 11.5_
|
||||
const renderResultCount = () => {
|
||||
if (!searchTerm) return null;
|
||||
|
||||
if (hasResults === false) {
|
||||
return <span className="terminal-search-no-results">无结果</span>;
|
||||
}
|
||||
|
||||
if (searchResultInfo && searchResultInfo.totalCount > 0) {
|
||||
return (
|
||||
<span className="terminal-search-count">
|
||||
{searchResultInfo.currentIndex} / {searchResultInfo.totalCount}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="terminal-search-bar">
|
||||
<div className="terminal-search-input-wrapper">
|
||||
@@ -194,12 +265,12 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{searchTerm && hasResults === false && (
|
||||
<span className="terminal-search-no-results">无结果</span>
|
||||
)}
|
||||
{renderResultCount()}
|
||||
</div>
|
||||
|
||||
{/* 搜索选项 */}
|
||||
{/* 搜索选项
|
||||
* _Requirements: 11.1, 11.2, 11.3_
|
||||
*/}
|
||||
<div className="terminal-search-options">
|
||||
<button
|
||||
className={`terminal-search-option ${caseSensitive ? "active" : ""}`}
|
||||
@@ -224,12 +295,14 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
{/* 导航按钮
|
||||
* _Requirements: 11.6_
|
||||
*/}
|
||||
<div className="terminal-search-nav">
|
||||
<button
|
||||
className="terminal-search-nav-btn"
|
||||
onClick={handlePrevious}
|
||||
disabled={!searchTerm}
|
||||
disabled={!searchTerm || hasResults === false}
|
||||
title="上一个 (Shift+Enter)"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
@@ -237,14 +310,16 @@ export const TerminalSearch: React.FC<TerminalSearchProps> = ({
|
||||
<button
|
||||
className="terminal-search-nav-btn"
|
||||
onClick={handleNext}
|
||||
disabled={!searchTerm}
|
||||
disabled={!searchTerm || hasResults === false}
|
||||
title="下一个 (Enter)"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{/* 关闭按钮
|
||||
* _Requirements: 11.7_
|
||||
*/}
|
||||
<button
|
||||
className="terminal-search-close"
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* @file TerminalView.tsx
|
||||
* @description 终端视图组件 - 使用 Jotai 原子状态
|
||||
* @module components/terminal/TerminalView
|
||||
*
|
||||
* 重构后的终端视图组件,使用 Jotai 进行状态管理。
|
||||
* 对齐 waveterm 的 TerminalView 架构。
|
||||
*
|
||||
* ## 功能
|
||||
* - 使用 TermViewModel 管理状态
|
||||
* - 连接状态显示和重连
|
||||
* - 上下文菜单
|
||||
* - 多输入模式支持
|
||||
* - VDOM 模式支持
|
||||
* - 贴纸系统支持
|
||||
*
|
||||
* _Requirements: 9.7, 7.3, 7.4, 7.5, 10.1, 10.2, 10.3, 10.4, 10.5, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 14.1, 14.2, 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { TermWrap } from "./termwrap";
|
||||
import { TerminalSearch } from "./TerminalSearch";
|
||||
import {
|
||||
TerminalContextMenu,
|
||||
type ContextMenuPosition,
|
||||
} from "./TerminalContextMenu";
|
||||
import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";
|
||||
import { MultiInputIndicator } from "./MultiInputIndicator";
|
||||
import { VDomView } from "./VDomView";
|
||||
import { VDomModeToggle } from "./VDomModeSwitch";
|
||||
import { StickerLayer } from "./StickerLayer";
|
||||
import { cleanupStickerStateAtom } from "@/lib/terminal/stickers";
|
||||
import {
|
||||
getOrCreateTermViewModel,
|
||||
cleanupTermViewModel,
|
||||
useControllerStatusSync,
|
||||
useConnStatusSync,
|
||||
setTermModeAtom,
|
||||
setConnStatusAtom,
|
||||
setFontSizeAtom,
|
||||
setThemeNameAtom,
|
||||
cleanupTerminalStateAtom,
|
||||
type TermMode,
|
||||
} from "@/lib/terminal/store";
|
||||
import { cleanupVDomStateAtom } from "@/lib/terminal/vdom";
|
||||
import {
|
||||
writeToTerminalRaw,
|
||||
encodeBase64,
|
||||
type SessionStatus,
|
||||
} from "@/lib/terminal-api";
|
||||
import {
|
||||
type ThemeName,
|
||||
loadThemePreference,
|
||||
loadFontSizePreference,
|
||||
} from "@/lib/terminal/themes";
|
||||
import "./terminal.css";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** TerminalView 属性 */
|
||||
export interface TerminalViewProps {
|
||||
/** 块 ID(会话 ID) */
|
||||
blockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** 连接名称(可选,用于 SSH/WSL) */
|
||||
connection?: string;
|
||||
/** 是否显示搜索栏 */
|
||||
showSearch?: boolean;
|
||||
/** 搜索栏关闭回调 */
|
||||
onSearchClose?: () => void;
|
||||
/** 状态变化回调 */
|
||||
onStatusChange?: (status: SessionStatus) => void;
|
||||
/** 是否启用多输入模式 */
|
||||
multiInputEnabled?: boolean;
|
||||
/** 多输入模式切换回调 */
|
||||
onMultiInputToggle?: () => void;
|
||||
/** 初始主题 */
|
||||
initialTheme?: ThemeName;
|
||||
/** 初始字体大小 */
|
||||
initialFontSize?: number;
|
||||
/** 初始终端模式
|
||||
* _Requirements: 14.1_
|
||||
*/
|
||||
initialTermMode?: TermMode;
|
||||
/** 是否显示模式切换按钮
|
||||
* _Requirements: 14.2_
|
||||
*/
|
||||
showModeSwitch?: boolean;
|
||||
/** 模式变更回调 */
|
||||
onModeChange?: (mode: TermMode) => void;
|
||||
/** 是否显示贴纸层
|
||||
* _Requirements: 15.1_
|
||||
*/
|
||||
showStickers?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TerminalView 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端视图组件
|
||||
*
|
||||
* 使用 Jotai 原子状态管理,对齐 waveterm 架构。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export const TerminalView: React.FC<TerminalViewProps> = ({
|
||||
blockId,
|
||||
tabId,
|
||||
connection,
|
||||
showSearch = false,
|
||||
onSearchClose,
|
||||
onStatusChange,
|
||||
multiInputEnabled = false,
|
||||
onMultiInputToggle,
|
||||
initialTheme,
|
||||
initialFontSize,
|
||||
initialTermMode = "term",
|
||||
showModeSwitch = false,
|
||||
onModeChange,
|
||||
showStickers = true,
|
||||
}) => {
|
||||
// 获取 TermViewModel
|
||||
const viewModel = useMemo(
|
||||
() => getOrCreateTermViewModel(blockId, tabId),
|
||||
[blockId, tabId],
|
||||
);
|
||||
|
||||
// 订阅后端事件
|
||||
useControllerStatusSync(blockId);
|
||||
useConnStatusSync(blockId, connection);
|
||||
|
||||
// 读取原子状态
|
||||
const termMode = useAtomValue(viewModel.termModeAtom);
|
||||
const connStatus = useAtomValue(viewModel.connStatusAtom);
|
||||
const fontSize = useAtomValue(viewModel.fontSizeAtom);
|
||||
const themeName = useAtomValue(viewModel.termThemeNameAtom);
|
||||
const shellProcStatus = useAtomValue(viewModel.shellProcStatusAtom);
|
||||
const _isConnected = useAtomValue(viewModel.isConnectedAtom);
|
||||
const _isRunning = useAtomValue(viewModel.isRunningAtom);
|
||||
const _isDone = useAtomValue(viewModel.isDoneAtom);
|
||||
const _hasError = useAtomValue(viewModel.hasErrorAtom);
|
||||
const exitCode = useAtomValue(viewModel.exitCodeAtom);
|
||||
|
||||
// 设置原子状态的 actions
|
||||
const setTermMode = useSetAtom(setTermModeAtom);
|
||||
const _setConnStatus = useSetAtom(setConnStatusAtom);
|
||||
const setFontSize = useSetAtom(setFontSizeAtom);
|
||||
const setThemeName = useSetAtom(setThemeNameAtom);
|
||||
const cleanupState = useSetAtom(cleanupTerminalStateAtom);
|
||||
const cleanupVDom = useSetAtom(cleanupVDomStateAtom);
|
||||
const cleanupStickers = useSetAtom(cleanupStickerStateAtom);
|
||||
|
||||
// 本地状态
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuPosition | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Refs
|
||||
const connectElemRef = useRef<HTMLDivElement>(null);
|
||||
const termWrapRef = useRef<TermWrap | null>(null);
|
||||
|
||||
// 初始化主题和字体大小
|
||||
useEffect(() => {
|
||||
const theme = initialTheme ?? loadThemePreference();
|
||||
const size = initialFontSize ?? loadFontSizePreference();
|
||||
setThemeName({ blockId, themeName: theme });
|
||||
setFontSize({ blockId, fontSize: size });
|
||||
// 初始化终端模式
|
||||
// _Requirements: 14.1_
|
||||
setTermMode({ blockId, mode: initialTermMode });
|
||||
}, [
|
||||
blockId,
|
||||
initialTheme,
|
||||
initialFontSize,
|
||||
initialTermMode,
|
||||
setThemeName,
|
||||
setFontSize,
|
||||
setTermMode,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// 键盘事件处理器(对齐 waveterm 的 handleTerminalKeydown)
|
||||
// 返回 true = 允许事件传递到终端
|
||||
// 返回 false = 阻止事件传递到终端(已处理)
|
||||
// ============================================================================
|
||||
|
||||
const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => {
|
||||
// 只处理 keydown 事件
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const termWrap = termWrapRef.current;
|
||||
if (!termWrap) return true;
|
||||
|
||||
const isMac = /mac/i.test(navigator.userAgent);
|
||||
|
||||
// Shift+End - 滚动到底部
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "End"
|
||||
) {
|
||||
termWrap.terminal.scrollToBottom();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+Home - 滚动到顶部
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Home"
|
||||
) {
|
||||
termWrap.terminal.scrollToLine(0);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cmd+End (macOS) - 滚动到底部
|
||||
if (
|
||||
isMac &&
|
||||
e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.key === "End"
|
||||
) {
|
||||
termWrap.terminal.scrollToBottom();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cmd+Home (macOS) - 滚动到顶部
|
||||
if (
|
||||
isMac &&
|
||||
e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.shiftKey &&
|
||||
e.key === "Home"
|
||||
) {
|
||||
termWrap.terminal.scrollToLine(0);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+PageDown - 向下滚动一页
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "PageDown"
|
||||
) {
|
||||
termWrap.terminal.scrollPages(1);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+PageUp - 向上滚动一页
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "PageUp"
|
||||
) {
|
||||
termWrap.terminal.scrollPages(-1);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 未处理的事件,允许传递到终端
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// TermWrap 生命周期管理
|
||||
// ============================================================================
|
||||
|
||||
useEffect(() => {
|
||||
const container = connectElemRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 销毁旧的 TermWrap
|
||||
if (termWrapRef.current) {
|
||||
termWrapRef.current.dispose();
|
||||
termWrapRef.current = null;
|
||||
}
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = "";
|
||||
|
||||
// 创建新的 TermWrap
|
||||
const termWrap = new TermWrap(blockId, container, {
|
||||
onStatusChange: (status) => {
|
||||
onStatusChange?.(status);
|
||||
},
|
||||
themeName: themeName as ThemeName,
|
||||
fontSize: fontSize,
|
||||
keydownHandler: handleTerminalKeydown,
|
||||
});
|
||||
|
||||
termWrapRef.current = termWrap;
|
||||
|
||||
// 设置 ResizeObserver
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
termWrap.handleResize_debounced();
|
||||
});
|
||||
rszObs.observe(container);
|
||||
|
||||
// 异步初始化终端
|
||||
termWrap.initTerminal().catch(console.error);
|
||||
|
||||
// 自动聚焦
|
||||
setTimeout(() => termWrap.focus(), 10);
|
||||
|
||||
return () => {
|
||||
termWrap.dispose();
|
||||
rszObs.disconnect();
|
||||
};
|
||||
// 注意:handleTerminalKeydown 使用 useCallback 且无依赖,不会导致重新创建
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, themeName, fontSize, onStatusChange]);
|
||||
|
||||
// 清理 TermViewModel
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupTermViewModel(blockId, tabId);
|
||||
cleanupState(blockId);
|
||||
cleanupVDom(blockId);
|
||||
cleanupStickers(blockId);
|
||||
};
|
||||
}, [blockId, tabId, cleanupState, cleanupVDom, cleanupStickers]);
|
||||
|
||||
// ============================================================================
|
||||
// 搜索功能
|
||||
// ============================================================================
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
if (!termWrapRef.current) return false;
|
||||
return termWrapRef.current.search(term, options);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearchNext = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
if (!termWrapRef.current) return false;
|
||||
return termWrapRef.current.searchNext(term, options);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearchPrevious = useCallback(
|
||||
(term: string, options: import("@xterm/addon-search").ISearchOptions) => {
|
||||
if (!termWrapRef.current) return false;
|
||||
return termWrapRef.current.searchPrevious(term, options);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
termWrapRef.current?.clearSearch();
|
||||
}, []);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
handleClearSearch();
|
||||
onSearchClose?.();
|
||||
}, [handleClearSearch, onSearchClose]);
|
||||
|
||||
// ============================================================================
|
||||
// 上下文菜单
|
||||
// _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
|
||||
// ============================================================================
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleContextMenuClose = useCallback(() => {
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const selection = termWrapRef.current?.terminal.getSelection();
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && termWrapRef.current) {
|
||||
const base64 = encodeBase64(text);
|
||||
await writeToTerminalRaw(blockId, base64);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[TerminalView] 粘贴失败:", err);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [blockId]);
|
||||
|
||||
const getSelectedText = useCallback(() => {
|
||||
return termWrapRef.current?.terminal.getSelection() ?? "";
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// 重连功能
|
||||
// _Requirements: 7.4, 7.5_
|
||||
// ============================================================================
|
||||
|
||||
const handleReconnect = useCallback(async () => {
|
||||
// TODO: 实现重连逻辑,调用后端 resync_controller
|
||||
console.log("[TerminalView] 重连请求:", blockId);
|
||||
}, [blockId]);
|
||||
|
||||
// ============================================================================
|
||||
// 模式切换处理
|
||||
// _Requirements: 14.1, 14.2_
|
||||
// ============================================================================
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TermMode) => {
|
||||
setTermMode({ blockId, mode });
|
||||
onModeChange?.(mode);
|
||||
},
|
||||
[blockId, setTermMode, onModeChange],
|
||||
);
|
||||
|
||||
const handleSwitchToTerminal = useCallback(() => {
|
||||
handleModeChange("term");
|
||||
// 聚焦终端
|
||||
setTimeout(() => termWrapRef.current?.focus(), 10);
|
||||
}, [handleModeChange]);
|
||||
|
||||
// ============================================================================
|
||||
// 聚焦处理
|
||||
// ============================================================================
|
||||
|
||||
const handleContainerClick = useCallback(() => {
|
||||
termWrapRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// 渲染
|
||||
// ============================================================================
|
||||
|
||||
// VDOM 模式渲染
|
||||
// _Requirements: 14.1, 14.2_
|
||||
if (termMode === "vdom") {
|
||||
return (
|
||||
<div className="terminal-view">
|
||||
<VDomView
|
||||
blockId={blockId}
|
||||
tabId={tabId}
|
||||
onSwitchToTerminal={handleSwitchToTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 终端模式渲染
|
||||
return (
|
||||
<div className="terminal-view" onContextMenu={handleContextMenu}>
|
||||
{/* 连接状态指示器
|
||||
* _Requirements: 7.3, 7.4, 7.5_
|
||||
*/}
|
||||
<ConnectionStatusIndicator
|
||||
connStatus={connStatus}
|
||||
shellProcStatus={shellProcStatus}
|
||||
exitCode={exitCode}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
|
||||
{/* 多输入模式指示器
|
||||
* _Requirements: 10.3, 10.4_
|
||||
*/}
|
||||
{multiInputEnabled && (
|
||||
<MultiInputIndicator onToggle={onMultiInputToggle} />
|
||||
)}
|
||||
|
||||
{/* 模式切换按钮
|
||||
* _Requirements: 14.2_
|
||||
*/}
|
||||
{showModeSwitch && (
|
||||
<div className="terminal-mode-switch-container">
|
||||
<VDomModeToggle blockId={blockId} onModeChange={handleModeChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索栏 */}
|
||||
{showSearch && (
|
||||
<TerminalSearch
|
||||
visible={showSearch}
|
||||
onClose={handleSearchClose}
|
||||
onSearch={handleSearch}
|
||||
onSearchNext={handleSearchNext}
|
||||
onSearchPrevious={handleSearchPrevious}
|
||||
onClearSearch={handleClearSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 终端容器 */}
|
||||
<div
|
||||
ref={connectElemRef}
|
||||
className="term-connectelem"
|
||||
onClick={handleContainerClick}
|
||||
/>
|
||||
|
||||
{/* 贴纸层
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/}
|
||||
{showStickers && (
|
||||
<StickerLayer blockId={blockId} terminalRef={connectElemRef} />
|
||||
)}
|
||||
|
||||
{/* 上下文菜单
|
||||
* _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
|
||||
*/}
|
||||
{contextMenu && (
|
||||
<TerminalContextMenu
|
||||
position={contextMenu}
|
||||
onClose={handleContextMenuClose}
|
||||
onCopy={handleCopy}
|
||||
onPaste={handlePaste}
|
||||
selectedText={getSelectedText()}
|
||||
blockId={blockId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalView;
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @file VDomModeSwitch.tsx
|
||||
* @description VDOM 模式切换组件
|
||||
* @module components/terminal/VDomModeSwitch
|
||||
*
|
||||
* 提供终端模式(term/vdom)切换的 UI 组件。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2_
|
||||
*/
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
termModeAtomFamily,
|
||||
setTermModeAtom,
|
||||
type TermMode,
|
||||
} from "@/lib/terminal/store";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface VDomModeSwitchProps {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 是否显示标签 */
|
||||
showLabel?: boolean;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 模式变更回调 */
|
||||
onModeChange?: (mode: TermMode) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const TerminalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VDomIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// VDomModeSwitch 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 模式切换组件
|
||||
*
|
||||
* 提供终端模式和 VDOM 模式之间的切换。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2_
|
||||
*/
|
||||
export const VDomModeSwitch: React.FC<VDomModeSwitchProps> = ({
|
||||
blockId,
|
||||
showLabel = true,
|
||||
disabled = false,
|
||||
onModeChange,
|
||||
className = "",
|
||||
}) => {
|
||||
// 读取当前模式
|
||||
const termMode = useAtomValue(termModeAtomFamily(blockId));
|
||||
const setTermMode = useSetAtom(setTermModeAtom);
|
||||
|
||||
// 切换模式
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: TermMode) => {
|
||||
if (disabled || newMode === termMode) return;
|
||||
|
||||
setTermMode({ blockId, mode: newMode });
|
||||
onModeChange?.(newMode);
|
||||
},
|
||||
[blockId, termMode, disabled, setTermMode, onModeChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`vdom-mode-switch ${className}`}>
|
||||
{/* 终端模式按钮 */}
|
||||
<button
|
||||
className={`vdom-mode-btn ${termMode === "term" ? "active" : ""}`}
|
||||
onClick={() => handleModeChange("term")}
|
||||
disabled={disabled}
|
||||
title="终端模式"
|
||||
>
|
||||
<TerminalIcon className="vdom-mode-icon" />
|
||||
{showLabel && <span className="vdom-mode-label">终端</span>}
|
||||
</button>
|
||||
|
||||
{/* VDOM 模式按钮 */}
|
||||
<button
|
||||
className={`vdom-mode-btn ${termMode === "vdom" ? "active" : ""}`}
|
||||
onClick={() => handleModeChange("vdom")}
|
||||
disabled={disabled}
|
||||
title="VDOM 模式"
|
||||
>
|
||||
<VDomIcon className="vdom-mode-icon" />
|
||||
{showLabel && <span className="vdom-mode-label">VDOM</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 紧凑版模式切换
|
||||
// ============================================================================
|
||||
|
||||
export interface VDomModeToggleProps {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 模式变更回调 */
|
||||
onModeChange?: (mode: TermMode) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧凑版 VDOM 模式切换
|
||||
*
|
||||
* 单按钮切换,适合工具栏使用。
|
||||
*/
|
||||
export const VDomModeToggle: React.FC<VDomModeToggleProps> = ({
|
||||
blockId,
|
||||
disabled = false,
|
||||
onModeChange,
|
||||
className = "",
|
||||
}) => {
|
||||
const termMode = useAtomValue(termModeAtomFamily(blockId));
|
||||
const setTermMode = useSetAtom(setTermModeAtom);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
const newMode: TermMode = termMode === "term" ? "vdom" : "term";
|
||||
setTermMode({ blockId, mode: newMode });
|
||||
onModeChange?.(newMode);
|
||||
}, [blockId, termMode, disabled, setTermMode, onModeChange]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`vdom-mode-toggle ${termMode === "vdom" ? "active" : ""} ${className}`}
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
title={termMode === "term" ? "切换到 VDOM 模式" : "切换到终端模式"}
|
||||
>
|
||||
{termMode === "term" ? (
|
||||
<VDomIcon className="vdom-mode-icon" />
|
||||
) : (
|
||||
<TerminalIcon className="vdom-mode-icon" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VDomModeSwitch;
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @file VDomView.tsx
|
||||
* @description VDOM 视图组件
|
||||
* @module components/terminal/VDomView
|
||||
*
|
||||
* 在 VDOM 模式下渲染终端内嵌的 UI 块。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { SubBlockContainer } from "./SubBlock";
|
||||
import { VDomModeToggle } from "./VDomModeSwitch";
|
||||
import {
|
||||
type VDomContext,
|
||||
type VDomEvent,
|
||||
vdomBlocksAtomFamily,
|
||||
vdomToolbarAtomFamily,
|
||||
removeVDomBlockAtom,
|
||||
cleanupVDomStateAtom,
|
||||
} from "@/lib/terminal/vdom";
|
||||
import { setTermModeAtom } from "@/lib/terminal/store";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface VDomViewProps {
|
||||
/** 终端块 ID */
|
||||
blockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** 切换回终端模式的回调 */
|
||||
onSwitchToTerminal?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图标组件
|
||||
// ============================================================================
|
||||
|
||||
const TerminalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// VDomView 组件
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 视图组件
|
||||
*
|
||||
* 在 VDOM 模式下渲染终端内嵌的 UI 块。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
*/
|
||||
export const VDomView: React.FC<VDomViewProps> = ({
|
||||
blockId,
|
||||
tabId,
|
||||
onSwitchToTerminal,
|
||||
className = "",
|
||||
}) => {
|
||||
// 读取 VDOM 状态
|
||||
const blocks = useAtomValue(vdomBlocksAtomFamily(blockId));
|
||||
const toolbar = useAtomValue(vdomToolbarAtomFamily(blockId));
|
||||
|
||||
// 操作原子
|
||||
const setTermMode = useSetAtom(setTermModeAtom);
|
||||
const removeBlock = useSetAtom(removeVDomBlockAtom);
|
||||
const _cleanupVDom = useSetAtom(cleanupVDomStateAtom);
|
||||
|
||||
// 切换回终端模式
|
||||
// _Requirements: 14.5_
|
||||
const handleSwitchToTerminal = useCallback(() => {
|
||||
setTermMode({ blockId, mode: "term" });
|
||||
onSwitchToTerminal?.();
|
||||
}, [blockId, setTermMode, onSwitchToTerminal]);
|
||||
|
||||
// 关闭 VDOM 块
|
||||
const handleCloseBlock = useCallback(
|
||||
(vdomBlockId: string) => {
|
||||
removeBlock({ terminalBlockId: blockId, blockId: vdomBlockId });
|
||||
|
||||
// 如果没有更多块,自动切换回终端模式
|
||||
// _Requirements: 14.5_
|
||||
if (blocks.length <= 1) {
|
||||
handleSwitchToTerminal();
|
||||
}
|
||||
},
|
||||
[blockId, blocks.length, removeBlock, handleSwitchToTerminal],
|
||||
);
|
||||
|
||||
// 发送 VDOM 事件
|
||||
const handleSendEvent = useCallback((event: VDomEvent) => {
|
||||
console.log("[VDomView] 事件:", event);
|
||||
// TODO: 发送事件到后端或处理本地事件
|
||||
}, []);
|
||||
|
||||
// 创建 VDOM 上下文
|
||||
const context: VDomContext = useMemo(
|
||||
() => ({
|
||||
terminalBlockId: blockId,
|
||||
tabId,
|
||||
termMode: "vdom",
|
||||
sendEvent: handleSendEvent,
|
||||
switchToTerminal: handleSwitchToTerminal,
|
||||
closeBlock: handleCloseBlock,
|
||||
}),
|
||||
[blockId, tabId, handleSendEvent, handleSwitchToTerminal, handleCloseBlock],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`vdom-view ${className}`}>
|
||||
{/* VDOM 工具栏 */}
|
||||
{toolbar && toolbar.visible && toolbar.position === "top" && (
|
||||
<div className="vdom-toolbar vdom-toolbar-top">
|
||||
{toolbar.items.map((item) => (
|
||||
<div key={item.id} className="vdom-toolbar-item">
|
||||
{item.type === "separator" ? (
|
||||
<div className="vdom-toolbar-separator" />
|
||||
) : (
|
||||
<button
|
||||
className="vdom-toolbar-btn"
|
||||
disabled={item.disabled}
|
||||
title={item.tooltip}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VDOM 内容区域 */}
|
||||
<div className="vdom-content">
|
||||
{blocks.length > 0 ? (
|
||||
<SubBlockContainer
|
||||
blocks={blocks}
|
||||
terminalBlockId={blockId}
|
||||
tabId={tabId}
|
||||
context={context}
|
||||
/>
|
||||
) : (
|
||||
<div className="vdom-empty">
|
||||
<p className="vdom-empty-text">没有 VDOM 块</p>
|
||||
<button className="vdom-empty-btn" onClick={handleSwitchToTerminal}>
|
||||
<TerminalIcon className="vdom-empty-icon" />
|
||||
<span>返回终端</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部工具栏 */}
|
||||
{toolbar && toolbar.visible && toolbar.position === "bottom" && (
|
||||
<div className="vdom-toolbar vdom-toolbar-bottom">
|
||||
{toolbar.items.map((item) => (
|
||||
<div key={item.id} className="vdom-toolbar-item">
|
||||
{item.type === "separator" ? (
|
||||
<div className="vdom-toolbar-separator" />
|
||||
) : (
|
||||
<button
|
||||
className="vdom-toolbar-btn"
|
||||
disabled={item.disabled}
|
||||
title={item.tooltip}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模式切换按钮(固定在右上角) */}
|
||||
<div className="vdom-mode-switch-container">
|
||||
<VDomModeToggle
|
||||
blockId={blockId}
|
||||
onModeChange={(mode) => {
|
||||
if (mode === "term") {
|
||||
onSwitchToTerminal?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VDomView;
|
||||
@@ -15,6 +15,15 @@ interface ITerminalDimensions {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
interface IRenderDimensions {
|
||||
css: {
|
||||
cell: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const MINIMUM_COLS = 2;
|
||||
const MINIMUM_ROWS = 1;
|
||||
|
||||
@@ -38,12 +47,7 @@ export class FitAddon implements ITerminalAddon {
|
||||
// 访问 xterm 内部 API
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
// 安全检查:确保内部 API 可用
|
||||
if (!core || !core._renderService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 关键:如果大小变化,先清除渲染再 resize
|
||||
// 强制完整重新渲染
|
||||
if (
|
||||
this._terminal.rows !== dims.rows ||
|
||||
this._terminal.cols !== dims.cols
|
||||
@@ -64,19 +68,14 @@ export class FitAddon implements ITerminalAddon {
|
||||
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
if (!core || !core._renderService) {
|
||||
if (!core._renderService) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dims = core._renderService.dimensions;
|
||||
const dims: IRenderDimensions = core._renderService.dimensions;
|
||||
|
||||
if (
|
||||
!dims ||
|
||||
!dims.css ||
|
||||
!dims.css.cell ||
|
||||
dims.css.cell.width === 0 ||
|
||||
dims.css.cell.height === 0
|
||||
) {
|
||||
// 检查字体是否已加载
|
||||
if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -97,34 +96,38 @@ export class FitAddon implements ITerminalAddon {
|
||||
scrollbarWidth = 0;
|
||||
}
|
||||
|
||||
// Use getBoundingClientRect for more accurate measurement including fractional pixels
|
||||
const parentRect =
|
||||
this._terminal.element.parentElement.getBoundingClientRect();
|
||||
const parentElementHeight = parentRect.height;
|
||||
const parentElementWidth = parentRect.width;
|
||||
const parentElementStyle = window.getComputedStyle(
|
||||
this._terminal.element.parentElement,
|
||||
);
|
||||
const parentElementHeight = parseInt(
|
||||
parentElementStyle.getPropertyValue("height"),
|
||||
);
|
||||
const parentElementWidth = Math.max(
|
||||
0,
|
||||
parseInt(parentElementStyle.getPropertyValue("width")),
|
||||
);
|
||||
|
||||
console.log("[FitAddon Debug] Measuring parent (Rect):", {
|
||||
width: parentElementWidth,
|
||||
height: parentElementHeight,
|
||||
top: parentRect.top,
|
||||
bottom: parentRect.bottom,
|
||||
});
|
||||
// 安全检查:如果父元素高度为 0 或 NaN,说明布局还没完成
|
||||
if (
|
||||
!parentElementHeight ||
|
||||
parentElementHeight <= 0 ||
|
||||
isNaN(parentElementHeight)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const elementPadding = {
|
||||
top: parseInt(elementStyle.getPropertyValue("padding-top")) || 0,
|
||||
bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")) || 0,
|
||||
right: parseInt(elementStyle.getPropertyValue("padding-right")) || 0,
|
||||
left: parseInt(elementStyle.getPropertyValue("padding-left")) || 0,
|
||||
top: parseInt(elementStyle.getPropertyValue("padding-top")),
|
||||
bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")),
|
||||
right: parseInt(elementStyle.getPropertyValue("padding-right")),
|
||||
left: parseInt(elementStyle.getPropertyValue("padding-left")),
|
||||
};
|
||||
|
||||
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
|
||||
const elementPaddingHor = elementPadding.right + elementPadding.left;
|
||||
|
||||
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||
const availableWidth =
|
||||
parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||
|
||||
const geometry = {
|
||||
cols: Math.max(
|
||||
MINIMUM_COLS,
|
||||
@@ -135,17 +138,6 @@ export class FitAddon implements ITerminalAddon {
|
||||
Math.floor(availableHeight / dims.css.cell.height),
|
||||
),
|
||||
};
|
||||
|
||||
console.log("[FitAddon] proposeDimensions:", {
|
||||
parentElementHeight,
|
||||
parentElementWidth,
|
||||
availableHeight,
|
||||
availableWidth,
|
||||
cellHeight: dims.css.cell.height,
|
||||
cellWidth: dims.css.cell.width,
|
||||
geometry,
|
||||
});
|
||||
|
||||
return geometry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
* @file index.ts
|
||||
* @description 终端组件导出
|
||||
* @module components/terminal
|
||||
*
|
||||
* _Requirements: 8.1, 9.7, 14.1, 14.2, 14.3, 14.4, 14.5, 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
export { TerminalPage } from "./TerminalPage";
|
||||
export { TerminalView } from "./TerminalView";
|
||||
export { TerminalSearch } from "./TerminalSearch";
|
||||
export { TerminalContextMenu } from "./TerminalContextMenu";
|
||||
export { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";
|
||||
export { MultiInputIndicator } from "./MultiInputIndicator";
|
||||
export { TermWrap } from "./termwrap";
|
||||
|
||||
// VDOM 组件
|
||||
// _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
export { VDomModeSwitch, VDomModeToggle } from "./VDomModeSwitch";
|
||||
export { VDomView } from "./VDomView";
|
||||
export {
|
||||
SubBlock,
|
||||
SubBlockContainer,
|
||||
registerVDomComponent,
|
||||
unregisterVDomComponent,
|
||||
} from "./SubBlock";
|
||||
|
||||
// 贴纸组件
|
||||
// _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
export { Sticker } from "./Sticker";
|
||||
export { StickerLayer } from "./StickerLayer";
|
||||
|
||||
@@ -29,8 +29,18 @@
|
||||
background-color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
/* 终端容器样式 - 完全对齐 waveterm 的 .term-connectelem */
|
||||
.terminal-container {
|
||||
/* 主视图容器 - 对齐 waveterm 的 .view-term */
|
||||
.view-term {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 终端连接容器 - 对齐 waveterm 的 .term-connectelem */
|
||||
.term-connectelem {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -39,39 +49,37 @@
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 隐藏的终端 - 使用 visibility 而不是 display:none,保持布局稳定 */
|
||||
.terminal-container.terminal-hidden {
|
||||
visibility: hidden;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
/* 关键:xterm 容器必须填满父元素 */
|
||||
.term-connectelem .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
/* xterm viewport 滚动条样式 - 对齐 waveterm */
|
||||
.term-connectelem .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
background-color: var(--terminal-bg) !important;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
.term-connectelem .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
|
||||
.term-connectelem .xterm-viewport::-webkit-scrollbar-track {
|
||||
background-color: var(--terminal-scrollbar-bg);
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
.term-connectelem .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
background-color: var(--terminal-scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
.term-connectelem .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--terminal-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
.terminal-container:hover .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
.term-connectelem:hover .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -373,6 +381,13 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.terminal-search-count {
|
||||
font-size: 11px;
|
||||
color: var(--terminal-muted);
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 搜索选项按钮 */
|
||||
.terminal-search-options {
|
||||
display: flex;
|
||||
@@ -526,3 +541,811 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 字体大小控制样式
|
||||
* _Requirements: 8.8_
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-font-size-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-font-size-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-font-size-btn:hover:not(:disabled) {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-font-size-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.terminal-font-size-value {
|
||||
font-size: 11px;
|
||||
color: var(--terminal-muted);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* TerminalView 组件样式
|
||||
* _Requirements: 9.7_
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 连接状态指示器样式
|
||||
* _Requirements: 7.3, 7.4, 7.5_
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
font-size: 13px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.terminal-reconnect-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--terminal-fg);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.terminal-reconnect-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 多输入模式指示器样式
|
||||
* _Requirements: 10.3, 10.4
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-multi-input-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.terminal-multi-input-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-bg);
|
||||
background-color: var(--terminal-accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 2px 8px rgba(122, 162, 247, 0.3);
|
||||
}
|
||||
|
||||
.terminal-multi-input-btn:hover {
|
||||
background-color: #89b4fa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(122, 162, 247, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 上下文菜单样式
|
||||
* _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-context-menu {
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.terminal-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--terminal-fg);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.terminal-context-menu-item:hover:not(.disabled) {
|
||||
background-color: var(--terminal-tab-hover-bg);
|
||||
}
|
||||
|
||||
.terminal-context-menu-item.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.terminal-context-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.terminal-context-menu-item:hover:not(.disabled) .terminal-context-menu-icon {
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.terminal-context-menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-context-menu-shortcut {
|
||||
font-size: 11px;
|
||||
color: var(--terminal-muted);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.terminal-context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: var(--terminal-border);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* VDOM 模式切换样式
|
||||
* _Requirements: 14.1, 14.2_
|
||||
* ============================================================================ */
|
||||
|
||||
.vdom-mode-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vdom-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.vdom-mode-btn:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.vdom-mode-btn.active {
|
||||
background-color: var(--terminal-accent);
|
||||
color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
.vdom-mode-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vdom-mode-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vdom-mode-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 紧凑版切换按钮 */
|
||||
.vdom-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--terminal-muted);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.vdom-mode-toggle:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--terminal-fg);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.vdom-mode-toggle.active {
|
||||
background-color: var(--terminal-accent);
|
||||
color: var(--terminal-bg);
|
||||
border-color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
.vdom-mode-toggle:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* VDOM 视图样式
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
* ============================================================================ */
|
||||
|
||||
.vdom-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--terminal-bg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vdom-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* VDOM 空状态 */
|
||||
.vdom-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.vdom-empty-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vdom-empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--terminal-fg);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.vdom-empty-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.vdom-empty-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* VDOM 工具栏 */
|
||||
.vdom-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border-color: var(--terminal-border);
|
||||
}
|
||||
|
||||
.vdom-toolbar-top {
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.vdom-toolbar-bottom {
|
||||
border-top: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.vdom-toolbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vdom-toolbar-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--terminal-fg);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.vdom-toolbar-btn:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.vdom-toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vdom-toolbar-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: var(--terminal-border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 模式切换容器(固定位置) */
|
||||
.vdom-mode-switch-container {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* SubBlock 子块样式
|
||||
* _Requirements: 14.3, 14.4, 14.5_
|
||||
* ============================================================================ */
|
||||
|
||||
.subblock-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subblock {
|
||||
background-color: var(--terminal-tab-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.subblock:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subblock.focused {
|
||||
border-color: var(--terminal-accent);
|
||||
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2);
|
||||
}
|
||||
|
||||
/* 子块头部 */
|
||||
.subblock-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.subblock-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.subblock-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.subblock-close-btn:hover {
|
||||
background-color: rgba(248, 81, 73, 0.2);
|
||||
color: var(--terminal-error);
|
||||
}
|
||||
|
||||
.subblock-close-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* 子块内容 */
|
||||
.subblock-content {
|
||||
padding: 12px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.subblock-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.subblock-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--terminal-border);
|
||||
border-top-color: var(--terminal-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.subblock-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--terminal-error);
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.subblock-error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 占位符 */
|
||||
.subblock-placeholder {
|
||||
padding: 12px;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subblock-placeholder p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 终端模式切换容器样式
|
||||
* _Requirements: 14.2_
|
||||
* ============================================================================ */
|
||||
|
||||
.terminal-mode-switch-container {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* SubBlock 增强样式
|
||||
* _Requirements: 14.3, 14.4, 14.5_
|
||||
* ============================================================================ */
|
||||
|
||||
/* 焦点陷阱状态 */
|
||||
.subblock.focus-trapped {
|
||||
border-color: var(--terminal-warning);
|
||||
box-shadow: 0 0 0 2px rgba(224, 175, 104, 0.3);
|
||||
}
|
||||
|
||||
/* 头部操作区域 */
|
||||
.subblock-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 焦点锁定指示器 */
|
||||
.subblock-focus-indicator {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 块索引指示器 */
|
||||
.subblock-index {
|
||||
font-size: 11px;
|
||||
color: var(--terminal-muted);
|
||||
padding: 2px 6px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 信息卡片组件样式 */
|
||||
.subblock-info-card {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.subblock-info-card h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-fg);
|
||||
}
|
||||
|
||||
.subblock-info-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--terminal-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 按钮组组件样式 */
|
||||
.subblock-button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.subblock-action-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--terminal-fg);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.subblock-action-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.subblock-action-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--terminal-accent);
|
||||
}
|
||||
|
||||
/* 键盘导航提示 */
|
||||
.subblock[data-block-index]::after {
|
||||
content: attr(data-block-index);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-bg);
|
||||
background-color: var(--terminal-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.subblock.focused[data-block-index]::after {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 终端贴纸系统样式
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
* ============================================================================ */
|
||||
|
||||
/* 贴纸层容器 */
|
||||
.terminal-sticker-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* 单个贴纸 */
|
||||
.terminal-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-style: solid;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.terminal-sticker.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.terminal-sticker.draggable:active,
|
||||
.terminal-sticker.dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 贴纸内容 */
|
||||
.sticker-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 贴纸文本 */
|
||||
.sticker-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 贴纸徽章 */
|
||||
.sticker-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 贴纸自定义内容 */
|
||||
.sticker-custom {
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 贴纸关闭按钮 */
|
||||
.sticker-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-sticker:hover .sticker-close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sticker-close-btn:hover {
|
||||
background-color: rgba(248, 81, 73, 0.8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 贴纸动画 */
|
||||
@keyframes sticker-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-sticker {
|
||||
animation: sticker-appear 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* 贴纸变体样式 */
|
||||
.terminal-sticker.variant-success {
|
||||
background-color: rgba(158, 206, 106, 0.9);
|
||||
color: #1a1b26;
|
||||
}
|
||||
|
||||
.terminal-sticker.variant-warning {
|
||||
background-color: rgba(224, 175, 104, 0.9);
|
||||
color: #1a1b26;
|
||||
}
|
||||
|
||||
.terminal-sticker.variant-error {
|
||||
background-color: rgba(247, 118, 142, 0.9);
|
||||
color: #1a1b26;
|
||||
}
|
||||
|
||||
.terminal-sticker.variant-info {
|
||||
background-color: rgba(125, 207, 255, 0.9);
|
||||
color: #1a1b26;
|
||||
}
|
||||
|
||||
/* 贴纸工具提示 */
|
||||
.terminal-sticker[title]:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--terminal-tab-bg);
|
||||
color: var(--terminal-fg);
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
/**
|
||||
* @file termwrap.ts
|
||||
* @description 终端封装类 - 连接模式
|
||||
* @description 终端封装类 - 对齐 waveterm 架构
|
||||
* @module components/terminal/termwrap
|
||||
*
|
||||
* 封装 xterm.js 终端实例,管理终端生命周期、大小同步、输入输出。
|
||||
*
|
||||
* ## 架构说明
|
||||
* PTY 在后端预创建,TermWrap 只负责"连接"到已存在的会话。
|
||||
* 构造函数接收 sessionId(必须),立即设置事件监听和输入处理。
|
||||
* resize 只负责同步大小到后端,不触发创建。
|
||||
* ## 架构说明(对齐 waveterm)
|
||||
* 1. 构造函数中:创建终端、加载插件、open、handleResize
|
||||
* 2. initTerminal() 异步方法:设置事件监听、连接到后端
|
||||
* 3. loaded 标志:在 initTerminal 完成后设为 true
|
||||
*
|
||||
* ## 功能特性
|
||||
* - 搜索功能:集成 @xterm/addon-search
|
||||
* - 主题系统:支持多种终端主题切换
|
||||
* - Web 链接:自动识别并可点击 URL
|
||||
* - WebGL 渲染(可配置,提升性能)
|
||||
* - Unicode 11 宽字符支持
|
||||
* - FitAddon 自适应大小
|
||||
* - 搜索功能
|
||||
* - 主题切换
|
||||
* - IME 输入法支持
|
||||
*
|
||||
* _Requirements: 8.1, 8.2, 8.4, 8.5_
|
||||
*/
|
||||
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { SearchAddon, type ISearchOptions } from "@xterm/addon-search";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { FitAddon } from "./fitaddon";
|
||||
import {
|
||||
resizeTerminal,
|
||||
@@ -35,10 +42,10 @@ import {
|
||||
loadThemePreference,
|
||||
} from "@/lib/terminal/themes";
|
||||
|
||||
/** 简单的 debounce 实现 */
|
||||
/** 简单的 debounce 实现(对齐 waveterm 参数顺序) */
|
||||
function debounce<T extends (...args: unknown[]) => void>(
|
||||
fn: T,
|
||||
delay: number,
|
||||
fn: T,
|
||||
): T {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
return ((...args: unknown[]) => {
|
||||
@@ -57,6 +64,21 @@ export interface TermWrapOptions {
|
||||
themeName?: ThemeName;
|
||||
/** 状态变化回调 */
|
||||
onStatusChange?: (status: SessionStatus) => void;
|
||||
/** 是否启用 WebGL 渲染(默认 true)
|
||||
* _Requirements: 8.2_
|
||||
*/
|
||||
webglEnabled?: boolean;
|
||||
/** 括号粘贴模式(默认 true)
|
||||
* _Requirements: 8.9_
|
||||
*/
|
||||
bracketedPasteMode?: boolean;
|
||||
/** 滚动回滚行数(默认 5000) */
|
||||
scrollback?: number;
|
||||
/** 键盘事件处理器(对齐 waveterm)
|
||||
* 返回 true 表示事件已处理,阻止默认行为
|
||||
* 返回 false 表示事件未处理,继续传递
|
||||
*/
|
||||
keydownHandler?: (e: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
/** 搜索结果回调 */
|
||||
@@ -69,15 +91,9 @@ export interface SearchCallbacks {
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端封装类 - 连接模式
|
||||
* 终端封装类 - 对齐 waveterm 架构
|
||||
*
|
||||
* PTY 在后端预创建,TermWrap 只负责"连接"到已存在的会话。
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 先调用 createTerminalSession() 获取 sessionId
|
||||
* 2. 创建 TermWrap 实例,传入 sessionId
|
||||
* 3. TermWrap 自动连接并设置事件监听
|
||||
* 4. 首次 fit 后自动同步实际大小到后端
|
||||
* _Requirements: 8.1, 8.2, 8.4, 8.5_
|
||||
*/
|
||||
export class TermWrap {
|
||||
/** 会话 ID(必须) */
|
||||
@@ -90,9 +106,21 @@ export class TermWrap {
|
||||
fitAddon: FitAddon;
|
||||
/** SearchAddon 实例 */
|
||||
searchAddon: SearchAddon;
|
||||
/** 是否已连接(事件监听已设置) */
|
||||
connected: boolean = false;
|
||||
/** 防抖的 resize 处理函数 */
|
||||
/** WebglAddon 实例(可选)
|
||||
* _Requirements: 8.2_
|
||||
*/
|
||||
webglAddon: WebglAddon | null = null;
|
||||
/** Unicode11Addon 实例
|
||||
* _Requirements: 8.4_
|
||||
*/
|
||||
unicode11Addon: Unicode11Addon;
|
||||
/** 是否已加载(对齐 waveterm) */
|
||||
loaded: boolean = false;
|
||||
/** 是否已 resize 过(对齐 waveterm) */
|
||||
hasResized: boolean = false;
|
||||
/** 防抖的 resize 处理函数
|
||||
* _Requirements: 8.6_
|
||||
*/
|
||||
handleResize_debounced: () => void;
|
||||
/** 配置选项 */
|
||||
private options: TermWrapOptions;
|
||||
@@ -105,15 +133,24 @@ export class TermWrap {
|
||||
/** 事件监听器清理函数 */
|
||||
private unlistenOutput?: () => void;
|
||||
private unlistenStatus?: () => void;
|
||||
/** 上次同步到后端的大小 */
|
||||
private lastSyncedSize: { rows: number; cols: number } | null = null;
|
||||
/** WebGL 是否启用 */
|
||||
private webglEnabled: boolean;
|
||||
/** IME 组合状态
|
||||
* _Requirements: 8.11_
|
||||
*/
|
||||
private isComposing: boolean = false;
|
||||
/** 写入队列(用于批量写入减少闪烁) */
|
||||
private writeQueue: Uint8Array[] = [];
|
||||
/** 写入定时器 */
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 批量写入延迟(毫秒) */
|
||||
private readonly WRITE_BATCH_DELAY = 8; // ~120fps,平衡响应性和流畅性
|
||||
|
||||
/**
|
||||
* 创建终端封装实例
|
||||
* 创建终端封装实例(对齐 waveterm 构造函数)
|
||||
*
|
||||
* @param sessionId - 已创建的会话 ID(必须)
|
||||
* @param connectElem - 要挂载终端的 DOM 元素
|
||||
* @param options - 配置选项
|
||||
* 构造函数中:创建终端、加载插件、open、handleResize
|
||||
* 不在构造函数中连接到后端,由 initTerminal() 完成
|
||||
*/
|
||||
constructor(
|
||||
sessionId: string,
|
||||
@@ -123,12 +160,14 @@ export class TermWrap {
|
||||
this.sessionId = sessionId;
|
||||
this.connectElem = connectElem;
|
||||
this.options = options;
|
||||
this.webglEnabled = options.webglEnabled ?? true;
|
||||
|
||||
// 加载主题
|
||||
this.currentTheme = options.themeName ?? loadThemePreference();
|
||||
const theme = getTheme(this.currentTheme);
|
||||
|
||||
// 创建终端实例
|
||||
// _Requirements: 8.1_
|
||||
this.terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: options.fontSize ?? 14,
|
||||
@@ -137,124 +176,282 @@ export class TermWrap {
|
||||
theme,
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
scrollback: 5000,
|
||||
scrollback: options.scrollback ?? 5000,
|
||||
drawBoldTextInBrightColors: false,
|
||||
fontWeight: "normal",
|
||||
fontWeightBold: "bold",
|
||||
// 括号粘贴模式配置
|
||||
// _Requirements: 8.9_
|
||||
});
|
||||
|
||||
// 加载插件
|
||||
// 加载 FitAddon
|
||||
// _Requirements: 8.5_
|
||||
this.fitAddon = new FitAddon();
|
||||
// macOS 上禁用滚动条宽度计算(参考 waveterm)
|
||||
// macOS 上禁用滚动条宽度计算(对齐 waveterm)
|
||||
const isMac = /mac/i.test(navigator.userAgent);
|
||||
this.fitAddon.noScrollbar = isMac;
|
||||
|
||||
// 加载 SearchAddon
|
||||
// _Requirements: 8.3_
|
||||
this.searchAddon = new SearchAddon();
|
||||
|
||||
// 加载 Unicode11Addon(宽字符支持)
|
||||
// _Requirements: 8.4_
|
||||
this.unicode11Addon = new Unicode11Addon();
|
||||
|
||||
// 加载 WebLinksAddon
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
// 加载插件
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.searchAddon);
|
||||
this.terminal.loadAddon(this.unicode11Addon);
|
||||
this.terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// 激活 Unicode 11 支持
|
||||
// _Requirements: 8.4_
|
||||
this.terminal.unicode.activeVersion = "11";
|
||||
|
||||
// 打开终端
|
||||
this.terminal.open(this.connectElem);
|
||||
|
||||
// 设置防抖的 resize 处理(参考 waveterm 使用 50ms,增加到 100ms 以避免 TUI 初始化时的竞态)
|
||||
this.handleResize_debounced = debounce(this.handleResize.bind(this), 100);
|
||||
// 绑定键盘事件处理器(对齐 waveterm)
|
||||
if (options.keydownHandler) {
|
||||
this.terminal.attachCustomKeyEventHandler(options.keydownHandler);
|
||||
}
|
||||
|
||||
// 立即连接到会话
|
||||
this.connect();
|
||||
// 尝试加载 WebGL 渲染器
|
||||
// _Requirements: 8.2_
|
||||
this.tryLoadWebgl();
|
||||
|
||||
// 参考 waveterm:构造函数中调用 handleResize
|
||||
// 但需要等待 xterm 完全初始化(viewport 需要时间创建)
|
||||
// 设置防抖的 resize 处理(对齐 waveterm 使用 50ms)
|
||||
// _Requirements: 8.6_
|
||||
this.handleResize_debounced = debounce(50, this.handleResize.bind(this));
|
||||
|
||||
// 立即调用一次 resize(对齐 waveterm)
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到会话
|
||||
* 尝试加载 WebGL 渲染器
|
||||
*
|
||||
* 设置输入处理和事件监听。
|
||||
* WebGL 渲染可以显著提升终端性能,但在某些环境下可能不可用。
|
||||
* 如果加载失败,会回退到 Canvas 渲染。
|
||||
*
|
||||
* _Requirements: 8.2_
|
||||
*/
|
||||
private async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
console.log(`[TermWrap] 连接到会话: ${this.sessionId}`);
|
||||
private tryLoadWebgl(): void {
|
||||
if (!this.webglEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置输入处理
|
||||
const onDataDisposable = this.terminal.onData((data) => {
|
||||
const base64 = encodeBase64(data);
|
||||
writeToTerminalRaw(this.sessionId, base64).catch(console.error);
|
||||
});
|
||||
this.toDispose.push(onDataDisposable);
|
||||
this.webglAddon = new WebglAddon();
|
||||
|
||||
// 监听输出
|
||||
// 监听 WebGL 上下文丢失事件
|
||||
this.webglAddon.onContextLoss(() => {
|
||||
console.warn("[TermWrap] WebGL 上下文丢失,回退到 Canvas 渲染");
|
||||
this.disposeWebgl();
|
||||
});
|
||||
|
||||
this.terminal.loadAddon(this.webglAddon);
|
||||
} catch (err) {
|
||||
console.warn("[TermWrap] WebGL 渲染不可用,使用 Canvas 渲染:", err);
|
||||
this.webglAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁 WebGL 渲染器
|
||||
*/
|
||||
private disposeWebgl(): void {
|
||||
if (this.webglAddon) {
|
||||
try {
|
||||
this.webglAddon.dispose();
|
||||
} catch {
|
||||
// ignore dispose errors
|
||||
}
|
||||
this.webglAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化终端(对齐 waveterm 的 initTerminal)
|
||||
*
|
||||
* 异步方法:设置事件监听、连接到后端
|
||||
*/
|
||||
async initTerminal(): Promise<void> {
|
||||
// 首先确保 resize 已同步到后端(关键!)
|
||||
// 这样后端 PTY 的大小与前端一致,避免输出错位
|
||||
this.handleResize();
|
||||
// 等待 resize 命令发送到后端并生效
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 设置输入处理
|
||||
const onDataDisposable = this.terminal.onData((data) => {
|
||||
// 对齐 waveterm:在 loaded 之前不处理输入
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
// IME 组合状态下不发送数据
|
||||
// _Requirements: 8.11_
|
||||
if (this.isComposing) {
|
||||
return;
|
||||
}
|
||||
const base64 = encodeBase64(data);
|
||||
writeToTerminalRaw(this.sessionId, base64).catch(console.error);
|
||||
});
|
||||
this.toDispose.push(onDataDisposable);
|
||||
|
||||
// 设置 IME 组合事件处理
|
||||
// _Requirements: 8.11_
|
||||
this.setupIMEHandlers();
|
||||
|
||||
// 暂存数据队列(对齐 waveterm 的 heldData)
|
||||
const heldData: Uint8Array[] = [];
|
||||
|
||||
try {
|
||||
// 监听输出(使用批量写入减少闪烁)
|
||||
this.unlistenOutput = await onSessionOutput(this.sessionId, (data) => {
|
||||
this.terminal.write(decodeBytes(data));
|
||||
if (!this.loaded) {
|
||||
// 在 loaded 之前暂存数据
|
||||
heldData.push(data);
|
||||
return;
|
||||
}
|
||||
// 使用批量写入
|
||||
this.queueWrite(data);
|
||||
});
|
||||
|
||||
// 监听状态
|
||||
this.unlistenStatus = await onSessionStatus(this.sessionId, (event) => {
|
||||
this.options.onStatusChange?.(event.status);
|
||||
});
|
||||
|
||||
this.connected = true;
|
||||
console.log(`[TermWrap] 已连接到会话: ${this.sessionId}`);
|
||||
} catch (err) {
|
||||
console.error("[TermWrap] 连接失败:", err);
|
||||
this.options.onStatusChange?.("error");
|
||||
}
|
||||
|
||||
// 标记为已加载(对齐 waveterm)
|
||||
this.loaded = true;
|
||||
|
||||
// 写入暂存的数据
|
||||
if (heldData.length > 0) {
|
||||
for (const data of heldData) {
|
||||
const decoded = decodeBytes(data);
|
||||
this.terminal.write(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端大小变化
|
||||
* 将数据加入写入队列(批量写入减少闪烁)
|
||||
*
|
||||
* 调用 fitAddon.fit() 计算新大小,然后同步到后端。
|
||||
* Claude Code 等应用会发送大量小数据包(如 ESC[2K + ESC[1A 组合),
|
||||
* 如果每个包都立即写入会导致闪烁。通过批量写入,将短时间内的多个
|
||||
* 数据包合并后一次性写入,减少渲染次数。
|
||||
*/
|
||||
handleResize(): void {
|
||||
// 安全检查:确保终端已初始化
|
||||
if (!this.terminal || !this.terminal.element) {
|
||||
console.log("[TermWrap] handleResize: 终端未初始化,跳过");
|
||||
private queueWrite(data: Uint8Array): void {
|
||||
this.writeQueue.push(data);
|
||||
|
||||
// 如果已有定时器,等待批量处理
|
||||
if (this.writeTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置定时器,延迟后批量写入
|
||||
this.writeTimer = setTimeout(() => {
|
||||
this.flushWriteQueue();
|
||||
}, this.WRITE_BATCH_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新写入队列,将所有数据合并后写入终端
|
||||
*/
|
||||
private flushWriteQueue(): void {
|
||||
this.writeTimer = null;
|
||||
|
||||
if (this.writeQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并所有数据
|
||||
const totalLength = this.writeQueue.reduce(
|
||||
(sum, arr) => sum + arr.length,
|
||||
0,
|
||||
);
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of this.writeQueue) {
|
||||
merged.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
this.writeQueue = [];
|
||||
|
||||
// 一次性写入
|
||||
const decoded = decodeBytes(merged);
|
||||
this.terminal.write(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 IME 输入法事件处理
|
||||
*
|
||||
* _Requirements: 8.11_
|
||||
*/
|
||||
private setupIMEHandlers(): void {
|
||||
const textarea = this.connectElem.querySelector(
|
||||
".xterm-helper-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (!textarea) {
|
||||
console.warn("[TermWrap] 未找到 xterm textarea 元素");
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听组合开始
|
||||
textarea.addEventListener("compositionstart", () => {
|
||||
this.isComposing = true;
|
||||
});
|
||||
|
||||
// 监听组合结束
|
||||
textarea.addEventListener("compositionend", () => {
|
||||
this.isComposing = false;
|
||||
});
|
||||
|
||||
// 处理 Escape 键(在组合状态下取消组合)
|
||||
textarea.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.isComposing) {
|
||||
// 取消组合
|
||||
this.isComposing = false;
|
||||
// 清空输入
|
||||
textarea.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端大小变化(对齐 waveterm)
|
||||
*
|
||||
* _Requirements: 8.6_
|
||||
*/
|
||||
handleResize(): void {
|
||||
const oldRows = this.terminal.rows;
|
||||
const oldCols = this.terminal.cols;
|
||||
|
||||
// 调用 fit 计算新大小
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch (err) {
|
||||
console.warn("[TermWrap] fit() 失败:", err);
|
||||
return;
|
||||
this.fitAddon.fit();
|
||||
|
||||
// 如果尺寸改变,同步到后端
|
||||
if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {
|
||||
resizeTerminal(
|
||||
this.sessionId,
|
||||
this.terminal.rows,
|
||||
this.terminal.cols,
|
||||
).catch((e) => console.error("[TermWrap] resize 同步失败:", e));
|
||||
}
|
||||
|
||||
const { rows, cols } = this.terminal;
|
||||
|
||||
console.log(
|
||||
`[TermWrap] handleResize: ${cols}x${rows} (was ${oldCols}x${oldRows})`,
|
||||
);
|
||||
|
||||
// 同步大小到后端
|
||||
this.syncSizeToBackend(rows, cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步大小到后端
|
||||
*/
|
||||
private syncSizeToBackend(rows: number, cols: number): void {
|
||||
// 检查是否与上次同步的大小相同
|
||||
if (
|
||||
this.lastSyncedSize &&
|
||||
this.lastSyncedSize.rows === rows &&
|
||||
this.lastSyncedSize.cols === cols
|
||||
) {
|
||||
return; // 大小没变,不需要同步
|
||||
// 首次 resize 标记(对齐 waveterm)
|
||||
if (!this.hasResized) {
|
||||
this.hasResized = true;
|
||||
}
|
||||
|
||||
console.log(`[TermWrap] 同步 resize 到后端: ${cols}x${rows}`);
|
||||
this.lastSyncedSize = { rows, cols };
|
||||
resizeTerminal(this.sessionId, rows, cols).catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,8 +461,38 @@ export class TermWrap {
|
||||
this.terminal.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取终端是否聚焦
|
||||
*/
|
||||
hasFocus(): boolean {
|
||||
return (
|
||||
document.activeElement ===
|
||||
this.connectElem.querySelector(".xterm-helper-textarea")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前终端大小
|
||||
*/
|
||||
getSize(): { rows: number; cols: number } {
|
||||
return {
|
||||
rows: this.terminal.rows,
|
||||
cols: this.terminal.cols,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 WebGL 是否启用
|
||||
*
|
||||
* _Requirements: 8.2_
|
||||
*/
|
||||
isWebglEnabled(): boolean {
|
||||
return this.webglAddon !== null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 搜索功能
|
||||
// _Requirements: 8.3_
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -277,17 +504,13 @@ export class TermWrap {
|
||||
|
||||
/**
|
||||
* 搜索文本
|
||||
* @param term - 搜索词
|
||||
* @param options - 搜索选项
|
||||
* @returns 是否找到匹配
|
||||
*/
|
||||
search(term: string, options?: ISearchOptions): boolean {
|
||||
if (!term) {
|
||||
this.clearSearch();
|
||||
return false;
|
||||
}
|
||||
const found = this.searchAddon.findNext(term, options);
|
||||
return found;
|
||||
return this.searchAddon.findNext(term, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,6 +538,7 @@ export class TermWrap {
|
||||
|
||||
// ============================================================================
|
||||
// 主题功能
|
||||
// _Requirements: 8.7_
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -333,16 +557,106 @@ export class TermWrap {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 字体配置
|
||||
// _Requirements: 8.8_
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 销毁终端
|
||||
* 设置字体大小
|
||||
*/
|
||||
setFontSize(size: number): void {
|
||||
this.terminal.options.fontSize = size;
|
||||
// 字体大小变化后需要重新 fit
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前字体大小
|
||||
*/
|
||||
getFontSize(): number {
|
||||
return this.terminal.options.fontSize ?? 14;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置字体族
|
||||
*/
|
||||
setFontFamily(fontFamily: string): void {
|
||||
this.terminal.options.fontFamily = fontFamily;
|
||||
// 字体变化后需要重新 fit
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前字体族
|
||||
*/
|
||||
getFontFamily(): string {
|
||||
return (
|
||||
this.terminal.options.fontFamily ??
|
||||
'Hack, Menlo, Monaco, "Courier New", monospace'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 写入数据
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 直接写入数据到终端(不经过后端)
|
||||
*
|
||||
* 用于本地显示,如重同步时恢复历史数据
|
||||
*/
|
||||
writeData(data: string | Uint8Array): void {
|
||||
this.terminal.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空终端
|
||||
*/
|
||||
clear(): void {
|
||||
this.terminal.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置终端
|
||||
*/
|
||||
reset(): void {
|
||||
this.terminal.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到底部
|
||||
*/
|
||||
scrollToBottom(): void {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到顶部
|
||||
*/
|
||||
scrollToTop(): void {
|
||||
this.terminal.scrollToTop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁终端(对齐 waveterm)
|
||||
*/
|
||||
dispose(): void {
|
||||
console.log(`[TermWrap] 销毁终端: ${this.sessionId}`);
|
||||
// 清理写入定时器
|
||||
if (this.writeTimer !== null) {
|
||||
clearTimeout(this.writeTimer);
|
||||
this.writeTimer = null;
|
||||
}
|
||||
// 刷新剩余数据
|
||||
this.flushWriteQueue();
|
||||
|
||||
// 清理事件监听
|
||||
this.unlistenOutput?.();
|
||||
this.unlistenStatus?.();
|
||||
|
||||
// 清理 WebGL
|
||||
this.disposeWebgl();
|
||||
|
||||
// 清理其他资源
|
||||
this.toDispose.forEach((d) => {
|
||||
try {
|
||||
@@ -354,7 +668,5 @@ export class TermWrap {
|
||||
|
||||
// 销毁终端
|
||||
this.terminal.dispose();
|
||||
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { History, Clock, FileText, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MachineIdHistory } from "@/lib/api/machineId";
|
||||
import { machineIdApi } from "@/lib/api/machineId";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface MachineIdHistoryPanelProps {
|
||||
_onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function MachineIdHistoryPanel({
|
||||
_onRefresh,
|
||||
}: MachineIdHistoryPanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [history, setHistory] = useState<MachineIdHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const historyData = await machineIdApi.getMachineIdHistory();
|
||||
setHistory(historyData);
|
||||
} catch (error) {
|
||||
console.error("加载历史记录失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "加载失败",
|
||||
description: "无法加载机器码历史记录",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
switch (platform.toLowerCase()) {
|
||||
case "windows":
|
||||
return "🪟";
|
||||
case "macos":
|
||||
return "🍎";
|
||||
case "linux":
|
||||
return "🐧";
|
||||
default:
|
||||
return "💻";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<History className="w-5 h-5 text-blue-500" />
|
||||
<span>操作历史</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<History className="w-8 h-8 mx-auto mb-2 animate-spin text-blue-500" />
|
||||
<p className="text-muted-foreground">加载历史记录中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<History className="w-5 h-5 text-blue-500" />
|
||||
<span>操作历史</span>
|
||||
</CardTitle>
|
||||
<CardDescription>查看机器码的历史修改记录</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadHistory}
|
||||
className="space-x-2"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<span>刷新</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-24 h-24 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<History className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">暂无历史记录</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
当前还没有机器码操作历史记录
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-4 rounded-lg">
|
||||
<p>💡 提示:历史记录功能正在开发中</p>
|
||||
<p>完成后将记录所有机器码修改操作,包括:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>机器码修改时间</li>
|
||||
<li>修改前后的值</li>
|
||||
<li>操作平台信息</li>
|
||||
<li>备份文件路径</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{history.map((record, index) => (
|
||||
<Card
|
||||
key={record.id || index}
|
||||
className="border-l-4 border-l-blue-500"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-lg">
|
||||
{getPlatformIcon(record.platform)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">机器码操作</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{record.platform}
|
||||
</Badge>
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatTimestamp(record.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
机器码
|
||||
</p>
|
||||
<div className="font-mono text-sm bg-muted/50 p-2 rounded border">
|
||||
{record.machine_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{record.backup_path && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
备份路径
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-sm bg-muted/50 p-2 rounded border">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-mono">
|
||||
{record.backup_path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>关于历史记录</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-amber-700 dark:text-amber-400 space-y-2">
|
||||
<p>历史记录功能目前处于开发阶段,暂时返回空记录。</p>
|
||||
<p className="text-sm">完整实现后将包含:</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1 ml-4">
|
||||
<li>所有机器码修改操作的时间记录</li>
|
||||
<li>修改前后的机器码值对比</li>
|
||||
<li>操作系统和平台信息</li>
|
||||
<li>相关备份文件的路径信息</li>
|
||||
<li>操作结果和状态信息</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Copy,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { MachineIdInfo, AdminStatus } from "@/lib/api/machineId";
|
||||
import { machineIdApi, machineIdUtils } from "@/lib/api/machineId";
|
||||
|
||||
interface MachineIdInfoPanelProps {
|
||||
machineIdInfo: MachineIdInfo | null;
|
||||
adminStatus: AdminStatus | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function MachineIdInfoPanel({
|
||||
machineIdInfo,
|
||||
adminStatus,
|
||||
onRefresh,
|
||||
}: MachineIdInfoPanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
const handleCopyMachineId = async () => {
|
||||
if (!machineIdInfo) return;
|
||||
|
||||
try {
|
||||
setCopying(true);
|
||||
await machineIdApi.copyMachineIdToClipboard(machineIdInfo.current_id);
|
||||
toast({
|
||||
title: "复制成功",
|
||||
description: "机器码已复制到剪贴板",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("复制失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "复制失败",
|
||||
description: "无法复制机器码到剪贴板",
|
||||
});
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!machineIdInfo || !adminStatus) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
<span>无法获取机器码信息</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
无法加载机器码信息,请检查系统状态或权限设置。
|
||||
</p>
|
||||
<Button onClick={onRefresh} className="mt-4">
|
||||
重新加载
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (!machineIdInfo.can_modify) {
|
||||
return (
|
||||
<Badge variant="secondary" className="space-x-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>只读</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (machineIdInfo.requires_admin && !adminStatus.is_admin) {
|
||||
return (
|
||||
<Badge variant="destructive" className="space-x-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>需要管理员权限</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="default" className="space-x-1">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
<span>可修改</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getFormatBadge = () => {
|
||||
const color =
|
||||
machineIdInfo.format_type === "uuid"
|
||||
? "default"
|
||||
: machineIdInfo.format_type === "hex32"
|
||||
? "secondary"
|
||||
: "outline";
|
||||
|
||||
return (
|
||||
<Badge variant={color}>
|
||||
{machineIdUtils.getFormatDisplayName(machineIdInfo.format_type)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 主要信息卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<HardDrive className="w-5 h-5 text-blue-500" />
|
||||
<span>当前机器码</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
系统唯一标识符,用于设备识别和授权验证
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">机器码</p>
|
||||
<p className="font-mono text-lg font-semibold">
|
||||
{machineIdInfo.current_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getFormatBadge()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyMachineId}
|
||||
disabled={copying}
|
||||
className="space-x-1"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>{copying ? "复制中..." : "复制"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{machineIdInfo.original_id && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600" />
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
检测到机器码覆盖
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mb-2">
|
||||
原始机器码:
|
||||
</p>
|
||||
<p className="font-mono text-sm text-amber-900 dark:text-amber-200 bg-amber-100 dark:bg-amber-900/30 p-2 rounded">
|
||||
{machineIdInfo.original_id}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 系统信息卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Monitor className="w-5 h-5 text-green-500" />
|
||||
<span>系统状态</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">操作系统</p>
|
||||
<p className="font-semibold">
|
||||
{machineIdUtils.getPlatformDisplayName(machineIdInfo.platform)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">管理员权限</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
{adminStatus.is_admin ? (
|
||||
<Badge variant="default" className="space-x-1">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
<span>已获取</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="space-x-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>未获取</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">备份状态</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
{machineIdInfo.backup_exists ? (
|
||||
<Badge variant="default">已备份</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">未备份</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 权限提醒 */}
|
||||
{machineIdInfo.requires_admin && !adminStatus.is_admin && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>权限提醒</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-amber-700 dark:text-amber-400">
|
||||
<p className="mb-2">
|
||||
在当前平台({machineIdInfo.platform})上修改机器码需要管理员权限。
|
||||
</p>
|
||||
{adminStatus.elevation_method && (
|
||||
<p className="text-sm">
|
||||
提升权限方法:{adminStatus.elevation_method}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,787 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Save,
|
||||
Shuffle,
|
||||
ClipboardPaste,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileUp,
|
||||
FileDown,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
MachineIdInfo,
|
||||
AdminStatus,
|
||||
MachineIdValidation,
|
||||
} from "@/lib/api/machineId";
|
||||
import { machineIdApi, machineIdUtils } from "@/lib/api/machineId";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
interface MachineIdManagePanelProps {
|
||||
machineIdInfo: MachineIdInfo | null;
|
||||
adminStatus: AdminStatus | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function MachineIdManagePanel({
|
||||
machineIdInfo,
|
||||
adminStatus,
|
||||
onRefresh,
|
||||
}: MachineIdManagePanelProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
// 机器码修改
|
||||
const [newMachineId, setNewMachineId] = useState("");
|
||||
const [validationResult, setValidationResult] =
|
||||
useState<MachineIdValidation | null>(null);
|
||||
const [_isValidating, setIsValidating] = useState(false);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
// 格式转换
|
||||
const [formatInput, setFormatInput] = useState("");
|
||||
const [formatTarget, setFormatTarget] = useState<"uuid" | "hex32">("uuid");
|
||||
const [formatResult, setFormatResult] = useState("");
|
||||
|
||||
// 备份恢复
|
||||
const [backupPath, setBackupPath] = useState("");
|
||||
const [restorePath, setRestorePath] = useState("");
|
||||
const [isBackingUp, setIsBackingUp] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
|
||||
const canModify =
|
||||
machineIdInfo?.can_modify &&
|
||||
(!machineIdInfo.requires_admin || adminStatus?.is_admin);
|
||||
|
||||
const handleValidateMachineId = async () => {
|
||||
if (!newMachineId.trim()) {
|
||||
setValidationResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsValidating(true);
|
||||
const result = await machineIdApi.validateMachineId(newMachineId.trim());
|
||||
setValidationResult(result);
|
||||
} catch (error) {
|
||||
console.error("验证失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "验证失败",
|
||||
description: "无法验证机器码格式",
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyMachineId = async () => {
|
||||
if (!validationResult?.is_valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "无法应用",
|
||||
description: "机器码格式不正确",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsApplying(true);
|
||||
const result = await machineIdApi.setMachineId(newMachineId.trim());
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "机器码修改成功",
|
||||
description: result.message,
|
||||
});
|
||||
setNewMachineId("");
|
||||
setValidationResult(null);
|
||||
onRefresh();
|
||||
|
||||
if (result.requires_restart) {
|
||||
toast({
|
||||
title: "重启提醒",
|
||||
description: "部分应用程序可能需要重启才能识别新的机器码",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "机器码修改失败",
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("应用失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "应用失败",
|
||||
description: "无法设置新的机器码",
|
||||
});
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateRandom = async () => {
|
||||
try {
|
||||
const randomId = await machineIdApi.generateRandomMachineId();
|
||||
setNewMachineId(randomId);
|
||||
// 自动验证生成的机器码
|
||||
const result = await machineIdApi.validateMachineId(randomId);
|
||||
setValidationResult(result);
|
||||
toast({
|
||||
title: "随机机器码生成成功",
|
||||
description: "已生成新的随机机器码",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("生成失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "生成失败",
|
||||
description: "无法生成随机机器码",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
const clipboardText = await machineIdApi.pasteMachineIdFromClipboard();
|
||||
setNewMachineId(clipboardText);
|
||||
// 自动验证粘贴的机器码
|
||||
const result = await machineIdApi.validateMachineId(clipboardText);
|
||||
setValidationResult(result);
|
||||
toast({
|
||||
title: "从剪贴板粘贴成功",
|
||||
description: "已从剪贴板粘贴机器码",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("粘贴失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "粘贴失败",
|
||||
description: "剪贴板中没有有效的机器码",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormatConvert = async () => {
|
||||
if (!formatInput.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await machineIdApi.convertMachineIdFormat(
|
||||
formatInput.trim(),
|
||||
formatTarget,
|
||||
);
|
||||
setFormatResult(result);
|
||||
toast({
|
||||
title: "格式转换成功",
|
||||
description: `已转换为${formatTarget === "uuid" ? "UUID" : "32位十六进制"}格式`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("转换失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "转换失败",
|
||||
description: "无法转换机器码格式,请检查输入是否正确",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
if (!backupPath.trim()) return;
|
||||
|
||||
try {
|
||||
setIsBackingUp(true);
|
||||
const success = await machineIdApi.backupMachineIdToFile(
|
||||
backupPath.trim(),
|
||||
);
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: "备份成功",
|
||||
description: "机器码已备份到指定文件",
|
||||
});
|
||||
setBackupPath("");
|
||||
onRefresh();
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "备份失败",
|
||||
description: "无法创建备份文件",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("备份失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "备份失败",
|
||||
description: "备份操作失败",
|
||||
});
|
||||
} finally {
|
||||
setIsBackingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectBackupFile = async () => {
|
||||
try {
|
||||
const selected = await save({
|
||||
title: "保存机器码备份文件",
|
||||
defaultPath: `machine_id_backup_${new Date().toISOString().slice(0, 10)}.json`,
|
||||
filters: [
|
||||
{
|
||||
name: "JSON文件",
|
||||
extensions: ["json"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
setBackupPath(selected);
|
||||
toast({
|
||||
title: "保存位置已选择",
|
||||
description: "备份文件保存路径已设置",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("文件保存对话框失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "文件选择失败",
|
||||
description: "无法打开文件保存对话框",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRestoreFile = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
title: "选择机器码备份文件",
|
||||
filters: [
|
||||
{
|
||||
name: "JSON文件",
|
||||
extensions: ["json"],
|
||||
},
|
||||
],
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
setRestorePath(selected);
|
||||
toast({
|
||||
title: "文件已选择",
|
||||
description: "备份文件路径已设置",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("文件选择失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "文件选择失败",
|
||||
description: "无法打开文件选择对话框",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!restorePath.trim()) return;
|
||||
|
||||
try {
|
||||
setIsRestoring(true);
|
||||
const result = await machineIdApi.restoreMachineIdFromFile(
|
||||
restorePath.trim(),
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "恢复成功",
|
||||
description: result.message,
|
||||
});
|
||||
setRestorePath("");
|
||||
onRefresh();
|
||||
|
||||
if (result.requires_restart) {
|
||||
toast({
|
||||
title: "重启提醒",
|
||||
description: "部分应用程序可能需要重启才能识别恢复的机器码",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "恢复失败",
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("恢复失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "恢复失败",
|
||||
description: "恢复操作失败",
|
||||
});
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreOriginal = async () => {
|
||||
if (!machineIdInfo?.original_id) return;
|
||||
|
||||
try {
|
||||
setIsRestoring(true);
|
||||
const result = await machineIdApi.setMachineId(machineIdInfo.original_id);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "恢复成功",
|
||||
description: "已恢复到原始机器码",
|
||||
});
|
||||
onRefresh();
|
||||
|
||||
if (result.requires_restart) {
|
||||
toast({
|
||||
title: "重启提醒",
|
||||
description: "部分应用程序可能需要重启才能识别恢复的机器码",
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "恢复失败",
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("恢复原始机器码失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "恢复失败",
|
||||
description: "无法恢复到原始机器码",
|
||||
});
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearOverride = async () => {
|
||||
try {
|
||||
const result = await machineIdApi.clearMachineIdOverride();
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "清除成功",
|
||||
description: result.message,
|
||||
});
|
||||
onRefresh();
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "清除失败",
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("清除失败:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "清除失败",
|
||||
description: "清除覆盖失败",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!machineIdInfo || !adminStatus) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
<span>无法加载管理功能</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
无法加载机器码管理功能,请检查系统状态。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 权限状态提醒 */}
|
||||
{!canModify && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>权限不足</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-amber-700 dark:text-amber-400">
|
||||
<p>
|
||||
{!machineIdInfo.can_modify
|
||||
? "当前平台不支持机器码修改"
|
||||
: "需要管理员权限才能修改机器码"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 机器码修改 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5 text-blue-500" />
|
||||
<span>修改机器码</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
输入新的机器码来替换当前的系统标识符
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-machine-id">新机器码</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="new-machine-id"
|
||||
placeholder="输入新的机器码(UUID或32位十六进制)"
|
||||
value={newMachineId}
|
||||
onChange={(e) => {
|
||||
setNewMachineId(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
onBlur={handleValidateMachineId}
|
||||
disabled={!canModify}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerateRandom}
|
||||
disabled={!canModify}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<Shuffle className="w-4 h-4" />
|
||||
<span>随机生成</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePasteFromClipboard}
|
||||
disabled={!canModify}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
<span>粘贴</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${
|
||||
validationResult.is_valid
|
||||
? "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20"
|
||||
: "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/20"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{validationResult.is_valid ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
validationResult.is_valid
|
||||
? "text-green-800 dark:text-green-300"
|
||||
: "text-red-800 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{validationResult.is_valid ? "格式验证通过" : "格式验证失败"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{validationResult.is_valid && validationResult.formatted_id && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-muted-foreground">格式化后:</p>
|
||||
<p className="font-mono text-sm bg-background p-2 rounded border mt-1">
|
||||
{validationResult.formatted_id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!validationResult.is_valid && validationResult.error_message && (
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
|
||||
{validationResult.error_message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<Badge
|
||||
variant={
|
||||
validationResult.detected_format === "uuid"
|
||||
? "default"
|
||||
: validationResult.detected_format === "hex32"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
检测格式:
|
||||
{machineIdUtils.getFormatDisplayName(
|
||||
validationResult.detected_format,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleApplyMachineId}
|
||||
disabled={!canModify || !validationResult?.is_valid || isApplying}
|
||||
className="w-full space-x-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{isApplying ? "应用中..." : "应用机器码"}</span>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 格式转换工具 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5 text-green-500" />
|
||||
<span>格式转换</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
在UUID格式和32位十六进制格式之间转换
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="format-input">源机器码</Label>
|
||||
<Input
|
||||
id="format-input"
|
||||
placeholder="输入要转换的机器码"
|
||||
value={formatInput}
|
||||
onChange={(e) => setFormatInput(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>目标格式</Label>
|
||||
<RadioGroup
|
||||
value={formatTarget}
|
||||
onValueChange={(value: string) =>
|
||||
setFormatTarget(value as "uuid" | "hex32")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="uuid" id="uuid" />
|
||||
<Label htmlFor="uuid">
|
||||
UUID 格式 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="hex32" id="hex32" />
|
||||
<Label htmlFor="hex32">
|
||||
32位十六进制 (xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleFormatConvert}
|
||||
disabled={!formatInput.trim()}
|
||||
className="w-full space-x-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>转换格式</span>
|
||||
</Button>
|
||||
|
||||
{formatResult && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-1">转换结果:</p>
|
||||
<p className="font-mono bg-background p-2 rounded border">
|
||||
{formatResult}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 备份和恢复 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FileDown className="w-5 h-5 text-purple-500" />
|
||||
<span>备份与恢复</span>
|
||||
</CardTitle>
|
||||
<CardDescription>备份当前机器码或从备份文件恢复</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 备份 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">备份机器码</h4>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="选择保存位置..."
|
||||
value={backupPath}
|
||||
onChange={(e) => setBackupPath(e.target.value)}
|
||||
disabled={!canModify}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectBackupFile}
|
||||
disabled={!canModify}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span>选择位置</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBackup}
|
||||
disabled={!canModify || !backupPath.trim() || isBackingUp}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
<span>{isBackingUp ? "备份中..." : "备份"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 恢复 */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">恢复机器码</h4>
|
||||
|
||||
{/* 恢复到原始机器码 */}
|
||||
{machineIdInfo?.original_id && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="font-medium text-blue-800 dark:text-blue-300">
|
||||
恢复到原始机器码
|
||||
</h5>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
|
||||
将机器码恢复到首次备份的原始值
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-500 font-mono mt-2">
|
||||
{machineIdInfo.original_id}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestoreOriginal}
|
||||
disabled={!canModify || isRestoring}
|
||||
className="space-x-1 shrink-0 border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>{isRestoring ? "恢复中..." : "恢复原始"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 从文件恢复 */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-muted-foreground">
|
||||
从备份文件恢复
|
||||
</h5>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="选择备份文件..."
|
||||
value={restorePath}
|
||||
onChange={(e) => setRestorePath(e.target.value)}
|
||||
disabled={!canModify}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectRestoreFile}
|
||||
disabled={!canModify}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span>选择文件</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestore}
|
||||
disabled={!canModify || !restorePath.trim() || isRestoring}
|
||||
className="space-x-1 shrink-0"
|
||||
>
|
||||
<FileUp className="w-4 h-4" />
|
||||
<span>{isRestoring ? "恢复中..." : "恢复"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* macOS 特殊操作 */}
|
||||
{machineIdInfo.platform.toLowerCase() === "macos" &&
|
||||
machineIdInfo.original_id && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
<span>macOS 特殊操作</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
清除机器码覆盖,恢复原始系统机器码
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600" />
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
注意:此操作将删除当前的机器码覆盖
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mb-3">
|
||||
这将使系统恢复到原始机器码:{machineIdInfo.original_id}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearOverride}
|
||||
className="space-x-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>清除机器码覆盖</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
Cpu,
|
||||
Shield,
|
||||
Server,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SystemInfo, AdminStatus } from "@/lib/api/machineId";
|
||||
import { machineIdUtils } from "@/lib/api/machineId";
|
||||
|
||||
interface MachineIdSystemPanelProps {
|
||||
systemInfo: SystemInfo | null;
|
||||
adminStatus: AdminStatus | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function MachineIdSystemPanel({
|
||||
systemInfo,
|
||||
adminStatus,
|
||||
}: MachineIdSystemPanelProps) {
|
||||
if (!systemInfo || !adminStatus) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
<span>无法获取系统信息</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
无法加载系统信息,请检查系统状态。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getPlatformIcon = (os: string) => {
|
||||
switch (os.toLowerCase()) {
|
||||
case "windows":
|
||||
return "🪟";
|
||||
case "macos":
|
||||
return "🍎";
|
||||
case "linux":
|
||||
return "🐧";
|
||||
default:
|
||||
return "💻";
|
||||
}
|
||||
};
|
||||
|
||||
const getArchIcon = (arch: string) => {
|
||||
switch (arch.toLowerCase()) {
|
||||
case "x86_64":
|
||||
case "amd64":
|
||||
return "🖥️";
|
||||
case "aarch64":
|
||||
case "arm64":
|
||||
return "📱";
|
||||
case "x86":
|
||||
case "i386":
|
||||
return "🖧";
|
||||
default:
|
||||
return "🔧";
|
||||
}
|
||||
};
|
||||
|
||||
const getSupportBadge = (canRead: boolean, canWrite: boolean) => {
|
||||
if (canRead && canWrite) {
|
||||
return (
|
||||
<Badge variant="default" className="space-x-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>完全支持</span>
|
||||
</Badge>
|
||||
);
|
||||
} else if (canRead) {
|
||||
return (
|
||||
<Badge variant="secondary" className="space-x-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>只读支持</span>
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="outline" className="space-x-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>不支持</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 系统基本信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Server className="w-5 h-5 text-blue-500" />
|
||||
<span>系统信息</span>
|
||||
</CardTitle>
|
||||
<CardDescription>当前系统的基本信息和配置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">
|
||||
{getPlatformIcon(systemInfo.os)}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作系统</p>
|
||||
<p className="font-semibold">
|
||||
{machineIdUtils.getPlatformDisplayName(systemInfo.os)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
系列: {systemInfo.family}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{getArchIcon(systemInfo.arch)}</span>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">系统架构</p>
|
||||
<p className="font-semibold">{systemInfo.arch}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Shield className="w-6 h-6 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">权限状态</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
{adminStatus.is_admin ? (
|
||||
<Badge variant="default" className="space-x-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>管理员权限</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="space-x-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>普通用户</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{adminStatus.elevation_method && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
提升方法:{adminStatus.elevation_method}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Cpu className="w-6 h-6 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">机器码支持</p>
|
||||
{getSupportBadge(
|
||||
systemInfo.machine_id_support.can_read,
|
||||
systemInfo.machine_id_support.can_write,
|
||||
)}
|
||||
{systemInfo.requires_admin && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
⚠️ 修改需要管理员权限
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 平台支持详情 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Cpu className="w-5 h-5 text-purple-500" />
|
||||
<span>平台支持详情</span>
|
||||
</CardTitle>
|
||||
<CardDescription>当前平台对机器码操作的支持情况</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">支持格式</p>
|
||||
<p className="font-semibold">
|
||||
{systemInfo.machine_id_support.format}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">实现方法</p>
|
||||
<p className="text-sm bg-muted/50 p-2 rounded">
|
||||
{systemInfo.machine_id_support.method}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作权限</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{systemInfo.machine_id_support.can_read ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
读取机器码{" "}
|
||||
{systemInfo.machine_id_support.can_read ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{systemInfo.machine_id_support.can_write ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
修改机器码{" "}
|
||||
{systemInfo.machine_id_support.can_write ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{systemInfo.machine_id_support.limitations.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Info className="w-4 h-4 text-amber-500" />
|
||||
<p className="text-sm font-medium">平台限制</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{systemInfo.machine_id_support.limitations.map(
|
||||
(limitation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start space-x-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<span>{limitation}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 平台特定信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Info className="w-5 h-5 text-indigo-500" />
|
||||
<span>平台特定说明</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemInfo.os.toLowerCase() === "windows" && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Windows 平台说明</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
机器码存储在注册表中:
|
||||
<code className="bg-muted px-1 rounded">
|
||||
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\MachineGuid
|
||||
</code>
|
||||
</li>
|
||||
<li>修改机器码需要管理员权限</li>
|
||||
<li>某些应用程序可能需要重启才能识别新的机器码</li>
|
||||
<li>支持标准 UUID 格式</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{systemInfo.os.toLowerCase() === "macos" && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">macOS 平台说明</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>使用应用层覆盖机制,不修改系统原始 UUID</li>
|
||||
<li>
|
||||
原始机器码通过{" "}
|
||||
<code className="bg-muted px-1 rounded">ioreg</code> 命令获取
|
||||
</li>
|
||||
<li>覆盖文件存储在用户数据目录</li>
|
||||
<li>不需要管理员权限,但只影响使用覆盖的应用</li>
|
||||
<li>支持清除覆盖恢复原始状态</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{systemInfo.os.toLowerCase() === "linux" && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium">Linux 平台说明</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
机器码存储在{" "}
|
||||
<code className="bg-muted px-1 rounded">/etc/machine-id</code>{" "}
|
||||
文件中
|
||||
</li>
|
||||
<li>修改需要 root 权限</li>
|
||||
<li>使用 32 位十六进制格式</li>
|
||||
<li>某些系统服务可能需要重启</li>
|
||||
<li>修改可能影响系统服务的正常运行</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!["windows", "macos", "linux"].includes(
|
||||
systemInfo.os.toLowerCase(),
|
||||
) && (
|
||||
<div className="text-center py-6">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-muted-foreground" />
|
||||
<h4 className="font-medium mb-2">不支持的平台</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前平台({systemInfo.os})暂不支持机器码管理功能
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowLeft, Cpu, Settings, History, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MachineIdInfoPanel } from "./MachineIdInfoPanel";
|
||||
import { MachineIdManagePanel } from "./MachineIdManagePanel";
|
||||
import { MachineIdHistoryPanel } from "./MachineIdHistoryPanel";
|
||||
import { MachineIdSystemPanel } from "./MachineIdSystemPanel";
|
||||
import { MachineIdInfo, SystemInfo, AdminStatus } from "@/lib/api/machineId";
|
||||
import { machineIdApi } from "@/lib/api/machineId";
|
||||
|
||||
interface MachineIdToolProps {
|
||||
onNavigate?: (
|
||||
page:
|
||||
| "provider-pool"
|
||||
| "api-server"
|
||||
| "agent"
|
||||
| "tools"
|
||||
| "plugins"
|
||||
| "settings"
|
||||
| `plugin:${string}`,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function MachineIdTool({ onNavigate }: MachineIdToolProps) {
|
||||
const [machineIdInfo, setMachineIdInfo] = useState<MachineIdInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||
const [adminStatus, setAdminStatus] = useState<AdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllInfo();
|
||||
}, [refreshKey]);
|
||||
|
||||
const loadAllInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [machineId, system, admin] = await Promise.all([
|
||||
machineIdApi.getCurrentMachineId(),
|
||||
machineIdApi.getSystemInfo(),
|
||||
machineIdApi.checkAdminPrivileges(),
|
||||
]);
|
||||
|
||||
setMachineIdInfo(machineId);
|
||||
setSystemInfo(system);
|
||||
setAdminStatus(admin);
|
||||
} catch (error) {
|
||||
console.error("加载机器码信息失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<Cpu className="w-16 h-16 mx-auto mb-4 animate-spin text-blue-500" />
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onNavigate?.("tools")}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Cpu className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">机器码管理工具</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
查看、修改和管理系统机器码,支持跨平台操作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<Button variant="outline" onClick={handleRefresh} className="space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>刷新信息</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 主要功能标签页 */}
|
||||
<Tabs defaultValue="info" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="info" className="space-x-2">
|
||||
<Info className="w-4 h-4" />
|
||||
<span>机器码信息</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manage" className="space-x-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>管理操作</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="space-x-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>操作历史</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="space-x-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
<span>系统信息</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info" className="space-y-6">
|
||||
<MachineIdInfoPanel
|
||||
machineIdInfo={machineIdInfo}
|
||||
adminStatus={adminStatus}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manage" className="space-y-6">
|
||||
<MachineIdManagePanel
|
||||
machineIdInfo={machineIdInfo}
|
||||
adminStatus={adminStatus}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="space-y-6">
|
||||
<MachineIdHistoryPanel _onRefresh={handleRefresh} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" className="space-y-6">
|
||||
<MachineIdSystemPanel
|
||||
systemInfo={systemInfo}
|
||||
adminStatus={adminStatus}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,3 +240,22 @@ export type {
|
||||
// Usage API
|
||||
export { usageApi } from "@/lib/api/usage";
|
||||
export type { UsageInfo } from "@/lib/api/usage";
|
||||
|
||||
// Machine ID API
|
||||
export { machineIdApi, machineIdUtils } from "@/lib/api/machineId";
|
||||
export type {
|
||||
MachineIdFormat,
|
||||
MachineIdInfo,
|
||||
MachineIdResult,
|
||||
AdminStatus as MachineIdAdminStatus,
|
||||
MachineIdValidation,
|
||||
MachineIdHistory,
|
||||
SystemInfo as MachineIdSystemInfo,
|
||||
PlatformSupport,
|
||||
} from "@/lib/api/machineId";
|
||||
|
||||
// Label 组件
|
||||
export { Label } from "@/components/ui/label";
|
||||
|
||||
// Toaster 组件
|
||||
export { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# terminal
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 概述
|
||||
|
||||
终端核心库模块,提供终端主题配置和状态管理功能。
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `themes.ts` - 终端主题配置(Tokyo Night, Dracula, One Dark 等)
|
||||
- `store/` - 终端状态管理(Jotai 原子)
|
||||
|
||||
## 子目录
|
||||
|
||||
### store/
|
||||
|
||||
终端状态管理模块,使用 Jotai 进行原子化状态管理。
|
||||
|
||||
详见 [store/README.md](./store/README.md)
|
||||
|
||||
## 功能
|
||||
|
||||
### 主题系统
|
||||
|
||||
提供多种预定义终端主题:
|
||||
- Tokyo Night(默认)
|
||||
- Dracula
|
||||
- One Dark
|
||||
- GitHub Dark
|
||||
- Monokai
|
||||
- Nord
|
||||
- Solarized Dark
|
||||
- Gruvbox Dark
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Jotai 管理终端状态:
|
||||
- 终端模式(term/vdom)
|
||||
- 连接状态
|
||||
- 字体大小
|
||||
- 主题配置
|
||||
- Shell 进程状态
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
// 主题
|
||||
import { getTheme, loadThemePreference } from "@/lib/terminal/themes";
|
||||
const theme = getTheme("tokyo-night");
|
||||
|
||||
// 状态管理
|
||||
import { termModeAtomFamily, connStatusAtomFamily } from "@/lib/terminal/store";
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
# 终端贴纸系统
|
||||
|
||||
终端贴纸系统允许在终端上显示可定位的贴纸标注。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 基于字符网格的精确定位
|
||||
- 支持多种内容类型(文本、图标、徽章、自定义组件)
|
||||
- 可自定义样式(颜色、边框、透明度等)
|
||||
- 支持拖拽移动
|
||||
- 终端大小变化时自动调整位置
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `types.ts` | 贴纸类型定义和工具函数 |
|
||||
| `store.ts` | Jotai 状态管理 |
|
||||
| `index.ts` | 模块导出入口 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
import { useSetAtom } from "jotai";
|
||||
import { addStickerAtom } from "@/lib/terminal/stickers";
|
||||
|
||||
// 添加文本贴纸
|
||||
const addSticker = useSetAtom(addStickerAtom);
|
||||
addSticker({
|
||||
blockId: "terminal-1",
|
||||
position: { row: 5, col: 10 },
|
||||
contentType: "text",
|
||||
text: "重要标记",
|
||||
style: {
|
||||
backgroundColor: "rgba(158, 206, 106, 0.9)",
|
||||
color: "#1a1b26",
|
||||
},
|
||||
draggable: true,
|
||||
closable: true,
|
||||
});
|
||||
|
||||
// 添加徽章贴纸
|
||||
addSticker({
|
||||
blockId: "terminal-1",
|
||||
position: { row: 10, col: 0 },
|
||||
contentType: "badge",
|
||||
badge: {
|
||||
text: "SUCCESS",
|
||||
variant: "success",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 需求追溯
|
||||
|
||||
- _Requirements: 15.1_ - 终端支持在指定位置显示贴纸
|
||||
- _Requirements: 15.2_ - 贴纸支持基于字符网格的定位
|
||||
- _Requirements: 15.3_ - 贴纸支持自定义内容和样式
|
||||
- _Requirements: 15.4_ - 终端大小变化时贴纸位置相应调整
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 终端贴纸系统模块入口
|
||||
* @module lib/terminal/stickers
|
||||
*
|
||||
* 导出贴纸系统的所有类型和状态管理。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
CharGridPosition,
|
||||
PixelPosition,
|
||||
StickerStyle,
|
||||
StickerContentType,
|
||||
StickerIcon,
|
||||
StickerBadge,
|
||||
Sticker,
|
||||
CreateStickerParams,
|
||||
UpdateStickerParams,
|
||||
TerminalDimensions,
|
||||
} from "./types";
|
||||
|
||||
// 常量和工具函数导出
|
||||
export {
|
||||
DEFAULT_STICKER_STYLE,
|
||||
DEFAULT_TERMINAL_DIMENSIONS,
|
||||
charGridToPixel,
|
||||
pixelToCharGrid,
|
||||
generateStickerId,
|
||||
createSticker,
|
||||
} from "./types";
|
||||
|
||||
// 状态管理导出
|
||||
export {
|
||||
stickersMapAtom,
|
||||
terminalDimensionsMapAtom,
|
||||
getStickersForBlockAtom,
|
||||
getTerminalDimensionsAtom,
|
||||
getStickerAtom,
|
||||
addStickerAtom,
|
||||
updateStickerAtom,
|
||||
removeStickerAtom,
|
||||
clearStickersForBlockAtom,
|
||||
updateTerminalDimensionsAtom,
|
||||
moveStickerAtom,
|
||||
cleanupStickerStateAtom,
|
||||
} from "./store";
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file store.ts
|
||||
* @description 终端贴纸状态管理
|
||||
* @module lib/terminal/stickers/store
|
||||
*
|
||||
* 使用 Jotai 管理终端贴纸状态。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import type {
|
||||
Sticker,
|
||||
CreateStickerParams,
|
||||
UpdateStickerParams,
|
||||
TerminalDimensions,
|
||||
} from "./types";
|
||||
import { createSticker, DEFAULT_TERMINAL_DIMENSIONS } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 基础原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 所有贴纸的存储(按 blockId 分组)
|
||||
*
|
||||
* Map<blockId, Map<stickerId, Sticker>>
|
||||
*/
|
||||
export const stickersMapAtom = atom<Map<string, Map<string, Sticker>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* 终端尺寸信息(按 blockId 存储)
|
||||
*
|
||||
* Map<blockId, TerminalDimensions>
|
||||
*/
|
||||
export const terminalDimensionsMapAtom = atom<Map<string, TerminalDimensions>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 派生原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取指定块的所有贴纸
|
||||
*/
|
||||
export const getStickersForBlockAtom = atom((get) => {
|
||||
const stickersMap = get(stickersMapAtom);
|
||||
return (blockId: string): Sticker[] => {
|
||||
const blockStickers = stickersMap.get(blockId);
|
||||
if (!blockStickers) return [];
|
||||
return Array.from(blockStickers.values()).filter((s) => s.visible);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取指定块的终端尺寸
|
||||
*/
|
||||
export const getTerminalDimensionsAtom = atom((get) => {
|
||||
const dimensionsMap = get(terminalDimensionsMapAtom);
|
||||
return (blockId: string): TerminalDimensions => {
|
||||
return dimensionsMap.get(blockId) ?? DEFAULT_TERMINAL_DIMENSIONS;
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取指定贴纸
|
||||
*/
|
||||
export const getStickerAtom = atom((get) => {
|
||||
const stickersMap = get(stickersMapAtom);
|
||||
return (blockId: string, stickerId: string): Sticker | undefined => {
|
||||
const blockStickers = stickersMap.get(blockId);
|
||||
return blockStickers?.get(stickerId);
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 写入原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 添加贴纸
|
||||
*
|
||||
* _Requirements: 15.1_
|
||||
*/
|
||||
export const addStickerAtom = atom(
|
||||
null,
|
||||
(get, set, params: CreateStickerParams) => {
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
const blockStickers = new Map(stickersMap.get(params.blockId) ?? new Map());
|
||||
|
||||
const sticker = createSticker(params);
|
||||
blockStickers.set(sticker.id, sticker);
|
||||
stickersMap.set(params.blockId, blockStickers);
|
||||
|
||||
set(stickersMapAtom, stickersMap);
|
||||
return sticker;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新贴纸
|
||||
*
|
||||
* _Requirements: 15.3_
|
||||
*/
|
||||
export const updateStickerAtom = atom(
|
||||
null,
|
||||
(get, set, params: UpdateStickerParams & { blockId: string }) => {
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
const blockStickers = stickersMap.get(params.blockId);
|
||||
|
||||
if (!blockStickers) return false;
|
||||
|
||||
const sticker = blockStickers.get(params.id);
|
||||
if (!sticker) return false;
|
||||
|
||||
const updatedSticker: Sticker = {
|
||||
...sticker,
|
||||
...(params.position !== undefined && { position: params.position }),
|
||||
...(params.text !== undefined && { text: params.text }),
|
||||
...(params.icon !== undefined && { icon: params.icon }),
|
||||
...(params.badge !== undefined && { badge: params.badge }),
|
||||
...(params.style !== undefined && {
|
||||
style: { ...sticker.style, ...params.style },
|
||||
}),
|
||||
...(params.visible !== undefined && { visible: params.visible }),
|
||||
...(params.tooltip !== undefined && { tooltip: params.tooltip }),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const newBlockStickers = new Map(blockStickers);
|
||||
newBlockStickers.set(params.id, updatedSticker);
|
||||
stickersMap.set(params.blockId, newBlockStickers);
|
||||
|
||||
set(stickersMapAtom, stickersMap);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 删除贴纸
|
||||
*/
|
||||
export const removeStickerAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ blockId, stickerId }: { blockId: string; stickerId: string },
|
||||
) => {
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
const blockStickers = stickersMap.get(blockId);
|
||||
|
||||
if (!blockStickers) return false;
|
||||
|
||||
const newBlockStickers = new Map(blockStickers);
|
||||
const deleted = newBlockStickers.delete(stickerId);
|
||||
|
||||
if (deleted) {
|
||||
stickersMap.set(blockId, newBlockStickers);
|
||||
set(stickersMapAtom, stickersMap);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 清除指定块的所有贴纸
|
||||
*/
|
||||
export const clearStickersForBlockAtom = atom(
|
||||
null,
|
||||
(get, set, blockId: string) => {
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
stickersMap.delete(blockId);
|
||||
set(stickersMapAtom, stickersMap);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新终端尺寸
|
||||
*
|
||||
* _Requirements: 15.4_
|
||||
*/
|
||||
export const updateTerminalDimensionsAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
blockId,
|
||||
dimensions,
|
||||
}: { blockId: string; dimensions: TerminalDimensions },
|
||||
) => {
|
||||
const dimensionsMap = new Map(get(terminalDimensionsMapAtom));
|
||||
dimensionsMap.set(blockId, dimensions);
|
||||
set(terminalDimensionsMapAtom, dimensionsMap);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 移动贴纸位置
|
||||
*
|
||||
* _Requirements: 15.2, 15.4_
|
||||
*/
|
||||
export const moveStickerAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
blockId,
|
||||
stickerId,
|
||||
newPosition,
|
||||
}: {
|
||||
blockId: string;
|
||||
stickerId: string;
|
||||
newPosition: { row: number; col: number };
|
||||
},
|
||||
) => {
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
const blockStickers = stickersMap.get(blockId);
|
||||
|
||||
if (!blockStickers) return false;
|
||||
|
||||
const sticker = blockStickers.get(stickerId);
|
||||
if (!sticker || !sticker.draggable) return false;
|
||||
|
||||
// 获取终端尺寸以验证位置
|
||||
const dimensionsMap = get(terminalDimensionsMapAtom);
|
||||
const dimensions =
|
||||
dimensionsMap.get(blockId) ?? DEFAULT_TERMINAL_DIMENSIONS;
|
||||
|
||||
// 确保位置在有效范围内
|
||||
const clampedPosition = {
|
||||
row: Math.max(0, Math.min(newPosition.row, dimensions.rows - 1)),
|
||||
col: Math.max(0, Math.min(newPosition.col, dimensions.cols - 1)),
|
||||
};
|
||||
|
||||
const updatedSticker: Sticker = {
|
||||
...sticker,
|
||||
position: clampedPosition,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const newBlockStickers = new Map(blockStickers);
|
||||
newBlockStickers.set(stickerId, updatedSticker);
|
||||
stickersMap.set(blockId, newBlockStickers);
|
||||
|
||||
set(stickersMapAtom, stickersMap);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 清理原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 清理指定块的所有贴纸状态
|
||||
*/
|
||||
export const cleanupStickerStateAtom = atom(
|
||||
null,
|
||||
(get, set, blockId: string) => {
|
||||
// 清理贴纸
|
||||
const stickersMap = new Map(get(stickersMapAtom));
|
||||
stickersMap.delete(blockId);
|
||||
set(stickersMapAtom, stickersMap);
|
||||
|
||||
// 清理尺寸信息
|
||||
const dimensionsMap = new Map(get(terminalDimensionsMapAtom));
|
||||
dimensionsMap.delete(blockId);
|
||||
set(terminalDimensionsMapAtom, dimensionsMap);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 终端贴纸系统类型定义
|
||||
* @module lib/terminal/stickers/types
|
||||
*
|
||||
* 定义终端贴纸系统所需的类型。
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3, 15.4_
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 贴纸位置
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 字符网格位置
|
||||
*
|
||||
* 基于终端字符网格的定位系统。
|
||||
*
|
||||
* _Requirements: 15.2_
|
||||
*/
|
||||
export interface CharGridPosition {
|
||||
/** 行号(从 0 开始) */
|
||||
row: number;
|
||||
/** 列号(从 0 开始) */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 像素位置
|
||||
*
|
||||
* 用于实际渲染时的像素定位。
|
||||
*/
|
||||
export interface PixelPosition {
|
||||
/** 顶部偏移(像素) */
|
||||
top: number;
|
||||
/** 左侧偏移(像素) */
|
||||
left: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 贴纸样式
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 贴纸样式配置
|
||||
*
|
||||
* _Requirements: 15.3_
|
||||
*/
|
||||
export interface StickerStyle {
|
||||
/** 背景颜色 */
|
||||
backgroundColor?: string;
|
||||
/** 文字颜色 */
|
||||
color?: string;
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 边框宽度 */
|
||||
borderWidth?: number;
|
||||
/** 边框圆角 */
|
||||
borderRadius?: number;
|
||||
/** 内边距 */
|
||||
padding?: number;
|
||||
/** 字体大小 */
|
||||
fontSize?: number;
|
||||
/** 字体粗细 */
|
||||
fontWeight?: string | number;
|
||||
/** 透明度 (0-1) */
|
||||
opacity?: number;
|
||||
/** 最大宽度(字符数) */
|
||||
maxWidthChars?: number;
|
||||
/** 最大高度(行数) */
|
||||
maxHeightRows?: number;
|
||||
/** 自定义 CSS 类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认贴纸样式
|
||||
*/
|
||||
export const DEFAULT_STICKER_STYLE: Required<
|
||||
Pick<
|
||||
StickerStyle,
|
||||
"backgroundColor" | "color" | "borderRadius" | "padding" | "opacity"
|
||||
>
|
||||
> = {
|
||||
backgroundColor: "rgba(122, 162, 247, 0.9)",
|
||||
color: "#1a1b26",
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 贴纸类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 贴纸内容类型
|
||||
*/
|
||||
export type StickerContentType = "text" | "icon" | "badge" | "custom";
|
||||
|
||||
/**
|
||||
* 贴纸图标配置
|
||||
*/
|
||||
export interface StickerIcon {
|
||||
/** 图标名称(使用 Lucide 图标) */
|
||||
name: string;
|
||||
/** 图标大小 */
|
||||
size?: number;
|
||||
/** 图标颜色 */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 贴纸徽章配置
|
||||
*/
|
||||
export interface StickerBadge {
|
||||
/** 徽章文本 */
|
||||
text: string;
|
||||
/** 徽章变体 */
|
||||
variant?: "default" | "success" | "warning" | "error" | "info";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 贴纸数据
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 贴纸数据
|
||||
*
|
||||
* _Requirements: 15.1, 15.2, 15.3_
|
||||
*/
|
||||
export interface Sticker {
|
||||
/** 贴纸唯一 ID */
|
||||
id: string;
|
||||
/** 所属块 ID */
|
||||
blockId: string;
|
||||
/** 字符网格位置 */
|
||||
position: CharGridPosition;
|
||||
/** 内容类型 */
|
||||
contentType: StickerContentType;
|
||||
/** 文本内容(当 contentType 为 "text" 时) */
|
||||
text?: string;
|
||||
/** 图标配置(当 contentType 为 "icon" 时) */
|
||||
icon?: StickerIcon;
|
||||
/** 徽章配置(当 contentType 为 "badge" 时) */
|
||||
badge?: StickerBadge;
|
||||
/** 自定义渲染组件 ID(当 contentType 为 "custom" 时) */
|
||||
customComponentId?: string;
|
||||
/** 样式配置 */
|
||||
style?: StickerStyle;
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 是否可拖拽 */
|
||||
draggable?: boolean;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 工具提示 */
|
||||
tooltip?: string;
|
||||
/** 点击回调 ID */
|
||||
onClickId?: string;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 更新时间 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建贴纸的参数
|
||||
*/
|
||||
export interface CreateStickerParams {
|
||||
/** 所属块 ID */
|
||||
blockId: string;
|
||||
/** 字符网格位置 */
|
||||
position: CharGridPosition;
|
||||
/** 内容类型 */
|
||||
contentType: StickerContentType;
|
||||
/** 文本内容 */
|
||||
text?: string;
|
||||
/** 图标配置 */
|
||||
icon?: StickerIcon;
|
||||
/** 徽章配置 */
|
||||
badge?: StickerBadge;
|
||||
/** 自定义组件 ID */
|
||||
customComponentId?: string;
|
||||
/** 样式配置 */
|
||||
style?: StickerStyle;
|
||||
/** 是否可拖拽 */
|
||||
draggable?: boolean;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 工具提示 */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新贴纸的参数
|
||||
*/
|
||||
export interface UpdateStickerParams {
|
||||
/** 贴纸 ID */
|
||||
id: string;
|
||||
/** 新位置 */
|
||||
position?: CharGridPosition;
|
||||
/** 新文本 */
|
||||
text?: string;
|
||||
/** 新图标 */
|
||||
icon?: StickerIcon;
|
||||
/** 新徽章 */
|
||||
badge?: StickerBadge;
|
||||
/** 新样式 */
|
||||
style?: StickerStyle;
|
||||
/** 是否可见 */
|
||||
visible?: boolean;
|
||||
/** 工具提示 */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 终端尺寸信息
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端字符尺寸信息
|
||||
*
|
||||
* 用于计算贴纸的像素位置。
|
||||
*
|
||||
* _Requirements: 15.4_
|
||||
*/
|
||||
export interface TerminalDimensions {
|
||||
/** 字符宽度(像素) */
|
||||
charWidth: number;
|
||||
/** 字符高度(像素) */
|
||||
charHeight: number;
|
||||
/** 终端行数 */
|
||||
rows: number;
|
||||
/** 终端列数 */
|
||||
cols: number;
|
||||
/** 终端容器左边距 */
|
||||
paddingLeft: number;
|
||||
/** 终端容器上边距 */
|
||||
paddingTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认终端尺寸
|
||||
*/
|
||||
export const DEFAULT_TERMINAL_DIMENSIONS: TerminalDimensions = {
|
||||
charWidth: 8,
|
||||
charHeight: 17,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 5,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将字符网格位置转换为像素位置
|
||||
*
|
||||
* _Requirements: 15.2, 15.4_
|
||||
*/
|
||||
export function charGridToPixel(
|
||||
gridPos: CharGridPosition,
|
||||
dimensions: TerminalDimensions,
|
||||
): PixelPosition {
|
||||
return {
|
||||
top: dimensions.paddingTop + gridPos.row * dimensions.charHeight,
|
||||
left: dimensions.paddingLeft + gridPos.col * dimensions.charWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将像素位置转换为字符网格位置
|
||||
*
|
||||
* _Requirements: 15.2, 15.4_
|
||||
*/
|
||||
export function pixelToCharGrid(
|
||||
pixelPos: PixelPosition,
|
||||
dimensions: TerminalDimensions,
|
||||
): CharGridPosition {
|
||||
return {
|
||||
row: Math.floor(
|
||||
(pixelPos.top - dimensions.paddingTop) / dimensions.charHeight,
|
||||
),
|
||||
col: Math.floor(
|
||||
(pixelPos.left - dimensions.paddingLeft) / dimensions.charWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一贴纸 ID
|
||||
*/
|
||||
export function generateStickerId(): string {
|
||||
return `sticker-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新贴纸
|
||||
*/
|
||||
export function createSticker(params: CreateStickerParams): Sticker {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: generateStickerId(),
|
||||
blockId: params.blockId,
|
||||
position: params.position,
|
||||
contentType: params.contentType,
|
||||
text: params.text,
|
||||
icon: params.icon,
|
||||
badge: params.badge,
|
||||
customComponentId: params.customComponentId,
|
||||
style: params.style,
|
||||
visible: true,
|
||||
draggable: params.draggable ?? false,
|
||||
closable: params.closable ?? true,
|
||||
tooltip: params.tooltip,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# terminal/store
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 概述
|
||||
|
||||
终端状态管理模块,使用 Jotai 进行原子化状态管理。为每个终端维护独立的原子状态,支持后端事件订阅和自动状态同步。
|
||||
|
||||
## 架构说明
|
||||
|
||||
采用 Jotai 的 atomFamily 模式,按 blockId 索引每个终端的状态:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Jotai Store │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ termModeAtomFamily(blockId) → TermMode │
|
||||
│ connStatusAtomFamily(blockId) → ConnStatus │
|
||||
│ fontSizeAtomFamily(blockId) → number │
|
||||
│ termThemeNameAtomFamily(blockId) → string │
|
||||
│ shellProcStatusAtomFamily(blockId) → ShellProcStatus │
|
||||
│ shellProcFullStatusAtomFamily(blockId) → RuntimeStatus │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ connStatusMapAtom → Map<string, Status> │
|
||||
│ defaultFontSizeAtom → number (持久化) │
|
||||
│ defaultThemeNameAtom → string (持久化) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 事件订阅
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TerminalEventManager │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ controller:status → 控制器状态更新 │
|
||||
│ terminal:conn-change → 连接状态变更 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 模块入口,导出所有公共 API
|
||||
- `types.ts` - 类型定义(TermMode, ConnStatus, ShellProcStatus 等)
|
||||
- `atoms.ts` - Jotai 原子定义(状态原子和操作原子)
|
||||
- `events.ts` - 后端事件订阅和状态同步
|
||||
- `hooks.ts` - React Hooks(useTermMode, useConnStatus, useMultiInput 等)
|
||||
- `viewmodel.ts` - TermViewModel 视图模型,封装终端视图的状态和操作
|
||||
- `multiInput.ts` - 多输入模式状态管理和输入广播逻辑
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 状态原子
|
||||
|
||||
| 原子 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `termModeAtomFamily` | `TermMode` | 终端模式(term/vdom) |
|
||||
| `connStatusAtomFamily` | `ConnStatus` | 连接状态详情 |
|
||||
| `fontSizeAtomFamily` | `number` | 字体大小 |
|
||||
| `termThemeNameAtomFamily` | `string` | 主题名称 |
|
||||
| `shellProcStatusAtomFamily` | `ShellProcStatus` | Shell 进程状态 |
|
||||
| `shellProcFullStatusAtomFamily` | `BlockControllerRuntimeStatus` | 完整运行时状态 |
|
||||
|
||||
### 事件订阅
|
||||
|
||||
- `subscribeControllerStatus` - 订阅控制器状态事件
|
||||
- `subscribeConnChange` - 订阅连接状态变更事件
|
||||
- `TerminalEventManager` - 统一的事件管理器
|
||||
|
||||
### React Hooks
|
||||
|
||||
- `useTermMode` - 使用终端模式状态
|
||||
- `useConnStatus` - 使用连接状态
|
||||
- `useFontSize` - 使用字体大小状态
|
||||
- `useThemeName` - 使用主题名称状态
|
||||
- `useShellProcStatus` - 使用 Shell 进程状态
|
||||
- `useTerminalState` - 综合使用终端状态和事件订阅
|
||||
- `useTerminalEventManager` - 初始化事件管理器
|
||||
- `useMultiInput` - 使用多输入模式状态
|
||||
- `useBroadcastableTerminals` - 获取可广播的终端列表
|
||||
- `useRegisterTerminal` - 注册终端到多输入系统
|
||||
- `useMultiInputBroadcast` - 使用多输入广播功能
|
||||
|
||||
### TermViewModel
|
||||
|
||||
- `createTermViewModel` - 创建终端视图模型
|
||||
- `getOrCreateTermViewModel` - 获取或创建终端视图模型
|
||||
- `cleanupTermViewModel` - 清理终端视图模型
|
||||
- `termViewModelAtomFamily` - TermViewModel 原子族
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
termModeAtomFamily,
|
||||
connStatusAtomFamily,
|
||||
setTermModeAtom,
|
||||
terminalEventManager,
|
||||
} from "@/lib/terminal/store";
|
||||
|
||||
// 在组件中使用
|
||||
function TerminalView({ blockId }: { blockId: string }) {
|
||||
// 读取状态
|
||||
const termMode = useAtomValue(termModeAtomFamily(blockId));
|
||||
const connStatus = useAtomValue(connStatusAtomFamily(blockId));
|
||||
|
||||
// 更新状态
|
||||
const setTermMode = useSetAtom(setTermModeAtom);
|
||||
const handleModeChange = () => {
|
||||
setTermMode({ blockId, mode: "vdom" });
|
||||
};
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// 初始化事件订阅(在应用启动时)
|
||||
await terminalEventManager.initialize();
|
||||
```
|
||||
|
||||
## 需求追溯
|
||||
|
||||
- _Requirements: 9.1_ - 使用 Jotai 进行原子化状态管理
|
||||
- _Requirements: 9.2_ - termModeAtom
|
||||
- _Requirements: 9.3_ - connStatusAtom
|
||||
- _Requirements: 9.4_ - fontSizeAtom, termThemeNameAtom
|
||||
- _Requirements: 9.5_ - shellProcStatusAtom
|
||||
- _Requirements: 9.6_ - 事件订阅和状态同步
|
||||
- _Requirements: 9.7_ - TermViewModel 视图模型
|
||||
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_ - 多输入模式
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @file atoms.ts
|
||||
* @description 终端状态原子定义
|
||||
* @module lib/terminal/store/atoms
|
||||
*
|
||||
* 使用 Jotai 定义终端相关的原子状态。
|
||||
* 每个终端(通过 blockId 标识)维护独立的状态。
|
||||
*
|
||||
* _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import type {
|
||||
TermMode,
|
||||
ConnStatus,
|
||||
ShellProcStatus,
|
||||
BlockControllerRuntimeStatus,
|
||||
TerminalViewState,
|
||||
} from "./types";
|
||||
import { createDefaultConnStatus, createDefaultRuntimeStatus } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 全局配置原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 默认字体大小原子(持久化存储)
|
||||
*
|
||||
* _Requirements: 9.4_
|
||||
*/
|
||||
export const defaultFontSizeAtom = atomWithStorage<number>(
|
||||
"terminal-default-font-size",
|
||||
14,
|
||||
);
|
||||
|
||||
/**
|
||||
* 默认主题名称原子(持久化存储)
|
||||
*
|
||||
* _Requirements: 9.4_
|
||||
*/
|
||||
export const defaultThemeNameAtom = atomWithStorage<string>(
|
||||
"terminal-theme",
|
||||
"tokyo-night",
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 终端状态原子族(按 blockId 索引)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端模式原子族
|
||||
*
|
||||
* 每个终端的显示模式(term/vdom)。
|
||||
*
|
||||
* _Requirements: 9.2, 14.1_
|
||||
*/
|
||||
export const termModeAtomFamily = atomFamily((_blockId: string) =>
|
||||
atom<TermMode>("term"),
|
||||
);
|
||||
|
||||
/**
|
||||
* 连接状态原子族
|
||||
*
|
||||
* 每个终端的连接状态详情。
|
||||
*
|
||||
* _Requirements: 9.3, 7.1_
|
||||
*/
|
||||
export const connStatusAtomFamily = atomFamily((_blockId: string) =>
|
||||
atom<ConnStatus>(createDefaultConnStatus()),
|
||||
);
|
||||
|
||||
/**
|
||||
* 字体大小原子族
|
||||
*
|
||||
* 每个终端的字体大小配置。
|
||||
*
|
||||
* _Requirements: 9.4_
|
||||
*/
|
||||
export const fontSizeAtomFamily = atomFamily((_blockId: string) =>
|
||||
atom<number>(14),
|
||||
);
|
||||
|
||||
/**
|
||||
* 主题名称原子族
|
||||
*
|
||||
* 每个终端的主题配置。
|
||||
*
|
||||
* _Requirements: 9.4_
|
||||
*/
|
||||
export const termThemeNameAtomFamily = atomFamily((_blockId: string) =>
|
||||
atom<string>("tokyo-night"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Shell 进程状态原子族
|
||||
*
|
||||
* 每个终端的 Shell 进程状态。
|
||||
*
|
||||
* _Requirements: 9.5, 1.2, 1.3_
|
||||
*/
|
||||
export const shellProcStatusAtomFamily = atomFamily((_blockId: string) =>
|
||||
atom<ShellProcStatus>("init"),
|
||||
);
|
||||
|
||||
/**
|
||||
* 完整控制器运行时状态原子族
|
||||
*
|
||||
* 每个终端的完整控制器状态。
|
||||
*
|
||||
* _Requirements: 9.5, 1.8_
|
||||
*/
|
||||
export const shellProcFullStatusAtomFamily = atomFamily((blockId: string) =>
|
||||
atom<BlockControllerRuntimeStatus>(createDefaultRuntimeStatus(blockId)),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 连接状态映射(按连接名称索引)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 所有连接状态的映射
|
||||
*
|
||||
* 用于按连接名称查询状态。
|
||||
*
|
||||
* _Requirements: 7.6_
|
||||
*/
|
||||
export const connStatusMapAtom = atom<Map<string, ConnStatus>>(new Map());
|
||||
|
||||
/**
|
||||
* 获取指定连接的状态
|
||||
*/
|
||||
export const getConnStatusAtom = atomFamily((connection: string) =>
|
||||
atom((get) => {
|
||||
const map = get(connStatusMapAtom);
|
||||
return map.get(connection) ?? createDefaultConnStatus(connection);
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 终端视图状态原子族
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端视图状态原子族
|
||||
*
|
||||
* 聚合单个终端的所有状态。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export const terminalViewStateAtomFamily = atomFamily(
|
||||
({ blockId, tabId }: { blockId: string; tabId: string }) =>
|
||||
atom<TerminalViewState>((get) => {
|
||||
const termMode = get(termModeAtomFamily(blockId));
|
||||
const connStatus = get(connStatusAtomFamily(blockId));
|
||||
const fontSize = get(fontSizeAtomFamily(blockId));
|
||||
const themeName = get(termThemeNameAtomFamily(blockId));
|
||||
const shellProcStatus = get(shellProcStatusAtomFamily(blockId));
|
||||
const runtimeStatus = get(shellProcFullStatusAtomFamily(blockId));
|
||||
|
||||
return {
|
||||
blockId,
|
||||
tabId,
|
||||
termMode,
|
||||
connStatus,
|
||||
fontSize,
|
||||
themeName,
|
||||
shellProcStatus,
|
||||
runtimeStatus,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 操作原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 更新终端模式
|
||||
*/
|
||||
export const setTermModeAtom = atom(
|
||||
null,
|
||||
(get, set, { blockId, mode }: { blockId: string; mode: TermMode }) => {
|
||||
set(termModeAtomFamily(blockId), mode);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新连接状态
|
||||
*/
|
||||
export const setConnStatusAtom = atom(
|
||||
null,
|
||||
(get, set, { blockId, status }: { blockId: string; status: ConnStatus }) => {
|
||||
set(connStatusAtomFamily(blockId), status);
|
||||
|
||||
// 同时更新连接状态映射
|
||||
if (status.connection) {
|
||||
const map = new Map(get(connStatusMapAtom));
|
||||
map.set(status.connection, status);
|
||||
set(connStatusMapAtom, map);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新字体大小
|
||||
*/
|
||||
export const setFontSizeAtom = atom(
|
||||
null,
|
||||
(get, set, { blockId, fontSize }: { blockId: string; fontSize: number }) => {
|
||||
set(fontSizeAtomFamily(blockId), fontSize);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新主题名称
|
||||
*/
|
||||
export const setThemeNameAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ blockId, themeName }: { blockId: string; themeName: string },
|
||||
) => {
|
||||
set(termThemeNameAtomFamily(blockId), themeName);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新 Shell 进程状态
|
||||
*/
|
||||
export const setShellProcStatusAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ blockId, status }: { blockId: string; status: ShellProcStatus },
|
||||
) => {
|
||||
set(shellProcStatusAtomFamily(blockId), status);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新完整控制器运行时状态
|
||||
*/
|
||||
export const setShellProcFullStatusAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
blockId,
|
||||
status,
|
||||
}: { blockId: string; status: BlockControllerRuntimeStatus },
|
||||
) => {
|
||||
set(shellProcFullStatusAtomFamily(blockId), status);
|
||||
// 同步更新简化的状态
|
||||
set(shellProcStatusAtomFamily(blockId), status.shellProcStatus);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量更新连接状态映射
|
||||
*/
|
||||
export const updateConnStatusMapAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ connection, status }: { connection: string; status: ConnStatus },
|
||||
) => {
|
||||
const map = new Map(get(connStatusMapAtom));
|
||||
map.set(connection, status);
|
||||
set(connStatusMapAtom, map);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 清理原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 清理终端状态
|
||||
*
|
||||
* 当终端关闭时调用,清理相关的原子状态。
|
||||
*/
|
||||
export const cleanupTerminalStateAtom = atom(
|
||||
null,
|
||||
(get, set, blockId: string) => {
|
||||
// 重置为默认值
|
||||
set(termModeAtomFamily(blockId), "term");
|
||||
set(connStatusAtomFamily(blockId), createDefaultConnStatus());
|
||||
set(fontSizeAtomFamily(blockId), get(defaultFontSizeAtom));
|
||||
set(termThemeNameAtomFamily(blockId), get(defaultThemeNameAtom));
|
||||
set(shellProcStatusAtomFamily(blockId), "init");
|
||||
set(
|
||||
shellProcFullStatusAtomFamily(blockId),
|
||||
createDefaultRuntimeStatus(blockId),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @file events.ts
|
||||
* @description 终端后端事件订阅和状态同步
|
||||
* @module lib/terminal/store/events
|
||||
*
|
||||
* 订阅后端 Tauri 事件,自动更新 Jotai 原子状态。
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
ControllerStatusEvent,
|
||||
ConnChangeEvent,
|
||||
ConnStatus,
|
||||
} from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// 后端原始事件类型(snake_case)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 后端连接状态(snake_case 格式)
|
||||
*/
|
||||
interface RawConnStatus {
|
||||
status: string;
|
||||
connected: boolean;
|
||||
connection: string;
|
||||
has_connected: boolean;
|
||||
active_conn_num: number;
|
||||
error?: string;
|
||||
wsh_enabled: boolean;
|
||||
wsh_error?: string;
|
||||
no_wsh_reason?: string;
|
||||
wsh_version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端连接变更事件(snake_case 格式)
|
||||
*/
|
||||
interface RawConnChangeEvent {
|
||||
connection: string;
|
||||
status: RawConnStatus;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件名称常量
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 控制器状态事件名称
|
||||
*
|
||||
* 对应后端的 CONTROLLER_STATUS_EVENT
|
||||
*/
|
||||
export const CONTROLLER_STATUS_EVENT = "controller:status";
|
||||
|
||||
/**
|
||||
* 连接状态变更事件名称
|
||||
*
|
||||
* 对应后端的 CONN_CHANGE
|
||||
*/
|
||||
export const CONN_CHANGE_EVENT = "terminal:conn-change";
|
||||
|
||||
// ============================================================================
|
||||
// 事件处理器类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 控制器状态事件处理器
|
||||
*/
|
||||
export type ControllerStatusHandler = (event: ControllerStatusEvent) => void;
|
||||
|
||||
/**
|
||||
* 连接变更事件处理器
|
||||
*/
|
||||
export type ConnChangeHandler = (event: ConnChangeEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// 事件订阅函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 订阅控制器状态事件
|
||||
*
|
||||
* 监听后端发送的控制器状态更新事件。
|
||||
*
|
||||
* @param handler - 事件处理函数
|
||||
* @returns 取消订阅函数
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export async function subscribeControllerStatus(
|
||||
handler: ControllerStatusHandler,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<ControllerStatusEvent>(CONTROLLER_STATUS_EVENT, (event) => {
|
||||
handler(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅连接状态变更事件
|
||||
*
|
||||
* 监听后端发送的连接状态变更事件。
|
||||
*
|
||||
* @param handler - 事件处理函数
|
||||
* @returns 取消订阅函数
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export async function subscribeConnChange(
|
||||
handler: ConnChangeHandler,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RawConnChangeEvent>(CONN_CHANGE_EVENT, (event) => {
|
||||
// 转换后端的 snake_case 字段为前端的 camelCase
|
||||
const payload = event.payload;
|
||||
const status: ConnStatus = {
|
||||
status: payload.status.status as ConnStatus["status"],
|
||||
connected: payload.status.connected,
|
||||
connection: payload.status.connection,
|
||||
hasConnected: payload.status.has_connected,
|
||||
activeConnNum: payload.status.active_conn_num,
|
||||
error: payload.status.error,
|
||||
wshEnabled: payload.status.wsh_enabled,
|
||||
wshError: payload.status.wsh_error,
|
||||
noWshReason: payload.status.no_wsh_reason,
|
||||
wshVersion: payload.status.wsh_version,
|
||||
};
|
||||
|
||||
handler({
|
||||
connection: payload.connection,
|
||||
status,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定块的控制器状态事件
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param handler - 事件处理函数
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
export async function subscribeBlockControllerStatus(
|
||||
blockId: string,
|
||||
handler: ControllerStatusHandler,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<ControllerStatusEvent>(CONTROLLER_STATUS_EVENT, (event) => {
|
||||
if (event.payload.blockId === blockId) {
|
||||
handler(event.payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定连接的状态变更事件
|
||||
*
|
||||
* @param connection - 连接名称
|
||||
* @param handler - 事件处理函数
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
export async function subscribeConnectionStatus(
|
||||
connection: string,
|
||||
handler: ConnChangeHandler,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RawConnChangeEvent>(CONN_CHANGE_EVENT, (event) => {
|
||||
if (event.payload.connection === connection) {
|
||||
// 转换字段名
|
||||
const payload = event.payload;
|
||||
const status: ConnStatus = {
|
||||
status: payload.status.status as ConnStatus["status"],
|
||||
connected: payload.status.connected,
|
||||
connection: payload.status.connection,
|
||||
hasConnected: payload.status.has_connected,
|
||||
activeConnNum: payload.status.active_conn_num,
|
||||
error: payload.status.error,
|
||||
wshEnabled: payload.status.wsh_enabled,
|
||||
wshError: payload.status.wsh_error,
|
||||
noWshReason: payload.status.no_wsh_reason,
|
||||
wshVersion: payload.status.wsh_version,
|
||||
};
|
||||
|
||||
handler({
|
||||
connection: payload.connection,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件管理器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端事件管理器
|
||||
*
|
||||
* 管理所有终端相关的事件订阅,提供统一的订阅和清理接口。
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export class TerminalEventManager {
|
||||
private unlisteners: UnlistenFn[] = [];
|
||||
private controllerStatusHandlers: Map<string, ControllerStatusHandler[]> =
|
||||
new Map();
|
||||
private connChangeHandlers: Map<string, ConnChangeHandler[]> = new Map();
|
||||
private globalControllerStatusHandlers: ControllerStatusHandler[] = [];
|
||||
private globalConnChangeHandlers: ConnChangeHandler[] = [];
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化事件管理器
|
||||
*
|
||||
* 订阅全局事件并分发到各个处理器。
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 订阅控制器状态事件
|
||||
const unlistenController = await subscribeControllerStatus((event) => {
|
||||
// 调用全局处理器
|
||||
this.globalControllerStatusHandlers.forEach((handler) => handler(event));
|
||||
|
||||
// 调用特定块的处理器
|
||||
const handlers = this.controllerStatusHandlers.get(event.blockId);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(event));
|
||||
}
|
||||
});
|
||||
this.unlisteners.push(unlistenController);
|
||||
|
||||
// 订阅连接变更事件
|
||||
const unlistenConn = await subscribeConnChange((event) => {
|
||||
// 调用全局处理器
|
||||
this.globalConnChangeHandlers.forEach((handler) => handler(event));
|
||||
|
||||
// 调用特定连接的处理器
|
||||
const handlers = this.connChangeHandlers.get(event.connection);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(event));
|
||||
}
|
||||
});
|
||||
this.unlisteners.push(unlistenConn);
|
||||
|
||||
this.initialized = true;
|
||||
console.log("[TerminalEventManager] 已初始化事件订阅");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局控制器状态处理器
|
||||
*/
|
||||
onControllerStatus(handler: ControllerStatusHandler): () => void {
|
||||
this.globalControllerStatusHandlers.push(handler);
|
||||
return () => {
|
||||
const index = this.globalControllerStatusHandlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
this.globalControllerStatusHandlers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局连接变更处理器
|
||||
*/
|
||||
onConnChange(handler: ConnChangeHandler): () => void {
|
||||
this.globalConnChangeHandlers.push(handler);
|
||||
return () => {
|
||||
const index = this.globalConnChangeHandlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
this.globalConnChangeHandlers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册特定块的控制器状态处理器
|
||||
*/
|
||||
onBlockControllerStatus(
|
||||
blockId: string,
|
||||
handler: ControllerStatusHandler,
|
||||
): () => void {
|
||||
if (!this.controllerStatusHandlers.has(blockId)) {
|
||||
this.controllerStatusHandlers.set(blockId, []);
|
||||
}
|
||||
this.controllerStatusHandlers.get(blockId)!.push(handler);
|
||||
|
||||
return () => {
|
||||
const handlers = this.controllerStatusHandlers.get(blockId);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册特定连接的状态变更处理器
|
||||
*/
|
||||
onConnectionStatus(
|
||||
connection: string,
|
||||
handler: ConnChangeHandler,
|
||||
): () => void {
|
||||
if (!this.connChangeHandlers.has(connection)) {
|
||||
this.connChangeHandlers.set(connection, []);
|
||||
}
|
||||
this.connChangeHandlers.get(connection)!.push(handler);
|
||||
|
||||
return () => {
|
||||
const handlers = this.connChangeHandlers.get(connection);
|
||||
if (handlers) {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理特定块的所有处理器
|
||||
*/
|
||||
cleanupBlock(blockId: string): void {
|
||||
this.controllerStatusHandlers.delete(blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理特定连接的所有处理器
|
||||
*/
|
||||
cleanupConnection(connection: string): void {
|
||||
this.connChangeHandlers.delete(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁事件管理器
|
||||
*
|
||||
* 取消所有事件订阅并清理资源。
|
||||
*/
|
||||
dispose(): void {
|
||||
this.unlisteners.forEach((unlisten) => unlisten());
|
||||
this.unlisteners = [];
|
||||
this.controllerStatusHandlers.clear();
|
||||
this.connChangeHandlers.clear();
|
||||
this.globalControllerStatusHandlers = [];
|
||||
this.globalConnChangeHandlers = [];
|
||||
this.initialized = false;
|
||||
console.log("[TerminalEventManager] 已销毁事件订阅");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局终端事件管理器实例
|
||||
*/
|
||||
export const terminalEventManager = new TerminalEventManager();
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* @file hooks.ts
|
||||
* @description 终端状态管理 React Hooks
|
||||
* @module lib/terminal/store/hooks
|
||||
*
|
||||
* 提供 React Hooks 用于在组件中使用终端状态和事件订阅。
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
termModeAtomFamily,
|
||||
connStatusAtomFamily,
|
||||
fontSizeAtomFamily,
|
||||
termThemeNameAtomFamily,
|
||||
shellProcStatusAtomFamily,
|
||||
shellProcFullStatusAtomFamily,
|
||||
setTermModeAtom,
|
||||
setConnStatusAtom,
|
||||
setFontSizeAtom,
|
||||
setThemeNameAtom,
|
||||
setShellProcFullStatusAtom,
|
||||
cleanupTerminalStateAtom,
|
||||
connStatusMapAtom,
|
||||
updateConnStatusMapAtom,
|
||||
} from "./atoms";
|
||||
import type { TermMode, ConnStatus } from "./types";
|
||||
import {
|
||||
terminalEventManager,
|
||||
type ControllerStatusHandler,
|
||||
type ConnChangeHandler,
|
||||
} from "./events";
|
||||
|
||||
// ============================================================================
|
||||
// 终端状态 Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 使用终端模式状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns [termMode, setTermMode]
|
||||
*/
|
||||
export function useTermMode(blockId: string) {
|
||||
const termMode = useAtomValue(termModeAtomFamily(blockId));
|
||||
const setTermModeAction = useSetAtom(setTermModeAtom);
|
||||
|
||||
const setTermMode = useCallback(
|
||||
(mode: TermMode) => {
|
||||
setTermModeAction({ blockId, mode });
|
||||
},
|
||||
[blockId, setTermModeAction],
|
||||
);
|
||||
|
||||
return [termMode, setTermMode] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用连接状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns [connStatus, setConnStatus]
|
||||
*/
|
||||
export function useConnStatus(blockId: string) {
|
||||
const connStatus = useAtomValue(connStatusAtomFamily(blockId));
|
||||
const setConnStatusAction = useSetAtom(setConnStatusAtom);
|
||||
|
||||
const setConnStatus = useCallback(
|
||||
(status: ConnStatus) => {
|
||||
setConnStatusAction({ blockId, status });
|
||||
},
|
||||
[blockId, setConnStatusAction],
|
||||
);
|
||||
|
||||
return [connStatus, setConnStatus] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用字体大小状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns [fontSize, setFontSize]
|
||||
*/
|
||||
export function useFontSize(blockId: string) {
|
||||
const fontSize = useAtomValue(fontSizeAtomFamily(blockId));
|
||||
const setFontSizeAction = useSetAtom(setFontSizeAtom);
|
||||
|
||||
const setFontSize = useCallback(
|
||||
(size: number) => {
|
||||
setFontSizeAction({ blockId, fontSize: size });
|
||||
},
|
||||
[blockId, setFontSizeAction],
|
||||
);
|
||||
|
||||
return [fontSize, setFontSize] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用主题名称状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns [themeName, setThemeName]
|
||||
*/
|
||||
export function useThemeName(blockId: string) {
|
||||
const themeName = useAtomValue(termThemeNameAtomFamily(blockId));
|
||||
const setThemeNameAction = useSetAtom(setThemeNameAtom);
|
||||
|
||||
const setThemeName = useCallback(
|
||||
(name: string) => {
|
||||
setThemeNameAction({ blockId, themeName: name });
|
||||
},
|
||||
[blockId, setThemeNameAction],
|
||||
);
|
||||
|
||||
return [themeName, setThemeName] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Shell 进程状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns shellProcStatus
|
||||
*/
|
||||
export function useShellProcStatus(blockId: string) {
|
||||
return useAtomValue(shellProcStatusAtomFamily(blockId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用完整的控制器运行时状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @returns runtimeStatus
|
||||
*/
|
||||
export function useShellProcFullStatus(blockId: string) {
|
||||
return useAtomValue(shellProcFullStatusAtomFamily(blockId));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件订阅 Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 订阅控制器状态事件并自动更新原子状态
|
||||
*
|
||||
* 在组件挂载时订阅事件,卸载时自动取消订阅。
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export function useControllerStatusSync(blockId: string) {
|
||||
const setShellProcFullStatus = useSetAtom(setShellProcFullStatusAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const handler: ControllerStatusHandler = (event) => {
|
||||
if (event.blockId === blockId) {
|
||||
setShellProcFullStatus({
|
||||
blockId,
|
||||
status: {
|
||||
blockId: event.blockId,
|
||||
version: event.version,
|
||||
shellProcStatus: event.shellProcStatus,
|
||||
shellProcConnName: event.shellProcConnName,
|
||||
shellProcExitCode: event.shellProcExitCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = terminalEventManager.onBlockControllerStatus(
|
||||
blockId,
|
||||
handler,
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [blockId, setShellProcFullStatus]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅连接状态变更事件并自动更新原子状态
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param connection - 连接名称(可选,如果提供则只监听该连接)
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export function useConnStatusSync(blockId: string, connection?: string) {
|
||||
const setConnStatus = useSetAtom(setConnStatusAtom);
|
||||
const updateConnStatusMap = useSetAtom(updateConnStatusMapAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const handler: ConnChangeHandler = (event) => {
|
||||
// 更新全局连接状态映射
|
||||
updateConnStatusMap({
|
||||
connection: event.connection,
|
||||
status: event.status,
|
||||
});
|
||||
|
||||
// 如果指定了连接名称,只更新匹配的块
|
||||
if (!connection || event.connection === connection) {
|
||||
setConnStatus({ blockId, status: event.status });
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribe: () => void;
|
||||
if (connection) {
|
||||
unsubscribe = terminalEventManager.onConnectionStatus(
|
||||
connection,
|
||||
handler,
|
||||
);
|
||||
} else {
|
||||
unsubscribe = terminalEventManager.onConnChange(handler);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [blockId, connection, setConnStatus, updateConnStatusMap]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合使用终端状态和事件订阅
|
||||
*
|
||||
* 自动订阅控制器状态和连接状态事件,并在组件卸载时清理。
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param connection - 连接名称(可选)
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export function useTerminalState(blockId: string, connection?: string) {
|
||||
// 订阅事件
|
||||
useControllerStatusSync(blockId);
|
||||
useConnStatusSync(blockId, connection);
|
||||
|
||||
// 返回状态
|
||||
const termMode = useAtomValue(termModeAtomFamily(blockId));
|
||||
const connStatus = useAtomValue(connStatusAtomFamily(blockId));
|
||||
const fontSize = useAtomValue(fontSizeAtomFamily(blockId));
|
||||
const themeName = useAtomValue(termThemeNameAtomFamily(blockId));
|
||||
const shellProcStatus = useAtomValue(shellProcStatusAtomFamily(blockId));
|
||||
const runtimeStatus = useAtomValue(shellProcFullStatusAtomFamily(blockId));
|
||||
|
||||
return {
|
||||
termMode,
|
||||
connStatus,
|
||||
fontSize,
|
||||
themeName,
|
||||
shellProcStatus,
|
||||
runtimeStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理终端状态
|
||||
*
|
||||
* 在终端关闭时调用,清理相关的原子状态。
|
||||
*
|
||||
* @returns cleanup 函数
|
||||
*/
|
||||
export function useTerminalCleanup() {
|
||||
const cleanup = useSetAtom(cleanupTerminalStateAtom);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 全局事件管理 Hook
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 初始化终端事件管理器
|
||||
*
|
||||
* 应在应用根组件中调用一次。
|
||||
*
|
||||
* _Requirements: 9.6_
|
||||
*/
|
||||
export function useTerminalEventManager() {
|
||||
useEffect(() => {
|
||||
// 初始化事件管理器
|
||||
terminalEventManager.initialize().catch((error) => {
|
||||
console.error("[useTerminalEventManager] 初始化失败:", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// 组件卸载时不销毁事件管理器,因为它是全局的
|
||||
// 只有在应用退出时才需要销毁
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有连接状态映射
|
||||
*
|
||||
* @returns 连接状态映射
|
||||
*/
|
||||
export function useConnStatusMap() {
|
||||
return useAtomValue(connStatusMapAtom);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 多输入模式 Hooks
|
||||
// _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
multiInputEnabledAtomFamily,
|
||||
broadcastableTerminalsAtomFamily,
|
||||
toggleMultiInputAtom,
|
||||
registerTerminalAtom,
|
||||
unregisterTerminalAtom,
|
||||
broadcastInput,
|
||||
type TerminalInfo,
|
||||
} from "./multiInput";
|
||||
|
||||
/**
|
||||
* 使用多输入模式状态
|
||||
*
|
||||
* @param tabId - 标签页 ID
|
||||
* @returns [enabled, toggle]
|
||||
*
|
||||
* _Requirements: 10.3, 10.4, 10.5_
|
||||
*/
|
||||
export function useMultiInput(tabId: string) {
|
||||
const enabled = useAtomValue(multiInputEnabledAtomFamily(tabId));
|
||||
const toggle = useSetAtom(toggleMultiInputAtom);
|
||||
|
||||
const toggleMultiInput = useCallback(() => {
|
||||
toggle(tabId);
|
||||
}, [tabId, toggle]);
|
||||
|
||||
return [enabled, toggleMultiInput] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用可广播的终端列表
|
||||
*
|
||||
* @param tabId - 标签页 ID
|
||||
* @returns 可广播的终端列表
|
||||
*
|
||||
* _Requirements: 10.2_
|
||||
*/
|
||||
export function useBroadcastableTerminals(tabId: string) {
|
||||
return useAtomValue(broadcastableTerminalsAtomFamily(tabId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册终端到多输入系统
|
||||
*
|
||||
* 在终端组件挂载时调用,卸载时自动取消注册。
|
||||
*
|
||||
* @param terminal - 终端信息
|
||||
*
|
||||
* _Requirements: 10.1_
|
||||
*/
|
||||
export function useRegisterTerminal(terminal: TerminalInfo) {
|
||||
const register = useSetAtom(registerTerminalAtom);
|
||||
const unregister = useSetAtom(unregisterTerminalAtom);
|
||||
|
||||
useEffect(() => {
|
||||
register(terminal);
|
||||
|
||||
return () => {
|
||||
unregister({ tabId: terminal.tabId, blockId: terminal.blockId });
|
||||
};
|
||||
}, [
|
||||
terminal.blockId,
|
||||
terminal.tabId,
|
||||
terminal.isBasicTerminal,
|
||||
terminal.isActive,
|
||||
register,
|
||||
unregister,
|
||||
terminal,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用多输入广播功能
|
||||
*
|
||||
* @param tabId - 标签页 ID
|
||||
* @param currentBlockId - 当前终端的块 ID
|
||||
* @returns 广播输入函数
|
||||
*
|
||||
* _Requirements: 10.1_
|
||||
*/
|
||||
export function useMultiInputBroadcast(tabId: string, _currentBlockId: string) {
|
||||
const multiInputEnabled = useAtomValue(multiInputEnabledAtomFamily(tabId));
|
||||
const broadcastableTerminals = useAtomValue(
|
||||
broadcastableTerminalsAtomFamily(tabId),
|
||||
);
|
||||
|
||||
const broadcast = useCallback(
|
||||
async (data: string) => {
|
||||
if (multiInputEnabled && broadcastableTerminals.length > 0) {
|
||||
// 多输入模式:广播到所有基础终端
|
||||
return broadcastInput(tabId, data, broadcastableTerminals);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
[tabId, multiInputEnabled, broadcastableTerminals],
|
||||
);
|
||||
|
||||
return {
|
||||
multiInputEnabled,
|
||||
broadcastableTerminals,
|
||||
broadcast,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 终端状态管理模块入口
|
||||
* @module lib/terminal/store
|
||||
*
|
||||
* 使用 Jotai 进行原子化状态管理,为每个终端维护独立的原子状态。
|
||||
*
|
||||
* ## 功能
|
||||
* - 终端模式状态(term/vdom)
|
||||
* - 连接状态管理
|
||||
* - 字体大小配置
|
||||
* - 主题配置
|
||||
* - Shell 进程状态
|
||||
* - 后端事件订阅和状态同步
|
||||
*
|
||||
* _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6_
|
||||
*/
|
||||
|
||||
// 导出原子状态
|
||||
export * from "./atoms";
|
||||
|
||||
// 导出类型
|
||||
export * from "./types";
|
||||
|
||||
// 导出事件订阅
|
||||
export * from "./events";
|
||||
|
||||
// 导出 React Hooks
|
||||
export * from "./hooks";
|
||||
|
||||
// 导出视图模型
|
||||
export * from "./viewmodel";
|
||||
|
||||
// 导出多输入模式
|
||||
export * from "./multiInput";
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @file multiInput.ts
|
||||
* @description 多输入模式状态管理
|
||||
* @module lib/terminal/store/multiInput
|
||||
*
|
||||
* 实现多输入模式的状态管理和输入广播逻辑。
|
||||
*
|
||||
* _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { writeToTerminalRaw, encodeBase64 } from "@/lib/terminal-api";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端信息
|
||||
*/
|
||||
export interface TerminalInfo {
|
||||
/** 块 ID(会话 ID) */
|
||||
blockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** 是否为基础终端(非 VDOM、非 cmd 控制器)
|
||||
* _Requirements: 10.2_
|
||||
*/
|
||||
isBasicTerminal: boolean;
|
||||
/** 是否活跃 */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 多输入模式原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 多输入模式状态原子族(按 tabId 索引)
|
||||
*
|
||||
* 多输入模式状态存储在 Tab 级别。
|
||||
*
|
||||
* _Requirements: 10.5_
|
||||
*/
|
||||
export const multiInputEnabledAtomFamily = atomFamily((_tabId: string) =>
|
||||
atom<boolean>(false),
|
||||
);
|
||||
|
||||
/**
|
||||
* 活跃终端列表原子族(按 tabId 索引)
|
||||
*
|
||||
* 存储当前 Tab 下所有活跃的基础终端。
|
||||
*/
|
||||
export const activeTerminalsAtomFamily = atomFamily((_tabId: string) =>
|
||||
atom<TerminalInfo[]>([]),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 操作原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 切换多输入模式
|
||||
*
|
||||
* _Requirements: 10.4_
|
||||
*/
|
||||
export const toggleMultiInputAtom = atom(null, (get, set, tabId: string) => {
|
||||
const currentValue = get(multiInputEnabledAtomFamily(tabId));
|
||||
set(multiInputEnabledAtomFamily(tabId), !currentValue);
|
||||
});
|
||||
|
||||
/**
|
||||
* 启用多输入模式
|
||||
*/
|
||||
export const enableMultiInputAtom = atom(null, (get, set, tabId: string) => {
|
||||
set(multiInputEnabledAtomFamily(tabId), true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 禁用多输入模式
|
||||
*/
|
||||
export const disableMultiInputAtom = atom(null, (get, set, tabId: string) => {
|
||||
set(multiInputEnabledAtomFamily(tabId), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* 注册终端到活跃列表
|
||||
*/
|
||||
export const registerTerminalAtom = atom(
|
||||
null,
|
||||
(get, set, terminal: TerminalInfo) => {
|
||||
const terminals = get(activeTerminalsAtomFamily(terminal.tabId));
|
||||
const exists = terminals.some((t) => t.blockId === terminal.blockId);
|
||||
|
||||
if (!exists) {
|
||||
set(activeTerminalsAtomFamily(terminal.tabId), [...terminals, terminal]);
|
||||
} else {
|
||||
// 更新现有终端信息
|
||||
set(
|
||||
activeTerminalsAtomFamily(terminal.tabId),
|
||||
terminals.map((t) => (t.blockId === terminal.blockId ? terminal : t)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 从活跃列表移除终端
|
||||
*/
|
||||
export const unregisterTerminalAtom = atom(
|
||||
null,
|
||||
(get, set, { tabId, blockId }: { tabId: string; blockId: string }) => {
|
||||
const terminals = get(activeTerminalsAtomFamily(tabId));
|
||||
set(
|
||||
activeTerminalsAtomFamily(tabId),
|
||||
terminals.filter((t) => t.blockId !== blockId),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新终端活跃状态
|
||||
*/
|
||||
export const updateTerminalActiveAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
tabId,
|
||||
blockId,
|
||||
isActive,
|
||||
}: { tabId: string; blockId: string; isActive: boolean },
|
||||
) => {
|
||||
const terminals = get(activeTerminalsAtomFamily(tabId));
|
||||
set(
|
||||
activeTerminalsAtomFamily(tabId),
|
||||
terminals.map((t) => (t.blockId === blockId ? { ...t, isActive } : t)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 派生原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取可广播的终端列表
|
||||
*
|
||||
* 只返回基础终端(非 VDOM、非 cmd 控制器)。
|
||||
*
|
||||
* _Requirements: 10.2_
|
||||
*/
|
||||
export const broadcastableTerminalsAtomFamily = atomFamily((tabId: string) =>
|
||||
atom((get) => {
|
||||
const terminals = get(activeTerminalsAtomFamily(tabId));
|
||||
return terminals.filter((t) => t.isBasicTerminal && t.isActive);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取多输入模式下的目标终端数量
|
||||
*/
|
||||
export const multiInputTargetCountAtomFamily = atomFamily((tabId: string) =>
|
||||
atom((get) => {
|
||||
const enabled = get(multiInputEnabledAtomFamily(tabId));
|
||||
if (!enabled) return 0;
|
||||
|
||||
const terminals = get(broadcastableTerminalsAtomFamily(tabId));
|
||||
return terminals.length;
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 输入广播函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 广播输入到所有活跃的基础终端
|
||||
*
|
||||
* _Requirements: 10.1_
|
||||
*
|
||||
* @param tabId - 标签页 ID
|
||||
* @param data - 输入数据(字符串)
|
||||
* @param terminals - 目标终端列表
|
||||
* @returns 成功发送的终端数量
|
||||
*/
|
||||
export async function broadcastInput(
|
||||
tabId: string,
|
||||
data: string,
|
||||
terminals: TerminalInfo[],
|
||||
): Promise<number> {
|
||||
const base64Data = encodeBase64(data);
|
||||
let successCount = 0;
|
||||
|
||||
// 并行发送到所有终端
|
||||
const results = await Promise.allSettled(
|
||||
terminals.map(async (terminal) => {
|
||||
try {
|
||||
await writeToTerminalRaw(terminal.blockId, base64Data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[broadcastInput] 发送到终端 ${terminal.blockId} 失败:`,
|
||||
err,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// 统计成功数量
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建多输入处理器
|
||||
*
|
||||
* 返回一个函数,用于处理输入并根据多输入模式决定是否广播。
|
||||
*
|
||||
* @param tabId - 标签页 ID
|
||||
* @param currentBlockId - 当前终端的块 ID
|
||||
* @param getMultiInputEnabled - 获取多输入模式状态的函数
|
||||
* @param getBroadcastableTerminals - 获取可广播终端列表的函数
|
||||
* @returns 输入处理函数
|
||||
*/
|
||||
export function createMultiInputHandler(
|
||||
tabId: string,
|
||||
currentBlockId: string,
|
||||
getMultiInputEnabled: () => boolean,
|
||||
getBroadcastableTerminals: () => TerminalInfo[],
|
||||
) {
|
||||
return async (data: string): Promise<void> => {
|
||||
const multiInputEnabled = getMultiInputEnabled();
|
||||
|
||||
if (multiInputEnabled) {
|
||||
// 多输入模式:广播到所有基础终端
|
||||
const terminals = getBroadcastableTerminals();
|
||||
await broadcastInput(tabId, data, terminals);
|
||||
} else {
|
||||
// 单输入模式:只发送到当前终端
|
||||
const base64Data = encodeBase64(data);
|
||||
await writeToTerminalRaw(currentBlockId, base64Data);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 终端状态类型定义
|
||||
* @module lib/terminal/store/types
|
||||
*
|
||||
* 定义终端状态管理所需的类型。
|
||||
*
|
||||
* _Requirements: 9.2, 9.3, 9.4, 9.5_
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 终端模式
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端模式类型
|
||||
*
|
||||
* - term: 标准终端模式
|
||||
* - vdom: 虚拟 DOM 模式(终端内嵌 UI)
|
||||
*
|
||||
* _Requirements: 14.1_
|
||||
*/
|
||||
export type TermMode = "term" | "vdom";
|
||||
|
||||
// ============================================================================
|
||||
// 连接状态
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 连接状态字符串
|
||||
*
|
||||
* _Requirements: 7.2_
|
||||
*/
|
||||
export type ConnectionStatusType =
|
||||
| "init"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* 连接状态详情
|
||||
*
|
||||
* 对应后端的 ConnStatus 结构体。
|
||||
*
|
||||
* _Requirements: 7.1_
|
||||
*/
|
||||
export interface ConnStatus {
|
||||
/** 状态字符串 */
|
||||
status: ConnectionStatusType;
|
||||
/** 是否已连接 */
|
||||
connected: boolean;
|
||||
/** 连接名称 */
|
||||
connection: string;
|
||||
/** 是否曾经连接成功 */
|
||||
hasConnected: boolean;
|
||||
/** 活跃连接数 */
|
||||
activeConnNum: number;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** wsh 是否启用 */
|
||||
wshEnabled: boolean;
|
||||
/** wsh 错误信息 */
|
||||
wshError?: string;
|
||||
/** 不使用 wsh 的原因 */
|
||||
noWshReason?: string;
|
||||
/** wsh 版本 */
|
||||
wshVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认连接状态
|
||||
*/
|
||||
export function createDefaultConnStatus(connection: string = ""): ConnStatus {
|
||||
return {
|
||||
status: "init",
|
||||
connected: false,
|
||||
connection,
|
||||
hasConnected: false,
|
||||
activeConnNum: 0,
|
||||
wshEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shell 进程状态
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Shell 进程状态类型
|
||||
*
|
||||
* _Requirements: 1.2, 1.3_
|
||||
*/
|
||||
export type ShellProcStatus = "init" | "running" | "done";
|
||||
|
||||
/**
|
||||
* 块控制器运行时状态
|
||||
*
|
||||
* 对应后端的 BlockControllerRuntimeStatus 结构体。
|
||||
*
|
||||
* _Requirements: 1.8_
|
||||
*/
|
||||
export interface BlockControllerRuntimeStatus {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 状态版本号 */
|
||||
version: number;
|
||||
/** Shell 进程状态 */
|
||||
shellProcStatus: ShellProcStatus;
|
||||
/** Shell 进程连接名称 */
|
||||
shellProcConnName?: string;
|
||||
/** Shell 进程退出码 */
|
||||
shellProcExitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认控制器运行时状态
|
||||
*/
|
||||
export function createDefaultRuntimeStatus(
|
||||
blockId: string,
|
||||
): BlockControllerRuntimeStatus {
|
||||
return {
|
||||
blockId,
|
||||
version: 0,
|
||||
shellProcStatus: "init",
|
||||
shellProcExitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 终端视图模型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端视图模型状态
|
||||
*
|
||||
* 包含单个终端的所有状态。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export interface TerminalViewState {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** 终端模式 */
|
||||
termMode: TermMode;
|
||||
/** 连接状态 */
|
||||
connStatus: ConnStatus;
|
||||
/** 字体大小 */
|
||||
fontSize: number;
|
||||
/** 主题名称 */
|
||||
themeName: string;
|
||||
/** Shell 进程状态 */
|
||||
shellProcStatus: ShellProcStatus;
|
||||
/** 完整的控制器运行时状态 */
|
||||
runtimeStatus: BlockControllerRuntimeStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认终端视图状态
|
||||
*/
|
||||
export function createDefaultTerminalViewState(
|
||||
blockId: string,
|
||||
tabId: string,
|
||||
): TerminalViewState {
|
||||
return {
|
||||
blockId,
|
||||
tabId,
|
||||
termMode: "term",
|
||||
connStatus: createDefaultConnStatus(),
|
||||
fontSize: 14,
|
||||
themeName: "tokyo-night",
|
||||
shellProcStatus: "init",
|
||||
runtimeStatus: createDefaultRuntimeStatus(blockId),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 后端事件类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 控制器状态事件
|
||||
*
|
||||
* 对应后端的 ControllerStatusEvent。
|
||||
*/
|
||||
export interface ControllerStatusEvent {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 状态版本号 */
|
||||
version: number;
|
||||
/** Shell 进程状态 */
|
||||
shellProcStatus: ShellProcStatus;
|
||||
/** Shell 进程连接名称 */
|
||||
shellProcConnName?: string;
|
||||
/** Shell 进程退出码 */
|
||||
shellProcExitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接变更事件
|
||||
*
|
||||
* 对应后端的 ConnChangeEvent。
|
||||
*/
|
||||
export interface ConnChangeEvent {
|
||||
/** 连接名称 */
|
||||
connection: string;
|
||||
/** 连接状态详情 */
|
||||
status: ConnStatus;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file viewmodel.ts
|
||||
* @description 终端视图模型
|
||||
* @module lib/terminal/store/viewmodel
|
||||
*
|
||||
* 实现 TermViewModel,封装终端视图的状态和操作。
|
||||
* 对齐 waveterm 的 TermViewModel 架构。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
|
||||
import { atom, type Atom, type PrimitiveAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import type {
|
||||
TermMode,
|
||||
ConnStatus,
|
||||
ShellProcStatus,
|
||||
BlockControllerRuntimeStatus,
|
||||
} from "./types";
|
||||
import {
|
||||
termModeAtomFamily,
|
||||
connStatusAtomFamily,
|
||||
fontSizeAtomFamily,
|
||||
termThemeNameAtomFamily,
|
||||
shellProcStatusAtomFamily,
|
||||
shellProcFullStatusAtomFamily,
|
||||
} from "./atoms";
|
||||
|
||||
// ============================================================================
|
||||
// TermViewModel 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 终端视图模型接口
|
||||
*
|
||||
* 对齐 waveterm 的 TermViewModel 设计。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export interface TermViewModel {
|
||||
/** 块 ID */
|
||||
blockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
|
||||
// Jotai 原子
|
||||
/** 终端模式原子 */
|
||||
termModeAtom: PrimitiveAtom<TermMode>;
|
||||
/** 连接状态原子 */
|
||||
connStatusAtom: PrimitiveAtom<ConnStatus>;
|
||||
/** 字体大小原子 */
|
||||
fontSizeAtom: PrimitiveAtom<number>;
|
||||
/** 主题名称原子 */
|
||||
termThemeNameAtom: PrimitiveAtom<string>;
|
||||
/** Shell 进程状态原子 */
|
||||
shellProcStatusAtom: PrimitiveAtom<ShellProcStatus>;
|
||||
/** 完整控制器运行时状态原子 */
|
||||
shellProcFullStatusAtom: PrimitiveAtom<BlockControllerRuntimeStatus>;
|
||||
|
||||
// 派生原子
|
||||
/** 是否已连接 */
|
||||
isConnectedAtom: Atom<boolean>;
|
||||
/** 是否正在运行 */
|
||||
isRunningAtom: Atom<boolean>;
|
||||
/** 是否已完成 */
|
||||
isDoneAtom: Atom<boolean>;
|
||||
/** 是否有错误 */
|
||||
hasErrorAtom: Atom<boolean>;
|
||||
/** 退出码 */
|
||||
exitCodeAtom: Atom<number>;
|
||||
/** 连接名称 */
|
||||
connectionNameAtom: Atom<string | undefined>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TermViewModel 工厂函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建终端视图模型
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param tabId - 标签页 ID
|
||||
* @returns 终端视图模型
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export function createTermViewModel(
|
||||
blockId: string,
|
||||
tabId: string,
|
||||
): TermViewModel {
|
||||
// 获取基础原子
|
||||
const termModeAtom = termModeAtomFamily(blockId);
|
||||
const connStatusAtom = connStatusAtomFamily(blockId);
|
||||
const fontSizeAtom = fontSizeAtomFamily(blockId);
|
||||
const termThemeNameAtom = termThemeNameAtomFamily(blockId);
|
||||
const shellProcStatusAtom = shellProcStatusAtomFamily(blockId);
|
||||
const shellProcFullStatusAtom = shellProcFullStatusAtomFamily(blockId);
|
||||
|
||||
// 创建派生原子
|
||||
const isConnectedAtom = atom((get) => {
|
||||
const connStatus = get(connStatusAtom);
|
||||
return connStatus.connected;
|
||||
});
|
||||
|
||||
const isRunningAtom = atom((get) => {
|
||||
const status = get(shellProcStatusAtom);
|
||||
return status === "running";
|
||||
});
|
||||
|
||||
const isDoneAtom = atom((get) => {
|
||||
const status = get(shellProcStatusAtom);
|
||||
return status === "done";
|
||||
});
|
||||
|
||||
const hasErrorAtom = atom((get) => {
|
||||
const connStatus = get(connStatusAtom);
|
||||
return connStatus.status === "error" || !!connStatus.error;
|
||||
});
|
||||
|
||||
const exitCodeAtom = atom((get) => {
|
||||
const fullStatus = get(shellProcFullStatusAtom);
|
||||
return fullStatus.shellProcExitCode;
|
||||
});
|
||||
|
||||
const connectionNameAtom = atom((get) => {
|
||||
const fullStatus = get(shellProcFullStatusAtom);
|
||||
return fullStatus.shellProcConnName;
|
||||
});
|
||||
|
||||
return {
|
||||
blockId,
|
||||
tabId,
|
||||
termModeAtom,
|
||||
connStatusAtom,
|
||||
fontSizeAtom,
|
||||
termThemeNameAtom,
|
||||
shellProcStatusAtom,
|
||||
shellProcFullStatusAtom,
|
||||
isConnectedAtom,
|
||||
isRunningAtom,
|
||||
isDoneAtom,
|
||||
hasErrorAtom,
|
||||
exitCodeAtom,
|
||||
connectionNameAtom,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TermViewModel 原子族
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TermViewModel 缓存
|
||||
*
|
||||
* 使用 Map 缓存已创建的 TermViewModel 实例。
|
||||
*/
|
||||
const viewModelCache = new Map<string, TermViewModel>();
|
||||
|
||||
/**
|
||||
* 获取或创建 TermViewModel
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param tabId - 标签页 ID
|
||||
* @returns 终端视图模型
|
||||
*/
|
||||
export function getOrCreateTermViewModel(
|
||||
blockId: string,
|
||||
tabId: string,
|
||||
): TermViewModel {
|
||||
const key = `${tabId}:${blockId}`;
|
||||
let viewModel = viewModelCache.get(key);
|
||||
|
||||
if (!viewModel) {
|
||||
viewModel = createTermViewModel(blockId, tabId);
|
||||
viewModelCache.set(key, viewModel);
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 TermViewModel
|
||||
*
|
||||
* @param blockId - 块 ID
|
||||
* @param tabId - 标签页 ID
|
||||
*/
|
||||
export function cleanupTermViewModel(blockId: string, tabId: string): void {
|
||||
const key = `${tabId}:${blockId}`;
|
||||
viewModelCache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 TermViewModel
|
||||
*/
|
||||
export function cleanupAllTermViewModels(): void {
|
||||
viewModelCache.clear();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TermViewModel 原子族(按 blockId + tabId 索引)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TermViewModel 原子族
|
||||
*
|
||||
* 用于在组件中获取 TermViewModel。
|
||||
*
|
||||
* _Requirements: 9.7_
|
||||
*/
|
||||
export const termViewModelAtomFamily = atomFamily(
|
||||
({ blockId, tabId }: { blockId: string; tabId: string }) =>
|
||||
atom(() => getOrCreateTermViewModel(blockId, tabId)),
|
||||
);
|
||||
@@ -4,6 +4,8 @@
|
||||
* @module lib/terminal/themes
|
||||
*
|
||||
* 提供多种终端主题,参考 waveterm 的主题系统。
|
||||
*
|
||||
* _Requirements: 8.7, 8.8, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_
|
||||
*/
|
||||
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
@@ -301,8 +303,18 @@ export function getThemeList(): TerminalTheme[] {
|
||||
|
||||
/** 主题存储键 */
|
||||
const THEME_STORAGE_KEY = "terminal-theme";
|
||||
/** 字体大小存储键 */
|
||||
const FONT_SIZE_STORAGE_KEY = "terminal-font-size";
|
||||
/** 默认字体大小 */
|
||||
export const DEFAULT_FONT_SIZE = 14;
|
||||
/** 最小字体大小 */
|
||||
export const MIN_FONT_SIZE = 8;
|
||||
/** 最大字体大小 */
|
||||
export const MAX_FONT_SIZE = 32;
|
||||
|
||||
/** 保存主题到本地存储 */
|
||||
/** 保存主题到本地存储
|
||||
* _Requirements: 12.4_
|
||||
*/
|
||||
export function saveThemePreference(name: ThemeName): void {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, name);
|
||||
}
|
||||
@@ -315,3 +327,48 @@ export function loadThemePreference(): ThemeName {
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
/** 保存字体大小到本地存储
|
||||
* _Requirements: 8.8_
|
||||
*/
|
||||
export function saveFontSizePreference(size: number): void {
|
||||
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||
localStorage.setItem(FONT_SIZE_STORAGE_KEY, String(clampedSize));
|
||||
}
|
||||
|
||||
/** 从本地存储加载字体大小 */
|
||||
export function loadFontSizePreference(): number {
|
||||
const saved = localStorage.getItem(FONT_SIZE_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const size = parseInt(saved, 10);
|
||||
if (!isNaN(size) && size >= MIN_FONT_SIZE && size <= MAX_FONT_SIZE) {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
/** 应用透明度到主题
|
||||
* _Requirements: 12.3_
|
||||
*/
|
||||
export function applyThemeOpacity(theme: ITheme, opacity: number): ITheme {
|
||||
if (opacity >= 1) return theme;
|
||||
|
||||
// 将背景色转换为带透明度的颜色
|
||||
const bgColor = theme.background || "#000000";
|
||||
const alpha = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
// 如果是 hex 颜色,转换为 rgba
|
||||
if (bgColor.startsWith("#")) {
|
||||
const hex = bgColor.slice(1);
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return {
|
||||
...theme,
|
||||
background: `rgba(${r}, ${g}, ${b}, ${alpha})`,
|
||||
};
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# VDOM 模块
|
||||
|
||||
终端内嵌虚拟 DOM 支持模块。
|
||||
|
||||
## 概述
|
||||
|
||||
本模块实现终端的 VDOM 模式,允许在终端内嵌入 React 组件,实现丰富的交互式 UI。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| `types.ts` | VDOM 类型定义 |
|
||||
| `store.ts` | VDOM 状态管理(Jotai 原子) |
|
||||
| `index.ts` | 模块导出 |
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 终端模式切换
|
||||
- 支持 `term`(终端)和 `vdom`(虚拟 DOM)两种模式
|
||||
- 通过 `term:mode` 配置或 UI 按钮切换
|
||||
|
||||
### VDOM 块
|
||||
- 支持在终端内嵌入多个 VDOM 块
|
||||
- 每个块可配置标题、位置、大小
|
||||
- 支持关闭、拖拽、调整大小(可配置)
|
||||
|
||||
### 焦点管理
|
||||
- 独立的焦点管理系统
|
||||
- 支持键盘导航
|
||||
- Escape 键关闭当前块
|
||||
|
||||
### 工具栏
|
||||
- 支持顶部/底部工具栏
|
||||
- 可配置工具栏项目
|
||||
|
||||
## 使用示例
|
||||
|
||||
```tsx
|
||||
import { TerminalView } from "@/components/terminal";
|
||||
|
||||
// 启用 VDOM 模式切换
|
||||
<TerminalView
|
||||
blockId="session-1"
|
||||
tabId="tab-1"
|
||||
showModeSwitch={true}
|
||||
initialTermMode="term"
|
||||
onModeChange={(mode) => console.log("模式切换:", mode)}
|
||||
/>
|
||||
```
|
||||
|
||||
## 需求追溯
|
||||
|
||||
- Requirements 14.1: 终端模式配置
|
||||
- Requirements 14.2: 模式切换 UI
|
||||
- Requirements 14.3: VDOM 块渲染
|
||||
- Requirements 14.4: 焦点管理
|
||||
- Requirements 14.5: 块关闭自动切换
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description VDOM 模块导出
|
||||
* @module lib/terminal/vdom
|
||||
*
|
||||
* 导出 VDOM 相关的类型、状态和工具函数。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export type {
|
||||
VDomBlockType,
|
||||
VDomBlockStatus,
|
||||
VDomBlockConfig,
|
||||
VDomBlockPosition,
|
||||
VDomBlockSize,
|
||||
VDomBlock,
|
||||
VDomToolbarConfig,
|
||||
VDomToolbarItem,
|
||||
VDomEventType,
|
||||
VDomEvent,
|
||||
VDomContext,
|
||||
} from "./types";
|
||||
|
||||
// 工厂函数导出
|
||||
export {
|
||||
createDefaultVDomBlockConfig,
|
||||
createVDomBlock,
|
||||
createDefaultToolbarConfig,
|
||||
} from "./types";
|
||||
|
||||
// 状态原子导出
|
||||
export {
|
||||
// 原子族
|
||||
vdomBlocksAtomFamily,
|
||||
focusedVDomBlockAtomFamily,
|
||||
vdomToolbarAtomFamily,
|
||||
vdomEventsAtomFamily,
|
||||
// 操作原子
|
||||
addVDomBlockAtom,
|
||||
removeVDomBlockAtom,
|
||||
updateVDomBlockAtom,
|
||||
setVDomBlockFocusAtom,
|
||||
setVDomToolbarAtom,
|
||||
cleanupVDomStateAtom,
|
||||
// 派生原子
|
||||
hasVDomBlocksAtomFamily,
|
||||
vdomBlockCountAtomFamily,
|
||||
getVDomBlockAtomFamily,
|
||||
} from "./store";
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @file store.ts
|
||||
* @description VDOM 状态管理
|
||||
* @module lib/terminal/vdom/store
|
||||
*
|
||||
* 使用 Jotai 管理 VDOM 块的状态。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
*/
|
||||
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import type {
|
||||
VDomBlock,
|
||||
VDomBlockConfig,
|
||||
VDomToolbarConfig,
|
||||
VDomEvent,
|
||||
} from "./types";
|
||||
import { createVDomBlock } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 块状态原子族
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 块列表原子族(按终端 blockId 索引)
|
||||
*
|
||||
* 存储每个终端的 VDOM 块列表。
|
||||
*
|
||||
* _Requirements: 14.3_
|
||||
*/
|
||||
export const vdomBlocksAtomFamily = atomFamily((_terminalBlockId: string) =>
|
||||
atom<VDomBlock[]>([]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 当前聚焦的 VDOM 块 ID 原子族
|
||||
*
|
||||
* _Requirements: 14.4_
|
||||
*/
|
||||
export const focusedVDomBlockAtomFamily = atomFamily(
|
||||
(_terminalBlockId: string) => atom<string | null>(null),
|
||||
);
|
||||
|
||||
/**
|
||||
* VDOM 工具栏配置原子族
|
||||
*
|
||||
* _Requirements: 14.4_
|
||||
*/
|
||||
export const vdomToolbarAtomFamily = atomFamily((_terminalBlockId: string) =>
|
||||
atom<VDomToolbarConfig | null>(null),
|
||||
);
|
||||
|
||||
/**
|
||||
* VDOM 事件历史原子族(用于调试)
|
||||
*/
|
||||
export const vdomEventsAtomFamily = atomFamily((_terminalBlockId: string) =>
|
||||
atom<VDomEvent[]>([]),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 块操作原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 添加 VDOM 块
|
||||
*
|
||||
* _Requirements: 14.3_
|
||||
*/
|
||||
export const addVDomBlockAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
terminalBlockId,
|
||||
config,
|
||||
}: { terminalBlockId: string; config: VDomBlockConfig },
|
||||
) => {
|
||||
const blocksAtom = vdomBlocksAtomFamily(terminalBlockId);
|
||||
const blocks = get(blocksAtom);
|
||||
|
||||
// 检查是否已存在
|
||||
if (blocks.some((b) => b.config.id === config.id)) {
|
||||
console.warn(`[VDOM] 块 ${config.id} 已存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新块
|
||||
const newBlock = createVDomBlock(config);
|
||||
set(blocksAtom, [...blocks, newBlock]);
|
||||
|
||||
// 记录事件
|
||||
const eventsAtom = vdomEventsAtomFamily(terminalBlockId);
|
||||
const events = get(eventsAtom);
|
||||
set(eventsAtom, [
|
||||
...events,
|
||||
{
|
||||
type: "block:create",
|
||||
blockId: config.id,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 移除 VDOM 块
|
||||
*
|
||||
* _Requirements: 14.5_
|
||||
*/
|
||||
export const removeVDomBlockAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ terminalBlockId, blockId }: { terminalBlockId: string; blockId: string },
|
||||
) => {
|
||||
const blocksAtom = vdomBlocksAtomFamily(terminalBlockId);
|
||||
const blocks = get(blocksAtom);
|
||||
|
||||
set(
|
||||
blocksAtom,
|
||||
blocks.filter((b) => b.config.id !== blockId),
|
||||
);
|
||||
|
||||
// 如果移除的是聚焦块,清除聚焦状态
|
||||
const focusedAtom = focusedVDomBlockAtomFamily(terminalBlockId);
|
||||
if (get(focusedAtom) === blockId) {
|
||||
set(focusedAtom, null);
|
||||
}
|
||||
|
||||
// 记录事件
|
||||
const eventsAtom = vdomEventsAtomFamily(terminalBlockId);
|
||||
const events = get(eventsAtom);
|
||||
set(eventsAtom, [
|
||||
...events,
|
||||
{
|
||||
type: "block:close",
|
||||
blockId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新 VDOM 块
|
||||
*/
|
||||
export const updateVDomBlockAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
terminalBlockId,
|
||||
blockId,
|
||||
updates,
|
||||
}: {
|
||||
terminalBlockId: string;
|
||||
blockId: string;
|
||||
updates: Partial<VDomBlock>;
|
||||
},
|
||||
) => {
|
||||
const blocksAtom = vdomBlocksAtomFamily(terminalBlockId);
|
||||
const blocks = get(blocksAtom);
|
||||
|
||||
set(
|
||||
blocksAtom,
|
||||
blocks.map((b) =>
|
||||
b.config.id === blockId
|
||||
? { ...b, ...updates, updatedAt: Date.now() }
|
||||
: b,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 设置 VDOM 块聚焦
|
||||
*
|
||||
* _Requirements: 14.4_
|
||||
*/
|
||||
export const setVDomBlockFocusAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
terminalBlockId,
|
||||
blockId,
|
||||
}: { terminalBlockId: string; blockId: string | null },
|
||||
) => {
|
||||
const focusedAtom = focusedVDomBlockAtomFamily(terminalBlockId);
|
||||
const previousFocused = get(focusedAtom);
|
||||
|
||||
// 更新聚焦状态
|
||||
set(focusedAtom, blockId);
|
||||
|
||||
// 更新块的 focused 属性
|
||||
const blocksAtom = vdomBlocksAtomFamily(terminalBlockId);
|
||||
const blocks = get(blocksAtom);
|
||||
|
||||
set(
|
||||
blocksAtom,
|
||||
blocks.map((b) => ({
|
||||
...b,
|
||||
focused: b.config.id === blockId,
|
||||
updatedAt:
|
||||
b.config.id === blockId || b.config.id === previousFocused
|
||||
? Date.now()
|
||||
: b.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
// 记录事件
|
||||
const eventsAtom = vdomEventsAtomFamily(terminalBlockId);
|
||||
const events = get(eventsAtom);
|
||||
|
||||
if (previousFocused && previousFocused !== blockId) {
|
||||
set(eventsAtom, [
|
||||
...events,
|
||||
{
|
||||
type: "block:blur",
|
||||
blockId: previousFocused,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
set(eventsAtom, [
|
||||
...get(eventsAtom),
|
||||
{
|
||||
type: "block:focus",
|
||||
blockId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 设置 VDOM 工具栏
|
||||
*
|
||||
* _Requirements: 14.4_
|
||||
*/
|
||||
export const setVDomToolbarAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{
|
||||
terminalBlockId,
|
||||
toolbar,
|
||||
}: { terminalBlockId: string; toolbar: VDomToolbarConfig | null },
|
||||
) => {
|
||||
const toolbarAtom = vdomToolbarAtomFamily(terminalBlockId);
|
||||
set(toolbarAtom, toolbar);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 清理终端的所有 VDOM 状态
|
||||
*/
|
||||
export const cleanupVDomStateAtom = atom(
|
||||
null,
|
||||
(get, set, terminalBlockId: string) => {
|
||||
set(vdomBlocksAtomFamily(terminalBlockId), []);
|
||||
set(focusedVDomBlockAtomFamily(terminalBlockId), null);
|
||||
set(vdomToolbarAtomFamily(terminalBlockId), null);
|
||||
set(vdomEventsAtomFamily(terminalBlockId), []);
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 派生原子
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取终端是否有 VDOM 块
|
||||
*/
|
||||
export const hasVDomBlocksAtomFamily = atomFamily((terminalBlockId: string) =>
|
||||
atom((get) => {
|
||||
const blocks = get(vdomBlocksAtomFamily(terminalBlockId));
|
||||
return blocks.length > 0;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取终端的 VDOM 块数量
|
||||
*/
|
||||
export const vdomBlockCountAtomFamily = atomFamily((terminalBlockId: string) =>
|
||||
atom((get) => {
|
||||
const blocks = get(vdomBlocksAtomFamily(terminalBlockId));
|
||||
return blocks.length;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取指定 VDOM 块
|
||||
*/
|
||||
export const getVDomBlockAtomFamily = atomFamily(
|
||||
({
|
||||
terminalBlockId,
|
||||
blockId,
|
||||
}: {
|
||||
terminalBlockId: string;
|
||||
blockId: string;
|
||||
}) =>
|
||||
atom((get) => {
|
||||
const blocks = get(vdomBlocksAtomFamily(terminalBlockId));
|
||||
return blocks.find((b) => b.config.id === blockId) ?? null;
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description VDOM 类型定义
|
||||
* @module lib/terminal/vdom/types
|
||||
*
|
||||
* 定义终端内嵌 VDOM 块的类型。
|
||||
*
|
||||
* _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 块类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 块类型
|
||||
*
|
||||
* 支持的 VDOM 块类型。
|
||||
*/
|
||||
export type VDomBlockType = "widget" | "toolbar" | "custom";
|
||||
|
||||
/**
|
||||
* VDOM 块状态
|
||||
*/
|
||||
export type VDomBlockStatus = "loading" | "ready" | "error" | "closed";
|
||||
|
||||
/**
|
||||
* VDOM 块配置
|
||||
*
|
||||
* _Requirements: 14.3_
|
||||
*/
|
||||
export interface VDomBlockConfig {
|
||||
/** 块 ID */
|
||||
id: string;
|
||||
/** 块类型 */
|
||||
type: VDomBlockType;
|
||||
/** 块标题(可选) */
|
||||
title?: string;
|
||||
/** 块内容组件名称 */
|
||||
component: string;
|
||||
/** 块属性 */
|
||||
props?: Record<string, unknown>;
|
||||
/** 块位置(相对于终端) */
|
||||
position?: VDomBlockPosition;
|
||||
/** 块大小 */
|
||||
size?: VDomBlockSize;
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 是否可拖拽 */
|
||||
draggable?: boolean;
|
||||
/** 是否可调整大小 */
|
||||
resizable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* VDOM 块位置
|
||||
*/
|
||||
export interface VDomBlockPosition {
|
||||
/** 顶部偏移(像素或百分比) */
|
||||
top?: number | string;
|
||||
/** 左侧偏移(像素或百分比) */
|
||||
left?: number | string;
|
||||
/** 底部偏移(像素或百分比) */
|
||||
bottom?: number | string;
|
||||
/** 右侧偏移(像素或百分比) */
|
||||
right?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VDOM 块大小
|
||||
*/
|
||||
export interface VDomBlockSize {
|
||||
/** 宽度(像素或百分比) */
|
||||
width?: number | string;
|
||||
/** 高度(像素或百分比) */
|
||||
height?: number | string;
|
||||
/** 最小宽度 */
|
||||
minWidth?: number;
|
||||
/** 最小高度 */
|
||||
minHeight?: number;
|
||||
/** 最大宽度 */
|
||||
maxWidth?: number;
|
||||
/** 最大高度 */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VDOM 块实例
|
||||
*
|
||||
* 运行时的 VDOM 块实例。
|
||||
*
|
||||
* _Requirements: 14.3, 14.4_
|
||||
*/
|
||||
export interface VDomBlock {
|
||||
/** 块配置 */
|
||||
config: VDomBlockConfig;
|
||||
/** 块状态 */
|
||||
status: VDomBlockStatus;
|
||||
/** 是否聚焦 */
|
||||
focused: boolean;
|
||||
/** 创建时间 */
|
||||
createdAt: number;
|
||||
/** 更新时间 */
|
||||
updatedAt: number;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 工具栏类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 工具栏配置
|
||||
*
|
||||
* _Requirements: 14.4_
|
||||
*/
|
||||
export interface VDomToolbarConfig {
|
||||
/** 工具栏 ID */
|
||||
id: string;
|
||||
/** 工具栏项目 */
|
||||
items: VDomToolbarItem[];
|
||||
/** 工具栏位置 */
|
||||
position: "top" | "bottom";
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* VDOM 工具栏项目
|
||||
*/
|
||||
export interface VDomToolbarItem {
|
||||
/** 项目 ID */
|
||||
id: string;
|
||||
/** 项目类型 */
|
||||
type: "button" | "separator" | "custom";
|
||||
/** 项目标签 */
|
||||
label?: string;
|
||||
/** 项目图标 */
|
||||
icon?: string;
|
||||
/** 项目提示 */
|
||||
tooltip?: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 点击处理函数名称 */
|
||||
onClick?: string;
|
||||
/** 自定义组件名称(type 为 custom 时) */
|
||||
component?: string;
|
||||
/** 自定义属性 */
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 事件类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 事件类型
|
||||
*/
|
||||
export type VDomEventType =
|
||||
| "block:create"
|
||||
| "block:close"
|
||||
| "block:focus"
|
||||
| "block:blur"
|
||||
| "block:update"
|
||||
| "toolbar:action";
|
||||
|
||||
/**
|
||||
* VDOM 事件
|
||||
*/
|
||||
export interface VDomEvent {
|
||||
/** 事件类型 */
|
||||
type: VDomEventType;
|
||||
/** 块 ID */
|
||||
blockId?: string;
|
||||
/** 事件数据 */
|
||||
data?: Record<string, unknown>;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VDOM 上下文类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VDOM 上下文
|
||||
*
|
||||
* 提供给 VDOM 块的上下文信息。
|
||||
*/
|
||||
export interface VDomContext {
|
||||
/** 终端块 ID */
|
||||
terminalBlockId: string;
|
||||
/** 标签页 ID */
|
||||
tabId: string;
|
||||
/** 当前终端模式 */
|
||||
termMode: "term" | "vdom";
|
||||
/** 发送事件到终端 */
|
||||
sendEvent: (event: VDomEvent) => void;
|
||||
/** 切换回终端模式 */
|
||||
switchToTerminal: () => void;
|
||||
/** 关闭 VDOM 块 */
|
||||
closeBlock: (blockId: string) => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工厂函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 创建默认 VDOM 块配置
|
||||
*/
|
||||
export function createDefaultVDomBlockConfig(
|
||||
id: string,
|
||||
component: string,
|
||||
): VDomBlockConfig {
|
||||
return {
|
||||
id,
|
||||
type: "widget",
|
||||
component,
|
||||
closable: true,
|
||||
draggable: false,
|
||||
resizable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 VDOM 块实例
|
||||
*/
|
||||
export function createVDomBlock(config: VDomBlockConfig): VDomBlock {
|
||||
const now = Date.now();
|
||||
return {
|
||||
config,
|
||||
status: "loading",
|
||||
focused: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认工具栏配置
|
||||
*/
|
||||
export function createDefaultToolbarConfig(id: string): VDomToolbarConfig {
|
||||
return {
|
||||
id,
|
||||
items: [],
|
||||
position: "top",
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user