mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-30 18:45:55 +08:00
Compare commits
7 Commits
8967fe34e5
...
d28861ff9c
| Author | SHA1 | Date | |
|---|---|---|---|
| d28861ff9c | |||
| b159407773 | |||
| e6a2416271 | |||
| 3a21891f84 | |||
| bfd114d80f | |||
| 2ceec54790 | |||
| b1acb3690c |
@@ -0,0 +1,38 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v4.0.0 (2026-05-26)
|
||||||
|
|
||||||
|
### 重大变更
|
||||||
|
|
||||||
|
完全重写为 Tauri 2.x + React 19 + TypeScript + Rust 技术栈,替代原有的 C + IUP GUI。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 现代 Web UI(React + Tailwind CSS 4 + Zustand)
|
||||||
|
- 深色/浅色模式切换
|
||||||
|
- 中英文界面即时切换
|
||||||
|
- 路径有效性颜色编码(红色无效、橙色重复)
|
||||||
|
- 环境变量展开悬停提示
|
||||||
|
- 文件夹拖拽添加路径
|
||||||
|
- 保存前 PATH 长度检查
|
||||||
|
- 66 个前端单元测试 + 10 个 Rust 单元测试
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 安装包体积从 ~3MB 降至 ~8MB(含 WebView2 运行时)
|
||||||
|
- 完整撤销/重做支持(8 种操作类型,50 步历史)
|
||||||
|
- JSON/CSV/TXT 三种格式导入导出
|
||||||
|
- 合并预览查看系统+用户路径
|
||||||
|
- 类型安全:TypeScript strict 模式 + Rust 编译期检查
|
||||||
|
|
||||||
|
### 移除
|
||||||
|
|
||||||
|
- 旧 C + IUP + Lua + gettext 代码库
|
||||||
|
- Lua 配置引擎 → JSON 配置文件
|
||||||
|
- gettext 国际化 → i18next
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- 需要 Windows 10+ 系统预装的 WebView2 运行时
|
||||||
|
- 内存占用约 50MB(旧版约 15MB)
|
||||||
|
- 文件系统路径验证在清理功能中为同步检查(不含实际目录存在性验证)
|
||||||
@@ -92,3 +92,4 @@ tests/unit/ # Vitest 前端单元测试
|
|||||||
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
|
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
|
||||||
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
|
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
|
||||||
- 运行需要管理员权限才能编辑系统 PATH
|
- 运行需要管理员权限才能编辑系统 PATH
|
||||||
|
- `cargo test` 需要 MinGW bin 在 PATH 中(GCC 15.2.0 运行时依赖 `libmcfgthread-2.dll`),开发模式下可用 `npx tauri dev` 替代
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "v4.0",
|
"name": "patheditor",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "4.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ use chrono::Local;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn backup_base_dir() -> PathBuf {
|
||||||
|
dirs::data_dir()
|
||||||
|
.or_else(dirs::home_dir)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("PathEditor")
|
||||||
|
.join("backups")
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 APPDATA 路径下的备份目录
|
/// 获取 APPDATA 路径下的备份目录
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_appdata_dir() -> String {
|
pub fn get_appdata_dir() -> String {
|
||||||
dirs::data_dir()
|
backup_base_dir().to_string_lossy().to_string()
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\"))
|
|
||||||
.join("PathEditor")
|
|
||||||
.join("backups")
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
/// 备份当前注册表中的系统 PATH 和用户 PATH
|
||||||
@@ -20,12 +23,7 @@ pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_
|
|||||||
// 确定备份目录
|
// 确定备份目录
|
||||||
let backup_dir = match custom_dir {
|
let backup_dir = match custom_dir {
|
||||||
Some(ref dir) if !dir.is_empty() => PathBuf::from(dir),
|
Some(ref dir) if !dir.is_empty() => PathBuf::from(dir),
|
||||||
_ => {
|
_ => backup_base_dir(),
|
||||||
dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("C:\\"))
|
|
||||||
.join("PathEditor")
|
|
||||||
.join("backups")
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建目录
|
// 创建目录
|
||||||
@@ -33,7 +31,7 @@ pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_
|
|||||||
.map_err(|e| format!("无法创建备份目录: {}", e))?;
|
.map_err(|e| format!("无法创建备份目录: {}", e))?;
|
||||||
|
|
||||||
// 生成带时间戳的文件名
|
// 生成带时间戳的文件名
|
||||||
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
|
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f");
|
||||||
let filename = format!("path_backup_{}.txt", timestamp);
|
let filename = format!("path_backup_{}.txt", timestamp);
|
||||||
let filepath = backup_dir.join(&filename);
|
let filepath = backup_dir.join(&filename);
|
||||||
|
|
||||||
|
|||||||
@@ -5,71 +5,64 @@ const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\
|
|||||||
const USER_REG_PATH: &str = "Environment";
|
const USER_REG_PATH: &str = "Environment";
|
||||||
const PATH_VALUE: &str = "Path";
|
const PATH_VALUE: &str = "Path";
|
||||||
|
|
||||||
/// 从注册表加载系统 PATH
|
fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
|
||||||
|
let key = RegKey::predef(root);
|
||||||
|
let env_key = key
|
||||||
|
.open_subkey_with_flags(sub_path, KEY_READ)
|
||||||
|
.map_err(|e| format!("无法打开{}注册表项: {}", label, e))?;
|
||||||
|
|
||||||
|
let value: String = env_key
|
||||||
|
.get_value(PATH_VALUE)
|
||||||
|
.map_err(|e| format!("无法读取{} PATH: {}", label, e))?;
|
||||||
|
|
||||||
|
Ok(split_path(&value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
|
||||||
|
let value = join_path(paths);
|
||||||
|
|
||||||
|
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
|
||||||
|
const MAX_PATH_LEN: usize = 32767;
|
||||||
|
if value.len() > MAX_PATH_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
|
||||||
|
label, value.len(), MAX_PATH_LEN
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = RegKey::predef(root);
|
||||||
|
let env_key = key
|
||||||
|
.open_subkey_with_flags(sub_path, KEY_WRITE)
|
||||||
|
.map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?;
|
||||||
|
|
||||||
|
env_key
|
||||||
|
.set_value(PATH_VALUE, &value)
|
||||||
|
.map_err(|e| format!("无法写入{} PATH: {}", label, e))?;
|
||||||
|
|
||||||
|
log::info!("已保存{} PATH,{} 个条目", label, paths.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_system_paths() -> Result<Vec<String>, String> {
|
pub fn load_system_paths() -> Result<Vec<String>, String> {
|
||||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
|
||||||
let env_key = hklm
|
|
||||||
.open_subkey_with_flags(SYS_REG_PATH, KEY_READ)
|
|
||||||
.map_err(|e| format!("无法打开系统注册表项: {}", e))?;
|
|
||||||
|
|
||||||
let value: String = env_key
|
|
||||||
.get_value(PATH_VALUE)
|
|
||||||
.map_err(|e| format!("无法读取系统 PATH: {}", e))?;
|
|
||||||
|
|
||||||
Ok(split_path(&value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从注册表加载用户 PATH
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_user_paths() -> Result<Vec<String>, String> {
|
pub fn load_user_paths() -> Result<Vec<String>, String> {
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
|
||||||
let env_key = hkcu
|
|
||||||
.open_subkey_with_flags(USER_REG_PATH, KEY_READ)
|
|
||||||
.map_err(|e| format!("无法打开用户注册表项: {}", e))?;
|
|
||||||
|
|
||||||
let value: String = env_key
|
|
||||||
.get_value(PATH_VALUE)
|
|
||||||
.map_err(|e| format!("无法读取用户 PATH: {}", e))?;
|
|
||||||
|
|
||||||
Ok(split_path(&value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存系统 PATH 到注册表
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
|
||||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
|
||||||
let env_key = hklm
|
|
||||||
.open_subkey_with_flags(SYS_REG_PATH, KEY_WRITE)
|
|
||||||
.map_err(|e| format!("无法写入系统注册表(需要管理员权限): {}", e))?;
|
|
||||||
|
|
||||||
let value = join_path(&paths);
|
|
||||||
env_key
|
|
||||||
.set_value(PATH_VALUE, &value)
|
|
||||||
.map_err(|e| format!("无法写入系统 PATH: {}", e))?;
|
|
||||||
|
|
||||||
log::info!("已保存系统 PATH,{} 个条目", paths.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存用户 PATH 到注册表
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
|
||||||
let env_key = hkcu
|
|
||||||
.open_subkey_with_flags(USER_REG_PATH, KEY_WRITE)
|
|
||||||
.map_err(|e| format!("无法写入用户注册表: {}", e))?;
|
|
||||||
|
|
||||||
let value = join_path(&paths);
|
|
||||||
env_key
|
|
||||||
.set_value(PATH_VALUE, &value)
|
|
||||||
.map_err(|e| format!("无法写入用户 PATH: {}", e))?;
|
|
||||||
|
|
||||||
log::info!("已保存用户 PATH,{} 个条目", paths.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用分号分割 PATH 字符串
|
|
||||||
fn split_path(raw: &str) -> Vec<String> {
|
fn split_path(raw: &str) -> Vec<String> {
|
||||||
raw.split(';')
|
raw.split(';')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
@@ -77,7 +70,56 @@ fn split_path(raw: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用分号连接路径列表
|
|
||||||
fn join_path(paths: &[String]) -> String {
|
fn join_path(paths: &[String]) -> String {
|
||||||
paths.join(";")
|
paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.trim())
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_empty() {
|
||||||
|
assert_eq!(split_path(""), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_single() {
|
||||||
|
assert_eq!(split_path("C:\\Windows"), vec!["C:\\Windows"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_multiple() {
|
||||||
|
assert_eq!(
|
||||||
|
split_path("C:\\Windows;D:\\Projects"),
|
||||||
|
vec!["C:\\Windows", "D:\\Projects"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_trims_and_filters_empty() {
|
||||||
|
assert_eq!(
|
||||||
|
split_path(" C:\\ ; ; D:\\ "),
|
||||||
|
vec!["C:\\", "D:\\"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_and_split_roundtrip() {
|
||||||
|
let paths = vec!["C:\\Windows".to_string(), "D:\\Projects".to_string()];
|
||||||
|
let joined = join_path(&paths);
|
||||||
|
let split = split_path(&joined);
|
||||||
|
assert_eq!(split, paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_trims_entries() {
|
||||||
|
let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()];
|
||||||
|
assert_eq!(join_path(&paths), "C:\\Windows;D:\\");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,32 @@ pub fn expand_env_vars(path: &str) -> String {
|
|||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转为 UTF-16 宽字符串
|
// 转为 UTF-16 宽字符串(以 null 结尾)
|
||||||
let wide_path: Vec<u16> = path
|
let wide_path: Vec<u16> = path
|
||||||
.encode_utf16()
|
.encode_utf16()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 先查询需要的缓冲区大小 (lpDst=NULL)
|
// SAFETY: wide_path 是以 null 结尾的 UTF-16 字符串,lpDst 为 null 且 nSize 为 0,
|
||||||
|
// 根据 MSDN 文档此时 API 只查询所需缓冲区大小而不写入数据
|
||||||
let required = unsafe {
|
let required = unsafe {
|
||||||
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
|
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
if required == 0 {
|
if required == 0 {
|
||||||
|
log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}");
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实际展开
|
// SAFETY: buffer 容量为 required(API 返回的精确大小),wide_path 以 null 结尾,
|
||||||
|
// 且两个指针指向不同的内存区域,不存在重叠
|
||||||
let mut buffer: Vec<u16> = vec![0; required as usize];
|
let mut buffer: Vec<u16> = vec![0; required as usize];
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
|
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
|
||||||
};
|
};
|
||||||
|
|
||||||
if result == 0 {
|
if result == 0 {
|
||||||
|
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +70,11 @@ pub fn broadcast_env_change() {
|
|||||||
const WM_SETTINGCHANGE: u32 = 0x001A;
|
const WM_SETTINGCHANGE: u32 = 0x001A;
|
||||||
const SMTO_ABORTIFHUNG: u32 = 0x0002;
|
const SMTO_ABORTIFHUNG: u32 = 0x0002;
|
||||||
|
|
||||||
|
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
|
||||||
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
|
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||||
|
|
||||||
|
// SAFETY: env_str.as_ptr() 指向以 null 结尾的字符串,HWND_BROADCAST 是合法句柄,
|
||||||
|
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
SendMessageTimeoutW(
|
SendMessageTimeoutW(
|
||||||
HWND_BROADCAST,
|
HWND_BROADCAST,
|
||||||
@@ -108,3 +115,34 @@ extern "system" {
|
|||||||
lpdwResult: *mut usize,
|
lpdwResult: *mut usize,
|
||||||
) -> isize;
|
) -> isize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_path_env_var_always_valid() {
|
||||||
|
assert!(validate_path("%JAVA_HOME%\\bin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_env_vars_no_percent_returns_original() {
|
||||||
|
let result = expand_env_vars("C:\\Windows");
|
||||||
|
assert_eq!(result, "C:\\Windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_env_vars_with_invalid_var_returns_original() {
|
||||||
|
// 展开不存在的变量可能会回归原始值或产生部分展开;测试是否不会崩溃
|
||||||
|
let result = expand_env_vars("%__NONEXISTENT_VAR__%");
|
||||||
|
// 至少不应为空白
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_admin_returns_bool() {
|
||||||
|
let result = check_admin();
|
||||||
|
// 在任意机器上应返回 true 或 false,不应 panic
|
||||||
|
assert!((result == true) || (result == false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// 传给前端的统一错误类型
|
/// 传给前端的统一错误类型(保留供未来使用,当前命令返回 Result<T, String>)
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AppError {
|
pub struct AppError {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod error;
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
|
|||||||
+7
-6
@@ -1,19 +1,20 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { initDarkMode, useThemeStore } from '@/store/theme-store';
|
import { initDarkMode } from '@/store/theme-store';
|
||||||
import { AppShell } from '@/components/layout/AppShell';
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialize = useAppStore((s) => s.initialize);
|
const initialize = useAppStore((s) => s.initialize);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initDarkMode();
|
initDarkMode();
|
||||||
const saved = localStorage.getItem('darkMode');
|
|
||||||
if (saved === '1') {
|
|
||||||
useThemeStore.setState({ isDark: true });
|
|
||||||
}
|
|
||||||
initialize();
|
initialize();
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
return <AppShell />;
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AppShell />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,20 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
interface HelpDialogProps {
|
interface HelpDialogProps { open: boolean; onClose: () => void; }
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal open={open} onClose={onClose}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">{t('help.content')}</pre>
|
||||||
onClick={onClose}
|
<div className="flex justify-end mt-4">
|
||||||
>
|
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={onClose}>
|
||||||
<div
|
{t('dialog.confirm')}
|
||||||
className="rounded-lg p-6 max-w-lg"
|
</button>
|
||||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
|
|
||||||
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed">
|
|
||||||
{t('help.content')}
|
|
||||||
</pre>
|
|
||||||
<div className="flex justify-end mt-4">
|
|
||||||
<button
|
|
||||||
className="px-4 py-1.5 text-sm rounded text-white"
|
|
||||||
style={{ backgroundColor: '#2563eb' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{t('dialog.confirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,31 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
interface ImportDialogProps {
|
interface ImportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
hasSystem: boolean;
|
systemCount: number;
|
||||||
hasUser: boolean;
|
userCount: number;
|
||||||
onSelect: (target: 'system' | 'user' | 'both') => void;
|
onSelect: (target: 'system' | 'user' | 'both') => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportDialog({
|
export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel }: ImportDialogProps) {
|
||||||
open,
|
|
||||||
hasSystem,
|
|
||||||
hasUser,
|
|
||||||
onSelect,
|
|
||||||
onCancel,
|
|
||||||
}: ImportDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal open={open} onClose={onCancel}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
<h2 className="text-lg font-semibold mb-4">{t('dialog.importTarget')}</h2>
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
<p className="text-sm mb-4 opacity-70">
|
||||||
onClick={onCancel}
|
{systemCount > 0 && `系统变量: ${systemCount} 条`}
|
||||||
>
|
{systemCount > 0 && userCount > 0 && ' | '}
|
||||||
<div
|
{userCount > 0 && `用户变量: ${userCount} 条`}
|
||||||
className="rounded-lg p-6"
|
</p>
|
||||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
<div className="flex flex-col gap-2">
|
||||||
onClick={(e) => e.stopPropagation()}
|
{systemCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('system')}>{t('dialog.importSystem')}</button>}
|
||||||
>
|
{userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('user')}>{t('dialog.importUser')}</button>}
|
||||||
<h2 className="text-lg font-semibold mb-4">{t('dialog.importTarget')}</h2>
|
{systemCount > 0 && userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('both')}>{t('dialog.importBoth')}</button>}
|
||||||
<p className="text-sm mb-4 opacity-70">
|
<button className="px-4 py-2 text-sm rounded border mt-2" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>{t('dialog.cancel')}</button>
|
||||||
{hasSystem && `系统变量: ${hasSystem}`}
|
|
||||||
{hasSystem && hasUser && ' | '}
|
|
||||||
{hasUser && `用户变量: ${hasUser}`}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{hasSystem && (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-sm rounded border text-left"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
onClick={() => onSelect('system')}
|
|
||||||
>
|
|
||||||
{t('dialog.importSystem')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasUser && (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-sm rounded border text-left"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
onClick={() => onSelect('user')}
|
|
||||||
>
|
|
||||||
{t('dialog.importUser')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasSystem && hasUser && (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-sm rounded border text-left"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
onClick={() => onSelect('both')}
|
|
||||||
>
|
|
||||||
{t('dialog.importBoth')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-sm rounded border mt-2"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{t('dialog.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
|
||||||
interface PathEditDialogProps {
|
interface PathEditDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,61 +14,27 @@ export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
// 每次打开时同步 initialValue(解决 React 复用实例导致空白的问题)
|
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setValue(initialValue);
|
|
||||||
}
|
|
||||||
}, [open, initialValue]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal open={open} onClose={onCancel}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
||||||
onClick={onCancel}
|
<input
|
||||||
>
|
type="text" autoFocus value={value}
|
||||||
<div
|
onChange={(e) => setValue(e.target.value)}
|
||||||
className="rounded-lg p-6 min-w-[400px]"
|
onKeyDown={(e) => { if (e.key === 'Enter') onConfirm(value); }}
|
||||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
className="w-full min-w-[400px] px-3 py-2 rounded border text-sm outline-none"
|
||||||
onClick={(e) => e.stopPropagation()}
|
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||||
>
|
/>
|
||||||
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
<button className="px-4 py-1.5 text-sm rounded border" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>
|
||||||
<input
|
{t('dialog.cancel')}
|
||||||
type="text"
|
</button>
|
||||||
autoFocus
|
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={() => onConfirm(value)}>
|
||||||
value={value}
|
{t('dialog.confirm')}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
</button>
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') onConfirm(value);
|
|
||||||
if (e.key === 'Escape') onCancel();
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 rounded border text-sm outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--app-list-bg)',
|
|
||||||
color: 'var(--app-fg)',
|
|
||||||
borderColor: 'var(--app-border)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
className="px-4 py-1.5 text-sm rounded border"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{t('dialog.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="px-4 py-1.5 text-sm rounded text-white"
|
|
||||||
style={{ backgroundColor: '#2563eb' }}
|
|
||||||
onClick={() => onConfirm(value)}
|
|
||||||
>
|
|
||||||
{t('dialog.confirm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAppStore, type TabId } from '@/store/app-store';
|
import { useAppStore, type TabId } from '@/store/app-store';
|
||||||
import { useThemeStore } from '@/store/theme-store';
|
import { useThemeStore } from '@/store/theme-store';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { TargetType } from '@/core/undo-redo';
|
import { TargetType } from '@/core/undo-redo';
|
||||||
import { importFromContent, exportToJson, flattenImportResult } from '@/core/import-export';
|
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { TitleBar } from './TitleBar';
|
import { TitleBar } from './TitleBar';
|
||||||
import { ToolBar } from '@/components/toolbar/ToolBar';
|
import { ToolBar } from '@/components/toolbar/ToolBar';
|
||||||
@@ -13,183 +12,28 @@ import { MergePreview } from '@/components/path-list/MergePreview';
|
|||||||
import { PathEditDialog } from '@/components/dialogs/PathEditDialog';
|
import { PathEditDialog } from '@/components/dialogs/PathEditDialog';
|
||||||
import { HelpDialog } from '@/components/dialogs/HelpDialog';
|
import { HelpDialog } from '@/components/dialogs/HelpDialog';
|
||||||
import { ImportDialog } from '@/components/dialogs/ImportDialog';
|
import { ImportDialog } from '@/components/dialogs/ImportDialog';
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard';
|
import { useAppActions, type DialogState } from '@/hooks/use-app-actions';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const activeTab = useAppStore((s) => s.activeTab);
|
const activeTab = useAppStore((s) => s.activeTab);
|
||||||
const setActiveTab = useAppStore((s) => s.setActiveTab);
|
const setActiveTab = useAppStore((s) => s.setActiveTab);
|
||||||
const selectedIndices = useAppStore((s) => s.selectedIndices);
|
|
||||||
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
||||||
|
|
||||||
// 对话弹窗状态
|
const [editDialog, setEditDialog] = useState<DialogState['editDialog']>({
|
||||||
const [editDialog, setEditDialog] = useState<{ open: boolean; index: number; value: string; target: TargetType }>({
|
|
||||||
open: false, index: -1, value: '', target: TargetType.SYSTEM,
|
open: false, index: -1, value: '', target: TargetType.SYSTEM,
|
||||||
});
|
});
|
||||||
const [newDialog, setNewDialog] = useState(false);
|
const [newDialog, setNewDialog] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [importDialog, setImportDialog] = useState<{ open: boolean; system: string[]; user: string[] }>({
|
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
||||||
open: false, system: [], user: [],
|
open: false, system: [], user: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 操作处理 ──
|
const actions = useAppActions(activeTab, {
|
||||||
|
editDialog, newDialog, helpOpen, importDialog,
|
||||||
const getCurrentTarget = useCallback((): TargetType => {
|
setEditDialog, setNewDialog, setHelpOpen, setImportDialog,
|
||||||
return activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const handleNew = useCallback(() => {
|
|
||||||
setNewDialog(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
|
||||||
if (selectedIndices.length !== 1) return;
|
|
||||||
const idx = selectedIndices[0];
|
|
||||||
const target = getCurrentTarget();
|
|
||||||
const list = target === TargetType.SYSTEM
|
|
||||||
? useAppStore.getState().sysPaths
|
|
||||||
: useAppStore.getState().userPaths;
|
|
||||||
const value = list.get(idx);
|
|
||||||
if (value) {
|
|
||||||
setEditDialog({ open: true, index: idx, value, target });
|
|
||||||
}
|
|
||||||
}, [selectedIndices, getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleBrowse = useCallback(() => {
|
|
||||||
// Tauri native dialog (简化版 — 后续可增强)
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.webkitdirectory = true;
|
|
||||||
input.onchange = (e) => {
|
|
||||||
const files = (e.target as HTMLInputElement).files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const path = (files[0] as any).path || files[0].name;
|
|
||||||
useAppStore.getState().addPath(path, getCurrentTarget());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
}, [getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
if (selectedIndices.length === 0) return;
|
|
||||||
useAppStore.getState().deletePaths(selectedIndices, getCurrentTarget());
|
|
||||||
}, [selectedIndices, getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleMoveUp = useCallback(() => {
|
|
||||||
if (selectedIndices.length !== 1) return;
|
|
||||||
useAppStore.getState().moveUp(selectedIndices[0], getCurrentTarget());
|
|
||||||
}, [selectedIndices, getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleMoveDown = useCallback(() => {
|
|
||||||
if (selectedIndices.length !== 1) return;
|
|
||||||
useAppStore.getState().moveDown(selectedIndices[0], getCurrentTarget());
|
|
||||||
}, [selectedIndices, getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleClean = useCallback(() => {
|
|
||||||
const removed = useAppStore.getState().cleanPaths(
|
|
||||||
getCurrentTarget(),
|
|
||||||
() => true, // 简化版,全有效
|
|
||||||
);
|
|
||||||
if (removed.length > 0) {
|
|
||||||
useAppStore.getState().setStatusMessage(
|
|
||||||
t('status.deleted', { count: removed.length }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [getCurrentTarget, t]);
|
|
||||||
|
|
||||||
const handleImport = useCallback(() => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = '.json,.csv,.txt';
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const content = await file.text();
|
|
||||||
const result = importFromContent(content, file.name);
|
|
||||||
|
|
||||||
if (result.system.length > 0 && result.user.length > 0) {
|
|
||||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
|
||||||
} else if (result.system.length > 0) {
|
|
||||||
useAppStore.getState().importPaths(TargetType.SYSTEM, result.system);
|
|
||||||
} else if (result.user.length > 0) {
|
|
||||||
useAppStore.getState().importPaths(TargetType.USER, result.user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
|
||||||
const state = useAppStore.getState();
|
|
||||||
const data = { system: state.sysPaths.toArray(), user: state.userPaths.toArray() };
|
|
||||||
|
|
||||||
const content = exportToJson(data);
|
|
||||||
const mime = 'application/json';
|
|
||||||
const ext = '.json';
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: mime });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `patheditor_export${ext}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
useAppStore.getState().savePaths();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── 键盘快捷键 ──
|
|
||||||
|
|
||||||
useKeyboard({
|
|
||||||
onNew: handleNew,
|
|
||||||
onSave: handleSave,
|
|
||||||
onDelete: handleDelete,
|
|
||||||
onUndo: () => useAppStore.getState().undo(),
|
|
||||||
onRedo: () => useAppStore.getState().redo(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 双击编辑监听 ──
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail;
|
|
||||||
if (detail && typeof detail.index === 'number') {
|
|
||||||
const target = getCurrentTarget();
|
|
||||||
setEditDialog({ open: true, index: detail.index, value: detail.path, target });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('path-dblclick', handler);
|
|
||||||
return () => window.removeEventListener('path-dblclick', handler);
|
|
||||||
}, [getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleNewConfirm = useCallback((value: string) => {
|
|
||||||
setNewDialog(false);
|
|
||||||
if (value.trim()) {
|
|
||||||
useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
|
||||||
}
|
|
||||||
}, [getCurrentTarget]);
|
|
||||||
|
|
||||||
const handleEditConfirm = useCallback((value: string) => {
|
|
||||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
|
||||||
if (value.trim()) {
|
|
||||||
useAppStore.getState().editPath(editDialog.index, value.trim(), editDialog.target);
|
|
||||||
}
|
|
||||||
}, [editDialog]);
|
|
||||||
|
|
||||||
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
|
||||||
const { system, user } = importDialog;
|
|
||||||
const flat = flattenImportResult({ system, user }, target);
|
|
||||||
if (flat.system.length > 0) {
|
|
||||||
useAppStore.getState().importPaths(TargetType.SYSTEM, flat.system);
|
|
||||||
}
|
|
||||||
if (flat.user.length > 0) {
|
|
||||||
useAppStore.getState().importPaths(TargetType.USER, flat.user);
|
|
||||||
}
|
|
||||||
setImportDialog({ open: false, system: [], user: [] });
|
|
||||||
}, [importDialog]);
|
|
||||||
|
|
||||||
// Tab 切换
|
|
||||||
const tabConfig: { id: TabId; label: string }[] = [
|
const tabConfig: { id: TabId; label: string }[] = [
|
||||||
{ id: 'system', label: t('tab.system') },
|
{ id: 'system', label: t('tab.system') },
|
||||||
{ id: 'user', label: t('tab.user') },
|
{ id: 'user', label: t('tab.user') },
|
||||||
@@ -197,27 +41,15 @@ export function AppShell() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||||
className="flex flex-col h-screen"
|
|
||||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
|
||||||
>
|
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
{/* Tab 栏 */}
|
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
|
||||||
<div
|
|
||||||
className="flex border-b px-4"
|
|
||||||
style={{ borderColor: 'var(--app-border)' }}
|
|
||||||
>
|
|
||||||
{tabConfig.map((tab) => (
|
{tabConfig.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => {
|
onClick={() => { setActiveTab(tab.id); setSelectedIndices([]); }}
|
||||||
setActiveTab(tab.id);
|
className={`px-4 py-1.5 text-sm font-medium transition-colors ${activeTab === tab.id ? 'tab-active' : 'opacity-60'}`}
|
||||||
setSelectedIndices([]);
|
|
||||||
}}
|
|
||||||
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id ? 'tab-active' : 'opacity-60'
|
|
||||||
}`}
|
|
||||||
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
|
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -225,20 +57,23 @@ export function AppShell() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<div className="px-4 py-2">
|
<div className="px-4 py-2">
|
||||||
<ToolBar
|
<ToolBar
|
||||||
onNew={handleNew}
|
onNew={actions.handleNew}
|
||||||
onEdit={handleEdit}
|
onEdit={actions.handleEdit}
|
||||||
onBrowse={handleBrowse}
|
onBrowse={actions.handleBrowse}
|
||||||
onDelete={handleDelete}
|
onDelete={actions.handleDelete}
|
||||||
onMoveUp={handleMoveUp}
|
onMoveUp={actions.handleMoveUp}
|
||||||
onMoveDown={handleMoveDown}
|
onMoveDown={actions.handleMoveDown}
|
||||||
onClean={handleClean}
|
onClean={actions.handleClean}
|
||||||
onImport={handleImport}
|
onImport={actions.handleImport}
|
||||||
onExport={handleExport}
|
onExport={actions.handleExport}
|
||||||
onSave={handleSave}
|
onSave={actions.handleSave}
|
||||||
onCancel={() => window.close()}
|
onCancel={() => {
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
if (state.isModified && !window.confirm('有未保存的修改,确定退出吗?')) return;
|
||||||
|
window.close();
|
||||||
|
}}
|
||||||
onHelp={() => setHelpOpen(true)}
|
onHelp={() => setHelpOpen(true)}
|
||||||
onLanguage={() => {
|
onLanguage={() => {
|
||||||
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
||||||
@@ -248,41 +83,30 @@ export function AppShell() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 路径列表 */}
|
<div
|
||||||
{activeTab === 'merged' ? (
|
className="flex-1 overflow-hidden"
|
||||||
<MergePreview />
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
||||||
) : (
|
onDrop={(e) => {
|
||||||
<PathTable tabId={activeTab as 'system' | 'user'} />
|
e.preventDefault();
|
||||||
)}
|
if (activeTab === 'merged') return;
|
||||||
|
for (let i = 0; i < e.dataTransfer.items.length; i++) {
|
||||||
|
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
||||||
|
if (entry?.isDirectory) {
|
||||||
|
const path = (e.dataTransfer.files[i] as any).path;
|
||||||
|
if (path) useAppStore.getState().addPath(path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeTab === 'merged' ? <MergePreview /> : <PathTable tabId={activeTab as 'system' | 'user'} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
{/* 弹窗 */}
|
<PathEditDialog open={newDialog} title={t('dialog.newPath')} initialValue="" onConfirm={actions.handleNewConfirm} onCancel={() => setNewDialog(false)} />
|
||||||
<PathEditDialog
|
<PathEditDialog open={editDialog.open} title={t('dialog.editPath')} initialValue={editDialog.value} onConfirm={actions.handleEditConfirm} onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} />
|
||||||
open={newDialog}
|
|
||||||
title={t('dialog.newPath')}
|
|
||||||
initialValue=""
|
|
||||||
onConfirm={handleNewConfirm}
|
|
||||||
onCancel={() => setNewDialog(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PathEditDialog
|
|
||||||
open={editDialog.open}
|
|
||||||
title={t('dialog.editPath')}
|
|
||||||
initialValue={editDialog.value}
|
|
||||||
onConfirm={handleEditConfirm}
|
|
||||||
onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
|
<ImportDialog open={importDialog.open} systemCount={importDialog.system.length} userCount={importDialog.user.length} onSelect={actions.handleImportSelect} onCancel={() => setImportDialog({ open: false, system: [], user: [] })} />
|
||||||
<ImportDialog
|
|
||||||
open={importDialog.open}
|
|
||||||
hasSystem={importDialog.system.length > 0}
|
|
||||||
hasUser={importDialog.user.length > 0}
|
|
||||||
onSelect={handleImportSelect}
|
|
||||||
onCancel={() => setImportDialog({ open: false, system: [], user: [] })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props { children: ReactNode; }
|
||||||
|
interface State { hasError: boolean; error: string; }
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false, error: '' };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(e: Error): State {
|
||||||
|
console.error('[ErrorBoundary]', e);
|
||||||
|
return { hasError: true, error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(_e: Error, info: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] componentStack:', info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">应用出错</h2>
|
||||||
|
<p className="text-sm opacity-70">{this.state.error}</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded border"
|
||||||
|
onClick={() => this.setState({ hasError: false })}
|
||||||
|
style={{ borderColor: 'var(--app-border)' }}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useThemeStore } from '@/store/theme-store';
|
import { useThemeStore } from '@/store/theme-store';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const statusMessage = useAppStore((s) => s.statusMessage);
|
const statusMessage = useAppStore((s) => s.statusMessage);
|
||||||
const isLoading = useAppStore((s) => s.isLoading);
|
const isLoading = useAppStore((s) => s.isLoading);
|
||||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||||
const isModified = useAppStore((s) => s.isModified);
|
const isModified = useAppStore((s) => s.isModified);
|
||||||
const isDark = useThemeStore((s) => s.isDark);
|
const isDark = useThemeStore((s) => s.isDark);
|
||||||
|
const hasError = statusMessage.includes(t('status.error'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -17,11 +20,22 @@ export function StatusBar() {
|
|||||||
color: 'var(--app-fg)',
|
color: 'var(--app-fg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{isLoading ? '加载中...' : statusMessage}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{isLoading ? t('status.loading') : statusMessage}</span>
|
||||||
|
{hasError && !isLoading && (
|
||||||
|
<button
|
||||||
|
className="px-2 py-0.5 rounded border text-xs"
|
||||||
|
style={{ borderColor: 'var(--app-border)' }}
|
||||||
|
onClick={() => useAppStore.getState().loadPaths()}
|
||||||
|
>
|
||||||
|
{t('button.retry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{isModified && <span className="text-yellow-500">● 已修改</span>}
|
{isModified && <span className="text-yellow-500">● {t('status.modified')}</span>}
|
||||||
{!isAdmin && <span className="text-yellow-500">只读</span>}
|
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
|
||||||
<span style={{ opacity: 0.5 }}>{isDark ? '深色' : '浅色'}</span>
|
<span style={{ opacity: 0.5 }}>{isDark ? t('status.dark') : t('status.light')}</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function MergePreview() {
|
export function MergePreview() {
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
const userPaths = useAppStore((s) => s.userPaths);
|
const userPaths = useAppStore((s) => s.userPaths);
|
||||||
const searchQuery = useAppStore((s) => s.searchQuery);
|
const searchQuery = useAppStore((s) => s.searchQuery);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const allPaths = useMemo(() => {
|
const allPaths = useMemo(() => {
|
||||||
const result: { path: string; source: '系统' | '用户'; index: number }[] = [];
|
const result: { path: string; source: string; index: number }[] = [];
|
||||||
sysPaths.all.forEach((p, i) => result.push({ path: p, source: '系统' as const, index: i }));
|
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
|
||||||
userPaths.all.forEach((p, i) => result.push({ path: p, source: '用户' as const, index: i }));
|
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
|
||||||
|
|
||||||
if (!searchQuery) return result;
|
if (!searchQuery) return result;
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return result.filter((r) => r.path.toLowerCase().includes(q));
|
return result.filter((r) => r.path.toLowerCase().includes(q));
|
||||||
}, [sysPaths, userPaths, searchQuery]);
|
}, [sysPaths, userPaths, searchQuery, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
@@ -25,8 +27,8 @@ export function MergePreview() {
|
|||||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
>
|
>
|
||||||
<th className="w-10 px-2 py-1">#</th>
|
<th className="w-10 px-2 py-1">#</th>
|
||||||
<th className="px-2 py-1">路径</th>
|
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
|
||||||
<th className="w-16 px-2 py-1">来源</th>
|
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { validatePath } from '@/hooks/use-path-validation';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
interface PathTableProps {
|
interface PathTableProps {
|
||||||
tabId: 'system' | 'user';
|
tabId: 'system' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PathRow {
|
||||||
|
path: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function PathTable({ tabId }: PathTableProps) {
|
export function PathTable({ tabId }: PathTableProps) {
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
const userPaths = useAppStore((s) => s.userPaths);
|
const userPaths = useAppStore((s) => s.userPaths);
|
||||||
@@ -17,20 +22,94 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const paths = tabId === 'system' ? sysPaths : userPaths;
|
const paths = tabId === 'system' ? sysPaths : userPaths;
|
||||||
const isActive = activeTab === tabId;
|
const isActive = activeTab === tabId;
|
||||||
|
|
||||||
|
// 本次会话中已验证过的路径缓存(key=path, value=isValid)
|
||||||
|
const [validationCache, setValidationCache] = useState<Map<string, boolean>>(new Map());
|
||||||
|
// 环境变量展开结果缓存(key=path, value=expanded)
|
||||||
|
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// 过滤搜索
|
// 过滤搜索
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo<PathRow[]>(() => {
|
||||||
if (!searchQuery) return paths.all.map((p, i) => ({ path: p, index: i }));
|
if (!searchQuery) return paths.map((p, i) => ({ path: p, index: i }));
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const result: { path: string; index: number }[] = [];
|
const result: PathRow[] = [];
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
if (paths.get(i)!.toLowerCase().includes(q)) {
|
const p = paths[i];
|
||||||
result.push({ path: paths.get(i)!, index: i });
|
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [paths, searchQuery]);
|
}, [paths, searchQuery]);
|
||||||
|
|
||||||
// 路径验证状态
|
// 异步验证未缓存的路径
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const allPaths = paths;
|
||||||
|
|
||||||
|
// 找出未缓存的路径
|
||||||
|
const toValidate = allPaths.filter((p) => !validationCache.has(p));
|
||||||
|
if (toValidate.length === 0) return;
|
||||||
|
|
||||||
|
// 批量验证(限制并发 20)
|
||||||
|
const batch = toValidate.slice(0, 20);
|
||||||
|
Promise.all(
|
||||||
|
batch.map(async (p): Promise<[string, boolean]> => {
|
||||||
|
try {
|
||||||
|
if (p.includes('%')) return [p, true];
|
||||||
|
const valid: boolean = await invoke('validate_path', { path: p });
|
||||||
|
return [p, valid];
|
||||||
|
} catch {
|
||||||
|
return [p, true];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setValidationCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) {
|
||||||
|
next.set(p, v);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths, validationCache]);
|
||||||
|
|
||||||
|
// 异步展开环境变量(用于 tooltip)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const toExpand = paths.filter(
|
||||||
|
(p) => p.includes('%') && !expandedCache.has(p),
|
||||||
|
);
|
||||||
|
if (toExpand.length === 0) return;
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
toExpand.map(async (p): Promise<[string, string]> => {
|
||||||
|
try {
|
||||||
|
const expanded: string = await invoke('expand_env_vars', { path: p });
|
||||||
|
return [p, expanded !== p ? expanded : ''];
|
||||||
|
} catch {
|
||||||
|
return [p, ''];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setExpandedCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) {
|
||||||
|
next.set(p, v);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths, expandedCache]);
|
||||||
|
|
||||||
|
// 所有路径都默认有效(异步验证结果回来后再精确染色)
|
||||||
const validations = useMemo(() => {
|
const validations = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return filtered.map(({ path }) => {
|
return filtered.map(({ path }) => {
|
||||||
@@ -38,11 +117,12 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
return {
|
return {
|
||||||
isValid: validatePath(path),
|
isValid: validationCache.get(path) ?? true,
|
||||||
isDuplicate,
|
isDuplicate,
|
||||||
|
isEnvVar: path.includes('%'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [filtered]);
|
}, [filtered, validationCache]);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(realIndex: number, e: React.MouseEvent) => {
|
(realIndex: number, e: React.MouseEvent) => {
|
||||||
@@ -62,10 +142,9 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(realIndex: number) => {
|
(realIndex: number) => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
// 双击编辑 — 由 AppShell 处理(通过事件)
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('path-dblclick', {
|
new CustomEvent('path-dblclick', {
|
||||||
detail: { index: realIndex, path: paths.get(realIndex) },
|
detail: { index: realIndex, path: paths[realIndex] },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -78,10 +157,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr
|
<tr
|
||||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
className="sticky top-0 z-10 text-left text-xs uppercase"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
backgroundColor: 'var(--app-list-alt)',
|
|
||||||
color: 'var(--app-fg)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<th className="w-8 px-2 py-1">#</th>
|
<th className="w-8 px-2 py-1">#</th>
|
||||||
<th className="px-2 py-1">路径</th>
|
<th className="px-2 py-1">路径</th>
|
||||||
@@ -109,13 +185,14 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
: 'var(--app-list-alt)',
|
: 'var(--app-list-alt)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||||
className="px-2 py-0.5 text-xs opacity-50"
|
|
||||||
style={{ color: 'var(--app-fg)' }}
|
|
||||||
>
|
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-0.5 text-sm" style={{ color: textColor }}>
|
<td
|
||||||
|
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
title={expandedCache.get(path) || undefined}
|
||||||
|
>
|
||||||
{path}
|
{path}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export function UndoRedoButtons() {
|
|||||||
borderColor: 'var(--app-border)',
|
borderColor: 'var(--app-border)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 订阅状态更新(canUndo/canRedo 不会触发 re-render,用 setTimeout 简单轮询不优雅,但 Zustand 的 subscribe 可以)
|
|
||||||
// 这里简化为每次渲染时检查(因为 undo/redo 会修改列表触发重渲染)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, children }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-6"
|
||||||
|
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,7 +145,14 @@ function parseCsvLine(line: string): string[] {
|
|||||||
export function importFromJson(content: string): ImportResult {
|
export function importFromJson(content: string): ImportResult {
|
||||||
const result: ImportResult = { system: [], user: [] };
|
const result: ImportResult = { system: [], user: [] };
|
||||||
|
|
||||||
const obj = JSON.parse(content);
|
let obj: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return result; // 无效 JSON 返回空结果,由调用方显示错误
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj !== 'object' || obj === null) return result;
|
||||||
|
|
||||||
if (Array.isArray(obj.system)) {
|
if (Array.isArray(obj.system)) {
|
||||||
result.system = obj.system.filter(
|
result.system = obj.system.filter(
|
||||||
|
|||||||
+14
-59
@@ -1,92 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* 路径管理器 — 对应 C 版 path_manager.c
|
* 路径管理器 — 不可变的 string[] 操作
|
||||||
* 提供路径增删移清理等 CRUD 操作的纯逻辑
|
|
||||||
*/
|
*/
|
||||||
import { StringList } from './string-list';
|
|
||||||
|
|
||||||
/** 删除指定索引的路径 */
|
|
||||||
export function pathRemoveAt(list: StringList, index: number): void {
|
|
||||||
list.removeAt(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上移路径(调整优先级) */
|
|
||||||
export function pathMoveUp(list: StringList, index: number): boolean {
|
|
||||||
if (index <= 0 || index >= list.length) return false;
|
|
||||||
list.swap(index, index - 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下移路径(调整优先级) */
|
|
||||||
export function pathMoveDown(list: StringList, index: number): boolean {
|
|
||||||
if (index < 0 || index >= list.length - 1) return false;
|
|
||||||
list.swap(index, index + 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标记路径的有效性(调用方负责提供验证函数和展开 env vars) */
|
|
||||||
export interface PathValidation {
|
export interface PathValidation {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
isEnvVar: boolean;
|
isEnvVar: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 分析路径列表中各条目的状态
|
|
||||||
* validateFn: 验证路径是否有效(需调用 Rust validate_path)
|
|
||||||
*/
|
|
||||||
export function analyzePaths(
|
export function analyzePaths(
|
||||||
list: StringList,
|
paths: readonly string[],
|
||||||
validateFn: (path: string) => boolean,
|
validateFn: (path: string) => boolean,
|
||||||
): PathValidation[] {
|
): PathValidation[] {
|
||||||
const result: PathValidation[] = [];
|
const result: PathValidation[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (const path of paths) {
|
||||||
const path = list.get(i)!;
|
|
||||||
const lower = path.toLowerCase();
|
const lower = path.toLowerCase();
|
||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
|
result.push({ isValid: validateFn(path), isDuplicate, isEnvVar: path.includes('%') });
|
||||||
result.push({
|
|
||||||
isValid: validateFn(path),
|
|
||||||
isDuplicate,
|
|
||||||
isEnvVar: path.includes('%'),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 从数组中移除无效和重复路径,返回 [新数组, 被移除的路径] */
|
||||||
* 批量删除选中的索引(从大到小排序以避免偏移)
|
|
||||||
*/
|
|
||||||
export function batchRemoveAt(list: StringList, indices: number[]): void {
|
|
||||||
const sorted = [...indices].sort((a, b) => b - a);
|
|
||||||
for (const idx of sorted) {
|
|
||||||
list.removeAt(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一键清理 — 移除无效路径和重复路径
|
|
||||||
* 从后往前操作以避免索引偏移
|
|
||||||
* 返回被移除的路径数量
|
|
||||||
*/
|
|
||||||
export function pathClean(
|
export function pathClean(
|
||||||
list: StringList,
|
paths: readonly string[],
|
||||||
validateFn: (path: string) => boolean,
|
validateFn: (path: string) => boolean,
|
||||||
): string[] {
|
): [string[], string[]] {
|
||||||
const analysis = analyzePaths(list, validateFn);
|
const analysis = analyzePaths(paths, validateFn);
|
||||||
|
const kept: string[] = [];
|
||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
|
|
||||||
for (let i = analysis.length - 1; i >= 0; i--) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
const a = analysis[i];
|
const a = analysis[i];
|
||||||
// 移除无效或重复的路径
|
|
||||||
if (!a.isValid || a.isDuplicate) {
|
if (!a.isValid || a.isDuplicate) {
|
||||||
removed.unshift(list.get(i)!);
|
removed.push(paths[i]);
|
||||||
list.removeAt(i);
|
} else {
|
||||||
|
kept.push(paths[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
return [kept, removed];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* StringList — 纯 TypeScript 的字符串列表数据结构
|
|
||||||
* 对应 C 版 include/utils/string_ext.h 的 StringList
|
|
||||||
*/
|
|
||||||
export class StringList {
|
|
||||||
private items: string[] = [];
|
|
||||||
|
|
||||||
/** 追加字符串 */
|
|
||||||
add(str: string): void {
|
|
||||||
this.items.push(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在指定索引处插入 */
|
|
||||||
insertAt(index: number, str: string): void {
|
|
||||||
this.items.splice(index, 0, str);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除指定索引处的元素 */
|
|
||||||
removeAt(index: number): void {
|
|
||||||
this.items.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取索引处元素 */
|
|
||||||
get(index: number): string | undefined {
|
|
||||||
return this.items[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设置索引处元素 */
|
|
||||||
set(index: number, str: string): void {
|
|
||||||
this.items[index] = str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 不区分大小写查找是否包含 */
|
|
||||||
contains(str: string): boolean {
|
|
||||||
return this.items.some((item) => item.toLowerCase() === str.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查找不区分大小写的索引,未找到返回 -1 */
|
|
||||||
indexOfIgnoreCase(str: string): number {
|
|
||||||
const lower = str.toLowerCase();
|
|
||||||
return this.items.findIndex((item) => item.toLowerCase() === lower);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 交换两个索引的元素 */
|
|
||||||
swap(i: number, j: number): void {
|
|
||||||
const tmp = this.items[i];
|
|
||||||
this.items[i] = this.items[j];
|
|
||||||
this.items[j] = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清空所有元素 */
|
|
||||||
clear(): void {
|
|
||||||
this.items = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 深拷贝 */
|
|
||||||
clone(): StringList {
|
|
||||||
const list = new StringList();
|
|
||||||
list.items = [...this.items];
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 转换为普通数组(传给 Rust 后端) */
|
|
||||||
toArray(): string[] {
|
|
||||||
return [...this.items];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从数组初始化 */
|
|
||||||
static fromArray(arr: string[]): StringList {
|
|
||||||
const list = new StringList();
|
|
||||||
list.items = [...arr];
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 元素数量 */
|
|
||||||
get length(): number {
|
|
||||||
return this.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只读数组 */
|
|
||||||
get all(): readonly string[] {
|
|
||||||
return this.items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+36
-88
@@ -1,25 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 撤销/重做管理器 — 对应 C 版 undo_redo.c
|
* 撤销/重做管理器 — 纯逻辑,操作不可变 string[]
|
||||||
* 支持 8 种操作类型的完整撤销/重做
|
|
||||||
*/
|
*/
|
||||||
import { StringList } from './string-list';
|
|
||||||
|
|
||||||
export const OperationType = {
|
export const OperationType = {
|
||||||
ADD: 0, // 新增路径
|
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7,
|
||||||
DELETE: 1, // 删除路径
|
|
||||||
EDIT: 2, // 编辑路径
|
|
||||||
MOVE_UP: 3, // 上移
|
|
||||||
MOVE_DOWN: 4, // 下移
|
|
||||||
CLEAN: 5, // 一键清理
|
|
||||||
CLEAR: 6, // 清空
|
|
||||||
IMPORT: 7, // 导入
|
|
||||||
} as const;
|
} as const;
|
||||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||||
|
|
||||||
export const TargetType = {
|
export const TargetType = { SYSTEM: 0, USER: 1 } as const;
|
||||||
SYSTEM: 0,
|
|
||||||
USER: 1,
|
|
||||||
} as const;
|
|
||||||
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
|
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
|
||||||
|
|
||||||
export interface OpRecord {
|
export interface OpRecord {
|
||||||
@@ -42,139 +30,99 @@ export class UndoRedoManager {
|
|||||||
this.maxSize = maxSize;
|
this.maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推送新操作记录,推送后截断重做分支 */
|
|
||||||
push(record: OpRecord): void {
|
push(record: OpRecord): void {
|
||||||
// 截断重做分支
|
|
||||||
this.records = this.records.slice(0, this.current + 1);
|
this.records = this.records.slice(0, this.current + 1);
|
||||||
|
|
||||||
// 如果已满,移除最旧的记录
|
|
||||||
if (this.records.length >= this.maxSize) {
|
if (this.records.length >= this.maxSize) {
|
||||||
this.records.shift();
|
this.records.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.records.push(record);
|
this.records.push(record);
|
||||||
this.current = this.records.length - 1;
|
this.current = this.records.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 撤销当前操作 */
|
undo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
||||||
undo(sysPaths: StringList, userPaths: StringList): boolean {
|
if (this.current < 0) return null;
|
||||||
if (this.current < 0) return false;
|
|
||||||
|
|
||||||
const rec = this.records[this.current];
|
const rec = this.records[this.current];
|
||||||
this.current--;
|
this.current--;
|
||||||
|
|
||||||
const target = rec.target === TargetType.SYSTEM ? sysPaths : userPaths;
|
const sys = [...sysPaths];
|
||||||
|
const user = [...userPaths];
|
||||||
|
const target = rec.target === TargetType.SYSTEM ? sys : user;
|
||||||
|
|
||||||
switch (rec.type) {
|
switch (rec.type) {
|
||||||
case OperationType.ADD:
|
case OperationType.ADD:
|
||||||
// 撤销添加 — 删除最后 count 个元素
|
target.splice(target.length - rec.count, rec.count);
|
||||||
for (let i = 0; i < rec.count; i++) {
|
|
||||||
target.removeAt(target.length - 1);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.DELETE:
|
case OperationType.DELETE:
|
||||||
// 撤销删除 — 逐个恢复
|
|
||||||
for (let i = 0; i < rec.count; i++) {
|
for (let i = 0; i < rec.count; i++) {
|
||||||
target.insertAt(rec.index + i, rec.oldPaths[i]);
|
target.splice(rec.index + i, 0, rec.oldPaths[i]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.EDIT:
|
case OperationType.EDIT:
|
||||||
target.set(rec.index, rec.oldPaths[0]);
|
target[rec.index] = rec.oldPaths[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.MOVE_UP:
|
case OperationType.MOVE_UP:
|
||||||
target.swap(rec.index - 1, rec.index);
|
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.MOVE_DOWN:
|
case OperationType.MOVE_DOWN:
|
||||||
target.swap(rec.index, rec.index + 1);
|
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.CLEAN:
|
case OperationType.CLEAN:
|
||||||
case OperationType.IMPORT:
|
case OperationType.IMPORT:
|
||||||
// 恢复到操作前的完整列表
|
target.length = 0;
|
||||||
target.clear();
|
target.push(...rec.oldPaths);
|
||||||
for (const path of rec.oldPaths) {
|
|
||||||
target.add(path);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.CLEAR:
|
case OperationType.CLEAR:
|
||||||
for (const path of rec.oldPaths) {
|
target.push(...rec.oldPaths);
|
||||||
target.add(path);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return [sys, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重做下一个操作 */
|
redo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
|
||||||
redo(sysPaths: StringList, userPaths: StringList): boolean {
|
if (this.current >= this.records.length - 1) return null;
|
||||||
if (this.current >= this.records.length - 1) return false;
|
|
||||||
|
|
||||||
this.current++;
|
this.current++;
|
||||||
const rec = this.records[this.current];
|
const rec = this.records[this.current];
|
||||||
const target = rec.target === TargetType.SYSTEM ? sysPaths : userPaths;
|
|
||||||
|
const sys = [...sysPaths];
|
||||||
|
const user = [...userPaths];
|
||||||
|
const target = rec.target === TargetType.SYSTEM ? sys : user;
|
||||||
|
|
||||||
switch (rec.type) {
|
switch (rec.type) {
|
||||||
case OperationType.ADD:
|
case OperationType.ADD:
|
||||||
for (let i = 0; i < rec.count; i++) {
|
target.push(...rec.newPaths);
|
||||||
target.add(rec.newPaths[i]);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.DELETE:
|
case OperationType.DELETE:
|
||||||
// 从后往前删,避免索引偏移
|
|
||||||
for (let i = rec.count - 1; i >= 0; i--) {
|
for (let i = rec.count - 1; i >= 0; i--) {
|
||||||
target.removeAt(rec.index + i);
|
target.splice(rec.index + i, 1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.EDIT:
|
case OperationType.EDIT:
|
||||||
target.set(rec.index, rec.newPaths[0]);
|
target[rec.index] = rec.newPaths[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.MOVE_UP:
|
case OperationType.MOVE_UP:
|
||||||
target.swap(rec.index - 1, rec.index);
|
[target[rec.index - 1], target[rec.index]] = [target[rec.index], target[rec.index - 1]];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.MOVE_DOWN:
|
case OperationType.MOVE_DOWN:
|
||||||
target.swap(rec.index, rec.index + 1);
|
[target[rec.index], target[rec.index + 1]] = [target[rec.index + 1], target[rec.index]];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.CLEAN:
|
case OperationType.CLEAN:
|
||||||
case OperationType.IMPORT:
|
case OperationType.IMPORT:
|
||||||
target.clear();
|
target.length = 0;
|
||||||
for (const path of rec.newPaths) {
|
target.push(...rec.newPaths);
|
||||||
target.add(path);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case OperationType.CLEAR:
|
case OperationType.CLEAR:
|
||||||
target.clear();
|
target.length = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return [sys, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
canUndo(): boolean {
|
canUndo(): boolean { return this.current >= 0; }
|
||||||
return this.current >= 0;
|
canRedo(): boolean { return this.current < this.records.length - 1; }
|
||||||
}
|
clear(): void { this.records = []; this.current = -1; }
|
||||||
|
get historyLength(): number { return this.records.length; }
|
||||||
canRedo(): boolean {
|
|
||||||
return this.current < this.records.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.records = [];
|
|
||||||
this.current = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
get historyLength(): number {
|
|
||||||
return this.records.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { TargetType } from '@/core/undo-redo';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
|
||||||
|
import { is_valid_path_format } from '@/core/validation';
|
||||||
|
import { useKeyboard } from './use-keyboard';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import type { TabId } from '@/store/app-store';
|
||||||
|
|
||||||
|
export interface DialogState {
|
||||||
|
editDialog: { open: boolean; index: number; value: string; target: TargetType };
|
||||||
|
newDialog: boolean;
|
||||||
|
helpOpen: boolean;
|
||||||
|
importDialog: { open: boolean; system: string[]; user: string[] };
|
||||||
|
setEditDialog: (v: DialogState['editDialog']) => void;
|
||||||
|
setNewDialog: (v: boolean) => void;
|
||||||
|
setHelpOpen: (v: boolean) => void;
|
||||||
|
setImportDialog: (v: DialogState['importDialog']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||||
|
const { setEditDialog, setNewDialog, setHelpOpen, setImportDialog } = dialogs;
|
||||||
|
|
||||||
|
const getCurrentTarget = useCallback((): TargetType => {
|
||||||
|
return activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// ── CRUD ──
|
||||||
|
|
||||||
|
const handleNew = useCallback(() => setNewDialog(true), [setNewDialog]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
const idx = useAppStore.getState().selectedIndices[0];
|
||||||
|
if (idx === undefined) return;
|
||||||
|
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
||||||
|
const list = target === TargetType.SYSTEM
|
||||||
|
? useAppStore.getState().sysPaths
|
||||||
|
: useAppStore.getState().userPaths;
|
||||||
|
const value = list[idx];
|
||||||
|
if (value) setEditDialog({ open: true, index: idx, value, target });
|
||||||
|
}, [activeTab, setEditDialog]);
|
||||||
|
|
||||||
|
const handleBrowse = useCallback(async () => {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
useAppStore.getState().addPath(selected, getCurrentTarget());
|
||||||
|
}
|
||||||
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
const idx = useAppStore.getState().selectedIndices;
|
||||||
|
if (idx.length === 0) return;
|
||||||
|
useAppStore.getState().deletePaths(idx, getCurrentTarget());
|
||||||
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
|
const handleMoveUp = useCallback(() => {
|
||||||
|
const idx = useAppStore.getState().selectedIndices[0];
|
||||||
|
if (idx === undefined) return;
|
||||||
|
useAppStore.getState().moveUp(idx, getCurrentTarget());
|
||||||
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
|
const handleMoveDown = useCallback(() => {
|
||||||
|
const idx = useAppStore.getState().selectedIndices[0];
|
||||||
|
if (idx === undefined) return;
|
||||||
|
useAppStore.getState().moveDown(idx, getCurrentTarget());
|
||||||
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
|
const handleClean = useCallback(() => {
|
||||||
|
const removed = useAppStore.getState().cleanPaths(
|
||||||
|
getCurrentTarget(),
|
||||||
|
is_valid_path_format,
|
||||||
|
);
|
||||||
|
if (removed.length > 0) {
|
||||||
|
useAppStore.getState().setStatusMessage(
|
||||||
|
i18n.t('status.deleted', { count: removed.length }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
|
// ── 导入导出 ──
|
||||||
|
|
||||||
|
const handleImport = useCallback(() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json,.csv,.txt';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) { input.remove(); return; }
|
||||||
|
const content = await file.text();
|
||||||
|
const result = importFromContent(content, file.name);
|
||||||
|
input.remove();
|
||||||
|
if (result.system.length > 0 && result.user.length > 0) {
|
||||||
|
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||||
|
} else if (result.system.length > 0) {
|
||||||
|
useAppStore.getState().importPaths(TargetType.SYSTEM, result.system);
|
||||||
|
} else if (result.user.length > 0) {
|
||||||
|
useAppStore.getState().importPaths(TargetType.USER, result.user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}, [setImportDialog]);
|
||||||
|
|
||||||
|
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
const data = { system: state.sysPaths, user: state.userPaths };
|
||||||
|
const isCsv = format === 'csv';
|
||||||
|
const content = isCsv ? exportToCsv(data) : exportToJson(data);
|
||||||
|
const mime = isCsv ? 'text/csv' : 'application/json';
|
||||||
|
const ext = isCsv ? '.csv' : '.json';
|
||||||
|
const blob = new Blob([isCsv ? '' : '', content], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `patheditor_export${ext}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
useAppStore.getState().savePaths();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── 键盘 ──
|
||||||
|
|
||||||
|
useKeyboard({
|
||||||
|
onNew: handleNew,
|
||||||
|
onSave: handleSave,
|
||||||
|
onDelete: handleDelete,
|
||||||
|
onUndo: () => useAppStore.getState().undo(),
|
||||||
|
onRedo: () => useAppStore.getState().redo(),
|
||||||
|
onHelp: () => setHelpOpen(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 双击编辑 ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail && typeof detail.index === 'number') {
|
||||||
|
const target = getCurrentTarget();
|
||||||
|
setEditDialog({ open: true, index: detail.index, value: detail.path, target });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('path-dblclick', handler);
|
||||||
|
return () => window.removeEventListener('path-dblclick', handler);
|
||||||
|
}, [getCurrentTarget, setEditDialog]);
|
||||||
|
|
||||||
|
// ── 弹窗确认 ──
|
||||||
|
|
||||||
|
const handleNewConfirm = useCallback((value: string) => {
|
||||||
|
setNewDialog(false);
|
||||||
|
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
||||||
|
}, [getCurrentTarget, setNewDialog]);
|
||||||
|
|
||||||
|
const handleEditConfirm = useCallback((value: string) => {
|
||||||
|
const d = dialogs.editDialog;
|
||||||
|
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
||||||
|
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
|
||||||
|
}, [dialogs.editDialog, setEditDialog]);
|
||||||
|
|
||||||
|
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
||||||
|
const { system, user } = dialogs.importDialog;
|
||||||
|
const flat = flattenImportResult({ system, user }, target);
|
||||||
|
if (flat.system.length > 0) useAppStore.getState().importPaths(TargetType.SYSTEM, flat.system);
|
||||||
|
if (flat.user.length > 0) useAppStore.getState().importPaths(TargetType.USER, flat.user);
|
||||||
|
setImportDialog({ open: false, system: [], user: [] });
|
||||||
|
}, [dialogs.importDialog, setImportDialog]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleNew, handleEdit, handleBrowse, handleDelete,
|
||||||
|
handleMoveUp, handleMoveDown, handleClean,
|
||||||
|
handleImport, handleExport, handleSave,
|
||||||
|
handleNewConfirm, handleEditConfirm, handleImportSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
+24
-12
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
interface KeyboardActions {
|
interface KeyboardActions {
|
||||||
@@ -7,18 +7,21 @@ interface KeyboardActions {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
|
onHelp: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局键盘快捷键
|
* 全局键盘快捷键
|
||||||
* Ctrl+N 新建, Ctrl+S 保存, Ctrl+Z 撤销, Ctrl+Y 重做, Delete 删除
|
* Ctrl+N 新建, Ctrl+S 保存, Ctrl+Z 撤销, Ctrl+Y 重做, Delete 删除, F1 帮助
|
||||||
|
* 使用 ref 避免因 actions 对象每次渲染都是新引用而重复注册事件
|
||||||
*/
|
*/
|
||||||
export function useKeyboard(actions: KeyboardActions) {
|
export function useKeyboard(actions: KeyboardActions) {
|
||||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||||
|
const actionsRef = useRef(actions);
|
||||||
|
actionsRef.current = actions;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
// 如果焦点在输入框中,只响应 Escape
|
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
|
|
||||||
@@ -29,32 +32,41 @@ export function useKeyboard(actions: KeyboardActions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) return;
|
const a = actionsRef.current;
|
||||||
|
|
||||||
const ctrl = e.ctrlKey || e.metaKey;
|
const ctrl = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
if (ctrl && e.key === 'z') {
|
if (ctrl && e.key === 'z') {
|
||||||
|
if (!isAdmin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
actions.onUndo();
|
a.onUndo();
|
||||||
} else if (ctrl && e.key === 'y') {
|
} else if (ctrl && e.key === 'y') {
|
||||||
|
if (!isAdmin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
actions.onRedo();
|
a.onRedo();
|
||||||
} else if (ctrl && e.key === 'n') {
|
} else if (ctrl && e.key === 'n') {
|
||||||
|
if (!isAdmin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
actions.onNew();
|
a.onNew();
|
||||||
} else if (ctrl && e.key === 's') {
|
} else if (ctrl && e.key === 's') {
|
||||||
|
if (!isAdmin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
actions.onSave();
|
a.onSave();
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (!isAdmin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
actions.onDelete();
|
a.onDelete();
|
||||||
|
} else if (ctrl && e.key === 'f') {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchInput = document.querySelector<HTMLInputElement>('input[placeholder]');
|
||||||
|
searchInput?.focus();
|
||||||
|
searchInput?.select();
|
||||||
} else if (e.key === 'F1') {
|
} else if (e.key === 'F1') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 帮助由 AppShell 处理
|
a.onHelp();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [isAdmin, actions]);
|
}, [isAdmin]); // 只依赖 isAdmin,actions 通过 ref 读取
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
// Rust 端未就绪时的 fallback
|
|
||||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
|
||||||
|
|
||||||
/** 同步验证(基于本地规则,不含文件系统检查) */
|
|
||||||
export function validatePath(path: string): boolean {
|
|
||||||
if (path.includes('%')) return true;
|
|
||||||
return true; // 文件系统检查需要调用 Rust backend
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 异步验证(调用 Rust validate_path) */
|
|
||||||
export function useAsyncValidation() {
|
|
||||||
const [cache, setCache] = useState<Map<string, boolean>>(new Map());
|
|
||||||
|
|
||||||
const validate = useCallback(async (path: string): Promise<boolean> => {
|
|
||||||
if (path.includes('%')) return true;
|
|
||||||
if (cache.has(path)) return cache.get(path)!;
|
|
||||||
|
|
||||||
if (isTauri) {
|
|
||||||
try {
|
|
||||||
const valid: boolean = await invoke('validate_path', { path });
|
|
||||||
setCache((prev) => new Map(prev).set(path, valid));
|
|
||||||
return valid;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, [cache]);
|
|
||||||
|
|
||||||
return { validate, cache };
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,13 @@
|
|||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
"lightMode": "Light Mode",
|
"lightMode": "Light Mode",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"merge": {
|
||||||
|
"system": "System",
|
||||||
|
"user": "User",
|
||||||
|
"source": "Source"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"normal": "Ready",
|
"normal": "Ready",
|
||||||
@@ -33,9 +39,15 @@
|
|||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Saved successfully",
|
"saved": "Saved successfully",
|
||||||
"error": "Operation failed",
|
"error": "Operation failed",
|
||||||
|
"warning_backup": "Backup creation failed, save will proceed without backup",
|
||||||
"deleted": "Deleted {{count}} path(s)",
|
"deleted": "Deleted {{count}} path(s)",
|
||||||
"loaded": "Loaded {{sysCount}} system and {{userCount}} user paths",
|
"loaded": "Loaded {{sysCount}} system and {{userCount}} user paths",
|
||||||
"dragFolderOnly": "Only folders can be dropped",
|
"dragFolderOnly": "Only folders can be dropped",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"modified": "Modified",
|
||||||
|
"readonly_label": "Read-only",
|
||||||
|
"light": "Light",
|
||||||
|
"dark": "Dark",
|
||||||
"adminWarning": "Running without administrator privileges, some features are disabled"
|
"adminWarning": "Running without administrator privileges, some features are disabled"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|||||||
@@ -25,7 +25,13 @@
|
|||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"lightMode": "浅色模式",
|
"lightMode": "浅色模式",
|
||||||
"language": "语言"
|
"language": "语言",
|
||||||
|
"retry": "重试"
|
||||||
|
},
|
||||||
|
"merge": {
|
||||||
|
"system": "系统",
|
||||||
|
"user": "用户",
|
||||||
|
"source": "来源"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"normal": "就绪",
|
"normal": "就绪",
|
||||||
@@ -33,10 +39,16 @@
|
|||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"saved": "保存成功",
|
"saved": "保存成功",
|
||||||
"error": "操作失败",
|
"error": "操作失败",
|
||||||
|
"warning_backup": "备份创建失败,保存将继续但不生成备份",
|
||||||
"deleted": "已删除 {{count}} 个路径",
|
"deleted": "已删除 {{count}} 个路径",
|
||||||
"loaded": "已加载 {{sysCount}} 个系统路径和 {{userCount}} 个用户路径",
|
"loaded": "已加载 {{sysCount}} 个系统路径和 {{userCount}} 个用户路径",
|
||||||
"dragFolderOnly": "只能拖拽文件夹",
|
"dragFolderOnly": "只能拖拽文件夹",
|
||||||
"adminWarning": "当前以非管理员身份运行,部分功能不可用"
|
"adminWarning": "当前以非管理员身份运行,部分功能不可用",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"modified": "已修改",
|
||||||
|
"readonly_label": "只读",
|
||||||
|
"light": "浅色",
|
||||||
|
"dark": "深色"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"newPath": "新建路径",
|
"newPath": "新建路径",
|
||||||
|
|||||||
+164
-185
@@ -1,23 +1,19 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { StringList } from '@/core/string-list';
|
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
||||||
import {
|
|
||||||
UndoRedoManager,
|
|
||||||
OperationType,
|
|
||||||
TargetType,
|
|
||||||
} from '@/core/undo-redo';
|
|
||||||
import { pathClean } from '@/core/path-manager';
|
import { pathClean } from '@/core/path-manager';
|
||||||
|
import appConfig from '@/config/default.json';
|
||||||
|
|
||||||
export type TabId = 'system' | 'user' | 'merged';
|
export type TabId = 'system' | 'user' | 'merged';
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// 数据(用 StringList 而非 string[] 以支持 undo/redo)
|
sysPaths: string[];
|
||||||
sysPaths: StringList;
|
userPaths: string[];
|
||||||
userPaths: StringList;
|
|
||||||
undoRedo: UndoRedoManager;
|
undoRedo: UndoRedoManager;
|
||||||
|
_savedSys: string[]; // 上次保存时的快照,用于 isModified 判断
|
||||||
|
_savedUser: string[];
|
||||||
|
|
||||||
// UI 状态
|
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedIndices: number[];
|
selectedIndices: number[];
|
||||||
@@ -25,14 +21,13 @@ interface AppState {
|
|||||||
statusMessage: string;
|
statusMessage: string;
|
||||||
isModified: boolean;
|
isModified: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
|
||||||
// 基本操作
|
|
||||||
setActiveTab: (tab: TabId) => void;
|
setActiveTab: (tab: TabId) => void;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setSelectedIndices: (indices: number[]) => void;
|
setSelectedIndices: (indices: number[]) => void;
|
||||||
setStatusMessage: (msg: string) => void;
|
setStatusMessage: (msg: string) => void;
|
||||||
|
|
||||||
// CRUD(带撤销/重做)
|
|
||||||
addPath: (path: string, target: TargetType) => void;
|
addPath: (path: string, target: TargetType) => void;
|
||||||
editPath: (index: number, newPath: string, target: TargetType) => void;
|
editPath: (index: number, newPath: string, target: TargetType) => void;
|
||||||
deletePaths: (indices: number[], target: TargetType) => void;
|
deletePaths: (indices: number[], target: TargetType) => void;
|
||||||
@@ -42,29 +37,28 @@ interface AppState {
|
|||||||
importPaths: (target: TargetType, importPaths: string[]) => void;
|
importPaths: (target: TargetType, importPaths: string[]) => void;
|
||||||
clearPaths: (target: TargetType) => void;
|
clearPaths: (target: TargetType) => void;
|
||||||
|
|
||||||
// 撤销/重做
|
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: () => boolean;
|
canUndo: () => boolean;
|
||||||
canRedo: () => boolean;
|
canRedo: () => boolean;
|
||||||
|
|
||||||
// 数据加载/保存
|
|
||||||
loadPaths: () => Promise<void>;
|
loadPaths: () => Promise<void>;
|
||||||
savePaths: () => Promise<void>;
|
savePaths: () => Promise<void>;
|
||||||
loadFromStringLists: (sys: string[], user: string[]) => void;
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
|
||||||
// 内部辅助
|
_markDirty: () => void;
|
||||||
_getTargetList: (target: TargetType) => StringList;
|
}
|
||||||
_markModified: () => void;
|
|
||||||
|
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||||
|
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set, get) => ({
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
sysPaths: new StringList(),
|
sysPaths: [],
|
||||||
userPaths: new StringList(),
|
userPaths: [],
|
||||||
undoRedo: new UndoRedoManager(50),
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
|
_savedSys: [],
|
||||||
|
_savedUser: [],
|
||||||
|
|
||||||
activeTab: 'system',
|
activeTab: 'system',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
@@ -73,131 +67,104 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
statusMessage: '',
|
statusMessage: '',
|
||||||
isModified: false,
|
isModified: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
isSaving: false,
|
||||||
|
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
|
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
|
||||||
setStatusMessage: (msg) => set({ statusMessage: msg }),
|
setStatusMessage: (msg) => set({ statusMessage: msg }),
|
||||||
|
|
||||||
_getTargetList: (target) => {
|
|
||||||
const { sysPaths, userPaths } = get();
|
|
||||||
return target === TargetType.SYSTEM ? sysPaths : userPaths;
|
|
||||||
},
|
|
||||||
|
|
||||||
_markModified: () => set({ isModified: true }),
|
|
||||||
|
|
||||||
// ── CRUD ──
|
|
||||||
|
|
||||||
addPath: (path, target) => {
|
addPath: (path, target) => {
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
list.add(path);
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
get().undoRedo.push({
|
const newList = [...list, path];
|
||||||
type: OperationType.ADD,
|
state.undoRedo.push({
|
||||||
target,
|
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||||||
index: list.length - 1,
|
oldPaths: [], newPaths: [path],
|
||||||
count: 1,
|
|
||||||
oldPaths: [],
|
|
||||||
newPaths: [path],
|
|
||||||
});
|
});
|
||||||
get()._markModified();
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
|
else set({ userPaths: newList });
|
||||||
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
editPath: (index, newPath, target) => {
|
editPath: (index, newPath, target) => {
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
const oldPath = list.get(index);
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
if (!oldPath) return;
|
const oldPath = list[index];
|
||||||
|
if (oldPath === undefined) return;
|
||||||
get().undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.EDIT,
|
type: OperationType.EDIT, target, index, count: 1,
|
||||||
target,
|
oldPaths: [oldPath], newPaths: [newPath],
|
||||||
index,
|
|
||||||
count: 1,
|
|
||||||
oldPaths: [oldPath],
|
|
||||||
newPaths: [newPath],
|
|
||||||
});
|
});
|
||||||
list.set(index, newPath);
|
const newList = [...list];
|
||||||
get()._markModified();
|
newList[index] = newPath;
|
||||||
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||||
|
else set({ userPaths: newList });
|
||||||
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePaths: (indices, target) => {
|
deletePaths: (indices, target) => {
|
||||||
const list = get()._getTargetList(target);
|
if (indices.length === 0) return;
|
||||||
// 从大到小排
|
const state = get();
|
||||||
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
const sorted = [...indices].sort((a, b) => b - a);
|
const sorted = [...indices].sort((a, b) => b - a);
|
||||||
const oldPaths = sorted.map((i) => list.get(i)!);
|
const oldPaths = sorted.map((i) => list[i]);
|
||||||
|
|
||||||
// 记录单个 DELETE(合并多个删除为一条记录)
|
// 单条撤销记录覆盖全部删除
|
||||||
if (sorted.length === 1) {
|
state.undoRedo.push({
|
||||||
get().undoRedo.push({
|
type: OperationType.DELETE, target,
|
||||||
type: OperationType.DELETE,
|
index: sorted[sorted.length - 1], count: sorted.length,
|
||||||
target,
|
oldPaths, newPaths: [],
|
||||||
index: sorted[0],
|
});
|
||||||
count: 1,
|
|
||||||
oldPaths,
|
|
||||||
newPaths: [],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
get().undoRedo.push({
|
|
||||||
type: OperationType.DELETE,
|
|
||||||
target,
|
|
||||||
index: sorted[sorted.length - 1],
|
|
||||||
count: sorted.length,
|
|
||||||
oldPaths,
|
|
||||||
newPaths: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const i of sorted) {
|
const toRemove = new Set(sorted);
|
||||||
list.removeAt(i);
|
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||||
}
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
|
||||||
set({ selectedIndices: [] });
|
else set({ userPaths: newList, selectedIndices: [] });
|
||||||
get()._markModified();
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
moveUp: (index, target) => {
|
moveUp: (index, target) => {
|
||||||
if (index <= 0) return;
|
if (index <= 0) return;
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
get().undoRedo.push({
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
type: OperationType.MOVE_UP,
|
state.undoRedo.push({
|
||||||
target,
|
type: OperationType.MOVE_UP, target, index, count: 1, oldPaths: [], newPaths: [],
|
||||||
index,
|
|
||||||
count: 1,
|
|
||||||
oldPaths: [],
|
|
||||||
newPaths: [],
|
|
||||||
});
|
});
|
||||||
list.swap(index, index - 1);
|
const newList = [...list];
|
||||||
get()._markModified();
|
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||||
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
|
||||||
|
else set({ userPaths: newList, selectedIndices: [index - 1] });
|
||||||
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
moveDown: (index, target) => {
|
moveDown: (index, target) => {
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
if (index >= list.length - 1) return;
|
if (index >= list.length - 1) return;
|
||||||
get().undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.MOVE_DOWN,
|
type: OperationType.MOVE_DOWN, target, index, count: 1, oldPaths: [], newPaths: [],
|
||||||
target,
|
|
||||||
index,
|
|
||||||
count: 1,
|
|
||||||
oldPaths: [],
|
|
||||||
newPaths: [],
|
|
||||||
});
|
});
|
||||||
list.swap(index, index + 1);
|
const newList = [...list];
|
||||||
get()._markModified();
|
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||||
|
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
|
||||||
|
else set({ userPaths: newList, selectedIndices: [index + 1] });
|
||||||
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
cleanPaths: (target, validateFn) => {
|
cleanPaths: (target, validateFn) => {
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
const oldPaths = list.toArray();
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
const removed = pathClean(list, validateFn);
|
const [kept, removed] = pathClean(list, validateFn);
|
||||||
|
|
||||||
if (removed.length > 0) {
|
if (removed.length > 0) {
|
||||||
get().undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.CLEAN,
|
type: OperationType.CLEAN, target, index: 0, count: removed.length,
|
||||||
target,
|
oldPaths: [...list], newPaths: kept,
|
||||||
index: 0,
|
|
||||||
count: removed.length,
|
|
||||||
oldPaths,
|
|
||||||
newPaths: list.toArray(),
|
|
||||||
});
|
});
|
||||||
get()._markModified();
|
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
||||||
|
else set({ userPaths: kept, selectedIndices: [] });
|
||||||
|
get()._markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
return removed;
|
||||||
@@ -205,70 +172,64 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
|
|
||||||
importPaths: (target, importPaths) => {
|
importPaths: (target, importPaths) => {
|
||||||
if (importPaths.length === 0) return;
|
if (importPaths.length === 0) return;
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
const oldPaths = list.toArray();
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
|
|
||||||
get().undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.IMPORT,
|
type: OperationType.IMPORT, target, index: 0, count: importPaths.length,
|
||||||
target,
|
oldPaths: [...list], newPaths: [...importPaths],
|
||||||
index: 0,
|
|
||||||
count: importPaths.length,
|
|
||||||
oldPaths,
|
|
||||||
newPaths: importPaths,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 覆盖为导入的路径
|
if (target === TargetType.SYSTEM) set({ sysPaths: [...importPaths], selectedIndices: [] });
|
||||||
list.clear();
|
else set({ userPaths: [...importPaths], selectedIndices: [] });
|
||||||
for (const p of importPaths) {
|
get()._markDirty();
|
||||||
list.add(p);
|
|
||||||
}
|
|
||||||
get()._markModified();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearPaths: (target) => {
|
clearPaths: (target) => {
|
||||||
const list = get()._getTargetList(target);
|
const state = get();
|
||||||
const oldPaths = list.toArray();
|
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||||
if (oldPaths.length === 0) return;
|
if (list.length === 0) return;
|
||||||
|
|
||||||
get().undoRedo.push({
|
state.undoRedo.push({
|
||||||
type: OperationType.CLEAR,
|
type: OperationType.CLEAR, target, index: 0, count: list.length,
|
||||||
target,
|
oldPaths: [...list], newPaths: [],
|
||||||
index: 0,
|
|
||||||
count: oldPaths.length,
|
|
||||||
oldPaths,
|
|
||||||
newPaths: [],
|
|
||||||
});
|
});
|
||||||
list.clear();
|
|
||||||
get()._markModified();
|
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
||||||
|
else set({ userPaths: [] });
|
||||||
|
get()._markDirty();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 撤销/重做 ──
|
|
||||||
|
|
||||||
undo: () => {
|
undo: () => {
|
||||||
const { undoRedo, sysPaths, userPaths } = get();
|
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||||
if (undoRedo.undo(sysPaths, userPaths)) {
|
const result = undoRedo.undo(sysPaths, userPaths);
|
||||||
set({ isModified: true, selectedIndices: [] });
|
if (result) {
|
||||||
|
set({
|
||||||
|
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||||
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
redo: () => {
|
redo: () => {
|
||||||
const { undoRedo, sysPaths, userPaths } = get();
|
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||||
if (undoRedo.redo(sysPaths, userPaths)) {
|
const result = undoRedo.redo(sysPaths, userPaths);
|
||||||
set({ isModified: true, selectedIndices: [] });
|
if (result) {
|
||||||
|
set({
|
||||||
|
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||||
|
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_markDirty: () => {
|
||||||
|
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
|
||||||
|
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
|
||||||
|
},
|
||||||
|
|
||||||
canUndo: () => get().undoRedo.canUndo(),
|
canUndo: () => get().undoRedo.canUndo(),
|
||||||
canRedo: () => get().undoRedo.canRedo(),
|
canRedo: () => get().undoRedo.canRedo(),
|
||||||
|
|
||||||
// ── 数据加载/保存 ──
|
|
||||||
|
|
||||||
loadFromStringLists: (sys: string[], user: string[]) => {
|
|
||||||
const sysPaths = StringList.fromArray(sys);
|
|
||||||
const userPaths = StringList.fromArray(user);
|
|
||||||
set({ sysPaths, userPaths, isModified: false, isLoading: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadPaths: async () => {
|
loadPaths: async () => {
|
||||||
try {
|
try {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
@@ -276,45 +237,63 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
invoke<string[]>('load_system_paths'),
|
invoke<string[]>('load_system_paths'),
|
||||||
invoke<string[]>('load_user_paths'),
|
invoke<string[]>('load_user_paths'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sysPaths = StringList.fromArray(sysArr);
|
|
||||||
const userPaths = StringList.fromArray(userArr);
|
|
||||||
const undoRedo = new UndoRedoManager(50);
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
sysPaths,
|
sysPaths: sysArr, userPaths: userArr,
|
||||||
userPaths,
|
_savedSys: [...sysArr], _savedUser: [...userArr],
|
||||||
undoRedo,
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
isLoading: false,
|
isLoading: false, isModified: false,
|
||||||
isModified: false,
|
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||||||
statusMessage: i18n.t('status.loaded', {
|
|
||||||
sysCount: sysArr.length,
|
|
||||||
userCount: userArr.length,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ isLoading: false, statusMessage: `加载失败: ${e}` });
|
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
savePaths: async () => {
|
savePaths: async () => {
|
||||||
const { sysPaths, userPaths } = get();
|
const state = get();
|
||||||
set({ statusMessage: '正在保存...' });
|
if (state.isSaving) return;
|
||||||
try {
|
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||||
await invoke('save_system_paths', { paths: sysPaths.toArray() });
|
|
||||||
await invoke('save_user_paths', { paths: userPaths.toArray() });
|
const { sysPaths, userPaths } = state;
|
||||||
await invoke('broadcast_env_change');
|
const sysJoined = sysPaths.join(';');
|
||||||
set({ isModified: false, statusMessage: '保存成功' });
|
const userJoined = userPaths.join(';');
|
||||||
} catch (e) {
|
|
||||||
set({ statusMessage: `保存失败: ${e}` });
|
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
||||||
|
if (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength) {
|
||||||
|
if (!window.confirm('PATH 长度超过建议值,是否继续保存?')) { set({ isSaving: false }); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份(失败时通知用户)
|
||||||
|
invoke('backup_registry', { customDir: null, sysPaths, userPaths })
|
||||||
|
.catch(() => set({ statusMessage: i18n.t('status.warning_backup') }));
|
||||||
|
|
||||||
|
const [sysResult, userResult] = await Promise.allSettled([
|
||||||
|
invoke('save_system_paths', { paths: sysPaths }),
|
||||||
|
invoke('save_user_paths', { paths: userPaths }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sysOk = sysResult.status === 'fulfilled';
|
||||||
|
const userOk = userResult.status === 'fulfilled';
|
||||||
|
|
||||||
|
if (sysOk && userOk) {
|
||||||
|
invoke('broadcast_env_change').catch(() => {});
|
||||||
|
const savedSys = [...sysPaths], savedUser = [...userPaths];
|
||||||
|
set({ isModified: false, isSaving: false, statusMessage: i18n.t('status.saved'), _savedSys: savedSys, _savedUser: savedUser });
|
||||||
|
} else {
|
||||||
|
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
|
||||||
|
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||||
|
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${reason}`;
|
||||||
|
set({ isSaving: false, statusMessage: msg });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
const isAdmin: boolean = await invoke('check_admin');
|
try {
|
||||||
set({ isAdmin });
|
const isAdmin: boolean = await invoke('check_admin');
|
||||||
if (!isAdmin) {
|
set({ isAdmin });
|
||||||
set({ statusMessage: '只读模式 — 需要管理员权限才能编辑' });
|
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
|
||||||
|
} catch {
|
||||||
|
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
|
||||||
}
|
}
|
||||||
await get().loadPaths();
|
await get().loadPaths();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ interface ThemeState {
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSavedDarkMode(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('darkMode') === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>((set) => ({
|
export const useThemeStore = create<ThemeState>((set) => ({
|
||||||
isDark: false,
|
isDark: getSavedDarkMode(),
|
||||||
toggle: () =>
|
toggle: () =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = !state.isDark;
|
const next = !state.isDark;
|
||||||
@@ -21,10 +29,10 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** 初始化深色模式状态(从 localStorage 读取) */
|
/** 初始化深色模式(DOM 类名 + store 状态) */
|
||||||
export function initDarkMode(): void {
|
export function initDarkMode(): void {
|
||||||
const saved = localStorage.getItem('darkMode');
|
if (getSavedDarkMode()) {
|
||||||
if (saved === '1') {
|
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
useThemeStore.setState({ isDark: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,31 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { pathClean } from '../../src/core/path-manager';
|
||||||
pathRemoveAt,
|
|
||||||
pathMoveUp,
|
|
||||||
pathMoveDown,
|
|
||||||
pathClean,
|
|
||||||
batchRemoveAt,
|
|
||||||
} from '../../src/core/path-manager';
|
|
||||||
import { StringList } from '../../src/core/string-list';
|
|
||||||
|
|
||||||
// 模拟验证函数:所有路径都"有效"
|
|
||||||
const alwaysValid = () => true;
|
const alwaysValid = () => true;
|
||||||
|
|
||||||
// 模拟验证函数:C:\\Invalid 无效
|
|
||||||
const validateFn = (path: string) => !path.includes('Invalid');
|
const validateFn = (path: string) => !path.includes('Invalid');
|
||||||
|
|
||||||
describe('pathRemoveAt', () => {
|
|
||||||
it('删除指定索引', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
|
||||||
pathRemoveAt(list, 1);
|
|
||||||
expect(list.toArray()).toEqual(['a', 'c']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pathMoveUp', () => {
|
|
||||||
it('上移元素', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
|
||||||
pathMoveUp(list, 1);
|
|
||||||
expect(list.toArray()).toEqual(['b', 'a', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('第一个元素不能上移', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b']);
|
|
||||||
expect(pathMoveUp(list, 0)).toBe(false);
|
|
||||||
expect(list.toArray()).toEqual(['a', 'b']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无效索引不能上移', () => {
|
|
||||||
const list = StringList.fromArray(['a']);
|
|
||||||
expect(pathMoveUp(list, -1)).toBe(false);
|
|
||||||
expect(pathMoveUp(list, 5)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pathMoveDown', () => {
|
|
||||||
it('下移元素', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
|
||||||
pathMoveDown(list, 0);
|
|
||||||
expect(list.toArray()).toEqual(['b', 'a', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('最后一个元素不能下移', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b']);
|
|
||||||
expect(pathMoveDown(list, 1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('batchRemoveAt', () => {
|
|
||||||
it('批量删除(按从大到小排序)', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c', 'd', 'e']);
|
|
||||||
batchRemoveAt(list, [0, 2, 4]);
|
|
||||||
expect(list.toArray()).toEqual(['b', 'd']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('删除乱序索引', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c', 'd']);
|
|
||||||
batchRemoveAt(list, [3, 0]);
|
|
||||||
expect(list.toArray()).toEqual(['b', 'c']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pathClean', () => {
|
describe('pathClean', () => {
|
||||||
it('移除无效路径', () => {
|
it('移除无效路径', () => {
|
||||||
const list = StringList.fromArray(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid']);
|
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Invalid', 'D:\\Valid'], validateFn);
|
||||||
const removed = pathClean(list, validateFn);
|
expect(kept).toEqual(['C:\\Valid', 'D:\\Valid']);
|
||||||
expect(list.toArray()).toEqual(['C:\\Valid', 'D:\\Valid']);
|
|
||||||
expect(removed).toEqual(['C:\\Invalid']);
|
expect(removed).toEqual(['C:\\Invalid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('移除重复路径(保留一个)', () => {
|
it('移除重复路径保留第一个', () => {
|
||||||
const list = StringList.fromArray(['C:\\Valid', 'C:\\Valid', 'D:\\Valid']);
|
const [kept, removed] = pathClean(['C:\\Valid', 'C:\\Valid', 'D:\\Valid'], alwaysValid);
|
||||||
const removed = pathClean(list, alwaysValid);
|
expect(kept.length).toBe(2);
|
||||||
expect(list.length).toBe(2);
|
expect(removed.length).toBe(1);
|
||||||
expect(removed.length).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('全部有效无变化', () => {
|
it('全部有效无变化', () => {
|
||||||
const list = StringList.fromArray(['C:\\a', 'D:\\b']);
|
const [kept, removed] = pathClean(['C:\\a', 'D:\\b'], alwaysValid);
|
||||||
const removed = pathClean(list, alwaysValid);
|
expect(kept).toEqual(['C:\\a', 'D:\\b']);
|
||||||
expect(list.toArray()).toEqual(['C:\\a', 'D:\\b']);
|
|
||||||
expect(removed.length).toBe(0);
|
expect(removed.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('全部无效全部移除', () => {
|
it('全部无效全部移除', () => {
|
||||||
const list = StringList.fromArray(['C:\\Invalid1', 'C:\\Invalid2']);
|
const [kept, removed] = pathClean(['C:\\Invalid1', 'C:\\Invalid2'], validateFn);
|
||||||
const removed = pathClean(list, validateFn);
|
expect(kept.length).toBe(0);
|
||||||
expect(list.length).toBe(0);
|
|
||||||
expect(removed.length).toBe(2);
|
expect(removed.length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { StringList } from '../../src/core/string-list';
|
|
||||||
|
|
||||||
describe('StringList', () => {
|
|
||||||
it('初始为空', () => {
|
|
||||||
const list = new StringList();
|
|
||||||
expect(list.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('添加和读取元素', () => {
|
|
||||||
const list = new StringList();
|
|
||||||
list.add('C:\\Windows');
|
|
||||||
list.add('C:\\Users');
|
|
||||||
expect(list.length).toBe(2);
|
|
||||||
expect(list.get(0)).toBe('C:\\Windows');
|
|
||||||
expect(list.get(1)).toBe('C:\\Users');
|
|
||||||
expect(list.get(99)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('在指定位置插入', () => {
|
|
||||||
const list = new StringList();
|
|
||||||
list.add('a');
|
|
||||||
list.add('c');
|
|
||||||
list.insertAt(1, 'b');
|
|
||||||
expect(list.toArray()).toEqual(['a', 'b', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('在开头插入', () => {
|
|
||||||
const list = StringList.fromArray(['b', 'c']);
|
|
||||||
list.insertAt(0, 'a');
|
|
||||||
expect(list.toArray()).toEqual(['a', 'b', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('删除指定位置', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
|
||||||
list.removeAt(1);
|
|
||||||
expect(list.toArray()).toEqual(['a', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('设置元素', () => {
|
|
||||||
const list = StringList.fromArray(['old']);
|
|
||||||
list.set(0, 'new');
|
|
||||||
expect(list.get(0)).toBe('new');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不区分大小写查找', () => {
|
|
||||||
const list = StringList.fromArray(['C:\\Windows', 'C:\\Users']);
|
|
||||||
expect(list.contains('c:\\windows')).toBe(true);
|
|
||||||
expect(list.contains('C:\\WINDOWS')).toBe(true);
|
|
||||||
expect(list.contains('C:\\Other')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不区分大小写索引', () => {
|
|
||||||
const list = StringList.fromArray(['C:\\Windows', 'C:\\Users']);
|
|
||||||
expect(list.indexOfIgnoreCase('c:\\windows')).toBe(0);
|
|
||||||
expect(list.indexOfIgnoreCase('c:\\users')).toBe(1);
|
|
||||||
expect(list.indexOfIgnoreCase('nope')).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('交换元素', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b']);
|
|
||||||
list.swap(0, 1);
|
|
||||||
expect(list.toArray()).toEqual(['b', 'a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('清空', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b', 'c']);
|
|
||||||
list.clear();
|
|
||||||
expect(list.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('深拷贝', () => {
|
|
||||||
const original = StringList.fromArray(['a', 'b']);
|
|
||||||
const cloned = original.clone();
|
|
||||||
cloned.set(0, 'modified');
|
|
||||||
expect(original.get(0)).toBe('a');
|
|
||||||
expect(cloned.get(0)).toBe('modified');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fromArray 和 toArray', () => {
|
|
||||||
const arr = ['x', 'y', 'z'];
|
|
||||||
const list = StringList.fromArray(arr);
|
|
||||||
expect(list.toArray()).toEqual(arr);
|
|
||||||
expect(list.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all 返回只读数组', () => {
|
|
||||||
const list = StringList.fromArray(['a', 'b']);
|
|
||||||
expect(list.all).toEqual(['a', 'b']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+66
-151
@@ -1,223 +1,138 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import {
|
import { UndoRedoManager, OperationType, TargetType, type OpRecord } from '../../src/core/undo-redo';
|
||||||
UndoRedoManager,
|
|
||||||
OperationType,
|
|
||||||
TargetType,
|
|
||||||
type OpRecord,
|
|
||||||
} from '../../src/core/undo-redo';
|
|
||||||
import { StringList } from '../../src/core/string-list';
|
|
||||||
|
|
||||||
function makeRecord(
|
function makeRecord(type: OperationType, target: TargetType, index: number, count: number, oldPaths: string[], newPaths: string[]): OpRecord {
|
||||||
type: OperationType,
|
|
||||||
target: TargetType,
|
|
||||||
index: number,
|
|
||||||
count: number,
|
|
||||||
oldPaths: string[],
|
|
||||||
newPaths: string[],
|
|
||||||
): OpRecord {
|
|
||||||
return { type, target, index, count, oldPaths, newPaths };
|
return { type, target, index, count, oldPaths, newPaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('UndoRedoManager', () => {
|
describe('UndoRedoManager', () => {
|
||||||
let mgr: UndoRedoManager;
|
let mgr: UndoRedoManager;
|
||||||
let sysPaths: StringList;
|
let sys: string[];
|
||||||
let userPaths: StringList;
|
let user: string[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mgr = new UndoRedoManager(50);
|
mgr = new UndoRedoManager(50);
|
||||||
sysPaths = StringList.fromArray(['C:\\Windows', 'C:\\Program Files']);
|
sys = ['C:\\Windows', 'C:\\Program Files'];
|
||||||
userPaths = StringList.fromArray(['C:\\Users\\me\\AppData']);
|
user = ['C:\\Users\\me\\AppData'];
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 基本状态 ──
|
|
||||||
|
|
||||||
it('初始不可撤销不可重做', () => {
|
it('初始不可撤销不可重做', () => {
|
||||||
expect(mgr.canUndo()).toBe(false);
|
expect(mgr.canUndo()).toBe(false);
|
||||||
expect(mgr.canRedo()).toBe(false);
|
expect(mgr.canRedo()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ADD ──
|
|
||||||
|
|
||||||
it('ADD 撤销/重做', () => {
|
it('ADD 撤销/重做', () => {
|
||||||
sysPaths.add('C:\\NewPath');
|
sys.push('C:\\NewPath');
|
||||||
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']));
|
||||||
|
|
||||||
mgr.push(
|
const u = mgr.undo(sys, user)!;
|
||||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], ['C:\\NewPath']),
|
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
);
|
|
||||||
|
|
||||||
expect(mgr.canUndo()).toBe(true);
|
const r = mgr.redo(...u)!;
|
||||||
|
expect(r[0]).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE ──
|
|
||||||
|
|
||||||
it('DELETE 撤销/重做', () => {
|
it('DELETE 撤销/重做', () => {
|
||||||
const removed = sysPaths.get(0)!;
|
const removed = sys[0];
|
||||||
mgr.push(
|
mgr.push(makeRecord(OperationType.DELETE, TargetType.SYSTEM, 0, 1, [removed], []));
|
||||||
makeRecord(OperationType.DELETE, TargetType.SYSTEM, 0, 1, [removed], []),
|
sys.splice(0, 1);
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.removeAt(0);
|
const u = mgr.undo(sys, user)!;
|
||||||
|
expect(u[0][0]).toBe(removed);
|
||||||
|
|
||||||
mgr.undo(sysPaths, userPaths);
|
const r = mgr.redo(...u)!;
|
||||||
expect(sysPaths.get(0)).toBe(removed);
|
expect(r[0]).toEqual(['C:\\Program Files']);
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── EDIT ──
|
|
||||||
|
|
||||||
it('EDIT 撤销/重做', () => {
|
it('EDIT 撤销/重做', () => {
|
||||||
const oldVal = sysPaths.get(0)!;
|
mgr.push(makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, ['C:\\Windows'], ['C:\\Edited']));
|
||||||
mgr.push(
|
sys[0] = 'C:\\Edited';
|
||||||
makeRecord(OperationType.EDIT, TargetType.SYSTEM, 0, 1, [oldVal], ['C:\\Edited']),
|
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.set(0, 'C:\\Edited');
|
const u = mgr.undo(sys, user)!;
|
||||||
|
expect(u[0][0]).toBe('C:\\Windows');
|
||||||
|
|
||||||
mgr.undo(sysPaths, userPaths);
|
const r = mgr.redo(...u)!;
|
||||||
expect(sysPaths.get(0)).toBe(oldVal);
|
expect(r[0][0]).toBe('C:\\Edited');
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.get(0)).toBe('C:\\Edited');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── MOVE_UP ──
|
|
||||||
|
|
||||||
it('MOVE_UP 撤销/重做', () => {
|
it('MOVE_UP 撤销/重做', () => {
|
||||||
mgr.push(
|
mgr.push(makeRecord(OperationType.MOVE_UP, TargetType.SYSTEM, 1, 1, [], []));
|
||||||
makeRecord(OperationType.MOVE_UP, TargetType.SYSTEM, 1, 1, [], []),
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.swap(0, 1);
|
const u = mgr.undo(sys, user)!;
|
||||||
|
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
const r = mgr.redo(...u)!;
|
||||||
|
expect(r[0]).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── MOVE_DOWN ──
|
|
||||||
|
|
||||||
it('MOVE_DOWN 撤销/重做', () => {
|
it('MOVE_DOWN 撤销/重做', () => {
|
||||||
mgr.push(
|
mgr.push(makeRecord(OperationType.MOVE_DOWN, TargetType.SYSTEM, 0, 1, [], []));
|
||||||
makeRecord(OperationType.MOVE_DOWN, TargetType.SYSTEM, 0, 1, [], []),
|
[sys[0], sys[1]] = [sys[1], sys[0]];
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.swap(0, 1);
|
const u = mgr.undo(sys, user)!;
|
||||||
|
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Program Files', 'C:\\Windows']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CLEAN ──
|
|
||||||
|
|
||||||
it('CLEAN 撤销/重做', () => {
|
it('CLEAN 撤销/重做', () => {
|
||||||
const oldPaths = sysPaths.toArray();
|
const old = [...sys];
|
||||||
const newPaths = ['C:\\Windows']; // 假设 Program Files 被清理掉了
|
const cleaned = ['C:\\Windows'];
|
||||||
|
mgr.push(makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, old, cleaned));
|
||||||
|
sys = cleaned;
|
||||||
|
|
||||||
mgr.push(
|
const u = mgr.undo(sys, user)!;
|
||||||
makeRecord(OperationType.CLEAN, TargetType.SYSTEM, 0, 2, oldPaths, newPaths),
|
expect(u[0]).toEqual(old);
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.clear();
|
const r = mgr.redo(...u)!;
|
||||||
for (const p of newPaths) sysPaths.add(p);
|
expect(r[0]).toEqual(cleaned);
|
||||||
|
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(newPaths);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── CLEAR ──
|
|
||||||
|
|
||||||
it('CLEAR 撤销/重做', () => {
|
it('CLEAR 撤销/重做', () => {
|
||||||
const oldPaths = sysPaths.toArray();
|
const old = [...sys];
|
||||||
|
mgr.push(makeRecord(OperationType.CLEAR, TargetType.SYSTEM, 0, 2, old, []));
|
||||||
|
sys = [];
|
||||||
|
|
||||||
mgr.push(
|
const u = mgr.undo(sys, user)!;
|
||||||
makeRecord(OperationType.CLEAR, TargetType.SYSTEM, 0, 2, oldPaths, []),
|
expect(u[0]).toEqual(old);
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.clear();
|
const r = mgr.redo(...u)!;
|
||||||
|
expect(r[0]).toEqual([]);
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.length).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── IMPORT ──
|
|
||||||
|
|
||||||
it('IMPORT 撤销/重做', () => {
|
it('IMPORT 撤销/重做', () => {
|
||||||
const oldPaths = sysPaths.toArray();
|
const old = [...sys];
|
||||||
const imported = ['C:\\New1', 'C:\\New2'];
|
const imported = ['C:\\New1', 'C:\\New2'];
|
||||||
|
mgr.push(makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, old, imported));
|
||||||
|
sys = imported;
|
||||||
|
|
||||||
mgr.push(
|
const u = mgr.undo(sys, user)!;
|
||||||
makeRecord(OperationType.IMPORT, TargetType.SYSTEM, 0, 2, oldPaths, imported),
|
expect(u[0]).toEqual(old);
|
||||||
);
|
|
||||||
|
|
||||||
sysPaths.clear();
|
const r = mgr.redo(...u)!;
|
||||||
for (const p of imported) sysPaths.add(p);
|
expect(r[0]).toEqual(imported);
|
||||||
|
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(oldPaths);
|
|
||||||
|
|
||||||
mgr.redo(sysPaths, userPaths);
|
|
||||||
expect(sysPaths.toArray()).toEqual(imported);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 重做分支截断 ──
|
|
||||||
|
|
||||||
it('新操作后截断重做分支', () => {
|
it('新操作后截断重做分支', () => {
|
||||||
mgr.push(
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['first']));
|
||||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['first']),
|
mgr.undo(sys, user);
|
||||||
);
|
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(mgr.canRedo()).toBe(true);
|
expect(mgr.canRedo()).toBe(true);
|
||||||
|
mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['second']));
|
||||||
// 推入新操作,重做分支被截断
|
|
||||||
mgr.push(
|
|
||||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], ['second']),
|
|
||||||
);
|
|
||||||
expect(mgr.canRedo()).toBe(false);
|
expect(mgr.canRedo()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 历史限制 ──
|
|
||||||
|
|
||||||
it('超出最大历史容量时移除最旧记录', () => {
|
it('超出最大历史容量时移除最旧记录', () => {
|
||||||
const small = new UndoRedoManager(3);
|
const small = new UndoRedoManager(3);
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
small.push(
|
small.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [`path_${i}`]));
|
||||||
makeRecord(OperationType.ADD, TargetType.SYSTEM, 0, 1, [], [`path_${i}`]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
expect(small.historyLength).toBe(3);
|
expect(small.historyLength).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── USER 目标 ──
|
|
||||||
|
|
||||||
it('操作 USER 路径', () => {
|
it('操作 USER 路径', () => {
|
||||||
userPaths.add('C:\\NewUserPath');
|
user.push('C:\\NewUserPath');
|
||||||
mgr.push(
|
mgr.push(makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']));
|
||||||
makeRecord(OperationType.ADD, TargetType.USER, 1, 1, [], ['C:\\NewUserPath']),
|
const u = mgr.undo(sys, user)!;
|
||||||
);
|
expect(u[1]).toEqual(['C:\\Users\\me\\AppData']);
|
||||||
|
expect(u[0]).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
||||||
mgr.undo(sysPaths, userPaths);
|
|
||||||
expect(userPaths.toArray()).toEqual(['C:\\Users\\me\\AppData']);
|
|
||||||
expect(sysPaths.toArray()).toEqual(['C:\\Windows', 'C:\\Program Files']);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Path aliases */
|
/* Path aliases */
|
||||||
@@ -22,6 +23,9 @@
|
|||||||
},
|
},
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
/* Strict */
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user