chore: bump version to v0.35.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
coso
2026-01-08 08:32:10 +08:00
co-authored by Claude Opus 4.5
parent d321c621e1
commit b7d6dde8a6
81 changed files with 20702 additions and 2318 deletions
+16 -2
View File
@@ -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
View File
@@ -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",
+41 -1
View File
@@ -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"
+3 -1
View File
@@ -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
+2
View File
@@ -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,
+103 -4
View File
@@ -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(())
}
+99 -21
View File
@@ -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]
+26 -10
View File
@@ -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,
}),
+32 -1
View File
@@ -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);
}
}
+37
View File
@@ -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
+46
View File
@@ -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 {
+24
View File
@@ -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)
- 自定义环境变量合并支持
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+31
View File
@@ -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) => (&params[..pos], &params[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);
}
}
+18
View File
@@ -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;
}
}
+18
View File
@@ -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)
}
}
+339 -75
View File
@@ -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)
}
}
+144
View File
@@ -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"), "");
}
}
+9
View File
@@ -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>
+3
View File
@@ -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 },
];
+10 -15
View File
@@ -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();
});
});
});
+134 -22
View File
@@ -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;
+64 -2
View File
@@ -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,
});
```
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+284
View File
@@ -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;
+195
View File
@@ -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;
+585
View File
@@ -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;
+303 -164
View File
@@ -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;
+86 -11
View File
@@ -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}
+565
View File
@@ -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;
+184
View File
@@ -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;
+204
View File
@@ -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;
+36 -44
View File
@@ -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;
}
}
+24
View File
@@ -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";
+837 -14
View File
@@ -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;
}
+410 -98
View File
@@ -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>
);
}
+19
View File
@@ -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";
+54
View File
@@ -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";
```
+59
View File
@@ -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_ - 终端大小变化时贴纸位置相应调整
+49
View File
@@ -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";
+276
View File
@@ -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);
},
);
+323
View File
@@ -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,
};
}
+128
View File
@@ -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_ - 多输入模式
+301
View File
@@ -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),
);
},
);
+356
View File
@@ -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();
+411
View File
@@ -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,
};
}
+35
View File
@@ -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";
+253
View File
@@ -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);
}
};
}
+211
View File
@@ -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;
}
+214
View File
@@ -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)),
);
+58 -1
View File
@@ -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;
}
+58
View File
@@ -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: 块关闭自动切换
+51
View File
@@ -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";
+316
View File
@@ -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;
}),
);
+251
View File
@@ -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,
};
}