Compare commits

...

15 Commits

Author SHA1 Message Date
Serendipity 7aa5dcd832 docs: 更新 README 反映 v5.0 workspace 架构 + CLI 命令行
CI / 前端检查 (TypeScript + Lint + Test) (push) Has been cancelled
CI / Rust 检查 (Check + Clippy + Test) (push) Has been cancelled
- 项目结构改为 core / gui / cli 三 crate
- 新增 CLI 命令行使用说明
- 更新构建命令和技术栈
- 修正安装包名称

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:59:14 +08:00
Serendipity 9b5b57a3ac fix: CLI 写操作加原子性保护 — 保存前重新读取注册表对比
所有修改命令(add/remove/edit/move/clean/import)在保存前重新读取
注册表,与操作前加载的值对比,不一致则报错退出,防止覆盖其他进程的修改。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:56:50 +08:00
Serendipity 1320aa57a8 fix: 架构审查修复 — broadcast、目标校验、import_csv 警告、workspace 元数据统一
- HIGH: CLI 所有修改命令补 broadcast_env_change()
- HIGH: --system/--user 互斥校验,不再静默忽略
- MEDIUM: gui/Cargo.toml 删冗余 serde_json(log 保留,lib.rs 实际使用)
- MEDIUM: import_csv 对跳过行输出 log::warn
- MEDIUM: ProfilePathEntry 从 core 重导出
- LOW: Cargo workspace.package 统一元数据

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:52:31 +08:00
Serendipity a553a16a64 feat: CLI 补全至 GUI 功能 100% 对等 — 新增 9 条命令
新增: edit, move-up, move-down, clean, enable, disable, import, export, backup
core: registry.rs +clean_paths, fs.rs +import_paths +export_paths
CLI 特有增强: move-up/move-down 支持 --steps N 一次移动多格

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:43:05 +08:00
Serendipity c181fe15d4 fix: 删除残留的 src-tauri 目录(已重命名为 gui)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:30:22 +08:00
Serendipity 36e1c89b2e fix: 审查修复 — save_profile 保留原始 created、&str 参数、clippy 清理
- CRITICAL: save_profile 覆盖已有配置时保留原始创建时间
- HIGH: profiles.rs 函数参数 String → &str(减少不必要的克隆)
- MEDIUM: 修复 18 个 clippy警告(空行 + map_or + collapsible-if)
- CLI: 移除不必要的 name.clone() 调用

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:26:54 +08:00
Serendipity 812f39b159 refactor: src-tauri 重命名为 gui
Tauri CLI 自动检测 workspace 成员,构建不受影响。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:19:14 +08:00
Serendipity cd896d389b refactor: 提取 core 库 + 新增 CLI 版本
- 创建 Cargo workspace(core / src-tauri / cli 三 crate)
- core: 纯 Rust 库,零 Tauri 依赖,包含所有业务逻辑
- src-tauri/commands: 改为薄包装,调用 core 函数
- cli: 基于 clap 的命令行工具,支持 JSON 输出
- CLI 命令: list, add, remove, conflicts, scan, profile, check-admin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:13:28 +08:00
Serendipity 5a864c41b2 chore: 版本号统一升级至 5.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:46:14 +08:00
Serendipity 986fe7f0d9 docs: README 用 Mermaid 流程图替换截图占位符 — 架构图、组件树、操作流程
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:54:53 +08:00
Serendipity 9c74c61d64 chore: 版本号统一升级至 4.3.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:35:45 +08:00
Serendipity 26f6953919 fix: ProfileDialog 标题栏添加 ✕ 关闭按钮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:29:50 +08:00
Serendipity 5ed15535e7 fix: 深色模式下选中行对比度不足 — 新增 CSS 变量分别适配浅色/深色主题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:25:54 +08:00
Serendipity 230fb5d741 fix: 配置文件目录从 %APPDATA% 改为 %USERPROFILE%/.patheditor/profiles
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:20:38 +08:00
Serendipity d7d11480b8 feat: PATH 配置文件/预设切换 — 保存、加载、一键应用不同场景的 PATH 配置
- 新增 profiles.rs: list/save/load/delete/rename 五个 Rust 命令
- 配置文件存储在 %APPDATA%/.patheditor/profiles/<name>.json
- ProfileDialog: 保存当前 PATH、加载预览、一键应用到注册表
- 工具栏新增「配置」按钮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:02:29 +08:00
62 changed files with 6599 additions and 72 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
runs-on: windows-latest
defaults:
run:
working-directory: src-tauri
working-directory: gui
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: 上传安装包到 Release
run: |
$installer = Get-ChildItem -Path "src-tauri\target\release\bundle\nsis\*.exe" | Select-Object -First 1
$installer = Get-ChildItem -Path "target\release\bundle\nsis\*.exe" | Select-Object -First 1
if (gh release view $env:GITHUB_REF_NAME 2>$null) {
gh release upload $env:GITHUB_REF_NAME "$installer" --clobber
} else {
Generated
+5329
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[workspace]
resolver = "2"
members = [
"core",
"gui",
"cli",
]
[workspace.package]
version = "5.0.0"
edition = "2021"
license = "MIT"
authors = ["刘航宇"]
repository = "https://github.com/LHY0125/PathEditor"
+130 -26
View File
@@ -4,7 +4,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-4.2.0-blue" alt="version">
<img src="https://img.shields.io/badge/version-5.0.0-blue" alt="version">
<img src="https://img.shields.io/badge/tauri-2.x-ffa03a" alt="tauri">
<img src="https://img.shields.io/badge/react-19-61dafb" alt="react">
<img src="https://img.shields.io/badge/rust-1.95-000000" alt="rust">
@@ -20,11 +20,113 @@
PathEditor 是 Windows PATH 环境变量的可视化管理工具。支持系统变量和用户变量的增删改查、拖拽排序、一键清理无效路径、导入导出以及完整的撤销/重做。
v4.2 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
v5.0 使用 **Tauri 2.x + React 19 + TypeScript + Rust** 完全重写,替代了原有的 C + IUP GUI。
## 截图
## 架构
_[待补充]_
```mermaid
graph TB
subgraph 前端["React 前端"]
UI[UI 组件层<br/>AppShell / PathTable / Dialogs]
Store[状态管理<br/>Zustand Store]
Core[纯逻辑层<br/>undo-redo / path-manager / validation]
UI --> Store
UI --> Core
Store --> Core
end
subgraph IPC["Tauri IPC 桥接"]
invoke[invoke / plugin-dialog]
end
subgraph 后端["Rust 后端"]
Registry[注册表读写<br/>HKLM / HKCU]
System[系统操作<br/>权限检测 / 路径验证 / 环境变量展开]
Files[文件操作<br/>备份 / 配置 / 导入读取]
Scanner[分析引擎<br/>冲突检测 / 工具清单]
end
subgraph Windows["Windows 系统"]
Reg[(注册表<br/>SYSTEM / USER PATH)]
FS[(文件系统<br/>目录验证 / exe 扫描)]
end
UI --> invoke
invoke --> Registry
invoke --> System
invoke --> Files
invoke --> Scanner
Registry --> Reg
System --> FS
Scanner --> FS
Files --> FS
```
### 组件树
```mermaid
graph TD
App["App.tsx<br/>ErrorBoundary"]
Shell["AppShell<br/>布局编排 + 弹窗管理"]
TitleBar["TitleBar<br/>拖拽区域"]
ToolBar["ToolBar<br/>搜索 / 操作 / 分析 / 配置"]
PathTable["PathTable<br/>路径列表 + 验证 + 复选框"]
MergePreview["MergePreview<br/>系统+用户合并视图"]
StatusBar["StatusBar<br/>状态 / 权限 / 重试"]
Dialogs["弹窗层<br/>PathEdit / Import / Help / Analyze / Profile"]
App --> Shell
Shell --> TitleBar
Shell --> ToolBar
Shell --> PathTable
Shell --> MergePreview
Shell --> StatusBar
Shell --> Dialogs
```
### 操作流程
```mermaid
sequenceDiagram
actor U as 用户
participant UI as React UI
participant Z as Zustand Store
participant IPC as Tauri IPC
participant R as Rust 后端
participant Win as Windows
U->>UI: 点击「保存」
UI->>Z: savePaths()
Z->>IPC: invoke('backup_registry')
IPC->>R: backup_registry()
R->>Win: 读取注册表 → 写入备份文件
Z->>IPC: Promise.allSettled([save_system, save_user])
IPC->>R: save_system_paths() / save_user_paths()
R->>Win: RegSetValueEx()
Z->>IPC: invoke('broadcast_env_change')
IPC->>R: SendMessageTimeout(WM_SETTINGCHANGE)
R->>Win: 通知所有进程
Z->>UI: isModified → false, statusMessage → '保存成功'
```
## CLI 命令行
```bash
# 安装
cargo install --path cli
# 查看 PATH
patheditor list --system --json
# 冲突检测
patheditor conflicts
# 配置切换
patheditor profile save "Python开发"
patheditor profile apply "Python开发"
```
完整 17 条命令:`patheditor --help`
## 功能
@@ -64,7 +166,7 @@ _[待补充]_
## 安装
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_4.2.0_x64-setup.exe` 安装。
从 [Releases](https://github.com/LHY0125/PathEditor/releases) 下载最新版 `PathEditor_5.0.0_x64-setup.exe` 安装。
或从源码构建:
@@ -81,7 +183,7 @@ npx tauri build
## 开发
```bash
# 开发模式(热更新)
# 开发模式 GUI(热更新)
npx tauri dev
# 仅前端
@@ -90,11 +192,14 @@ npm run dev
# 前端测试
npm test
# Rust 后端检查
cd src-tauri && cargo check
# Rust workspace 检查
cargo check
# Rust 后端测试
cd src-tauri && cargo test
# CLI 构建
cargo build --release -p patheditor-cli
# 完整构建
npx tauri build
```
### 技术栈
@@ -106,34 +211,33 @@ cd src-tauri && cargo test
| 状态管理 | Zustand |
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 后端 | Rust (winreg + windows-rs FFI) |
| 核心库 | Rust workspace (core + gui + cli) |
| 前端测试 | Vitest (72 个测试) |
| Rust 测试 | cargo test (10 个测试) |
| 构建 | Vite |
| 构建 | Vite + Cargo |
| 打包 | NSIS |
### 项目结构
```
core/ # Rust 核心库(零 Tauri 依赖)
├── registry.rs # 注册表读写 + 路径清理
├── system.rs # 权限检测、路径验证、环境变量展开
├── scanner.rs # 冲突检测、工具清单
├── profiles.rs # 配置文件管理
├── backup.rs / disabled.rs # 备份、禁用状态
└── fs.rs # 文件读写、导入导出解析
gui/ # Tauri 桌面应用
└── src/commands/ # 薄包装 → 调用 core
cli/ # 命令行工具
└── src/main.rs # 17 条命令
src/ # React 前端
├── core/ # 纯逻辑 — 零框架依赖、零平台依赖
├── core/ # 纯逻辑 — 零框架依赖
├── store/ # Zustand 状态管理
├── components/
│ ├── layout/ # AppShell、TitleBar、StatusBar、ErrorBoundary
│ ├── path-list/ # PathTable、MergePreview
│ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons、SearchInput
│ ├── dialogs/ # PathEditDialog、HelpDialog、ImportDialog
│ └── ui/ # Modal、buttons(共享组件)
├── components/ # UI 组件
├── hooks/ # useAppActions、useKeyboard
├── i18n/ # zh-CN / en
└── config/ # default.json
src-tauri/ # Rust 后端
└── src/commands/
├── registry.rs # 注册表读写
├── system.rs # 权限检测、路径验证、环境变量展开
└── backup.rs # 注册表备份
tests/unit/ # 前端单元测试
```
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "patheditor-cli"
description = "PathEditor CLI — command-line interface for Windows PATH management"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
path-editor-core = { path = "../core" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
+436
View File
@@ -0,0 +1,436 @@
use clap::{Parser, Subcommand};
use path_editor_core as core;
use serde_json::json;
#[derive(Parser)]
#[command(name = "patheditor", version = "5.0.0")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// 列出 PATH 路径
List {
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
#[arg(long)] json: bool,
},
/// 添加一条路径
Add {
path: String,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 删除指定位置的路径
Remove {
index: usize,
#[arg(short, long)] system: bool,
},
/// 编辑指定位置的路径
Edit {
index: usize,
new_path: String,
#[arg(short, long)] system: bool,
},
/// 上移路径(--steps 指定移动格数,默认 1)
MoveUp {
index: usize,
#[arg(long, default_value = "1")] steps: usize,
#[arg(short, long)] system: bool,
},
/// 下移路径(--steps 指定移动格数,默认 1)
MoveDown {
index: usize,
#[arg(long, default_value = "1")] steps: usize,
#[arg(short, long)] system: bool,
},
/// 清理无效和重复路径
Clean {
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
#[arg(long)] dry_run: bool,
#[arg(long)] json: bool,
},
/// 启用指定位置的路径
Enable {
index: usize,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 禁用指定位置的路径
Disable {
index: usize,
#[arg(short, long)] system: bool,
#[arg(short, long)] user: bool,
},
/// 从文件导入 PATHJSON/CSV/TXT
Import {
file: String,
#[arg(long, default_value = "both")] target: String,
},
/// 导出 PATH 为文件
Export {
#[arg(long, default_value = "json")] format: String,
#[arg(short, long)] output: Option<String>,
},
/// 创建注册表备份
Backup,
/// 检测可执行文件冲突
Conflicts { #[arg(long)] json: bool },
/// 列出 PATH 目录中的可执行文件
Scan {
#[arg(long)] query: Option<String>,
#[arg(long)] json: bool,
},
/// 检查管理员权限
CheckAdmin { #[arg(long)] json: bool },
/// 管理配置文件
#[command(subcommand)]
Profile(ProfileCmd),
}
#[derive(Subcommand)]
enum ProfileCmd {
/// 列出所有配置
List { #[arg(long)] json: bool },
/// 保存当前 PATH 为配置
Save { name: String },
/// 加载配置(预览)
Load { name: String },
/// 应用配置(写入注册表)
Apply { name: String },
/// 删除配置
Delete { name: String },
}
fn exit_err(msg: &str) -> ! {
eprintln!("错误: {msg}");
std::process::exit(1);
}
fn ensure_single_target(system: bool, user: bool) -> &'static str {
if system && user { exit_err("不能同时指定 --system 和 --user"); }
if system { "system" } else { "user" }
}
type SaveFn = fn(Vec<String>) -> Result<(), String>;
fn verify_and_save(target: &str, original: &[String], new_list: Vec<String>) {
let reload = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if reload != original {
exit_err("注册表已被其他进程修改,请重新执行操作");
}
let save: SaveFn = if target == "system" { core::registry::save_system_paths } else { core::registry::save_user_paths };
save(new_list).unwrap_or_else(|e| exit_err(&e));
}
fn load_and_save(system: bool, f: impl FnOnce(Vec<String>) -> Vec<String>) {
let target = ensure_single_target(system, false);
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let new_list = f(list.clone());
verify_and_save(target, &list, new_list);
}
// ── 命令实现 ──
fn cmd_list(system: bool, user: bool, json_out: bool) {
let mut sys: Vec<String> = vec![];
let mut usr: Vec<String> = vec![];
if system || (!system && !user) {
sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
}
if user || (!system && !user) {
usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
}
if json_out {
let output = json!({ "system": { "paths": sys, "count": sys.len() }, "user": { "paths": usr, "count": usr.len() } });
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
if !sys.is_empty() {
println!("═══ 系统 PATH ({}) ═══", sys.len());
for (i, p) in sys.iter().enumerate() { println!(" [{}] {}", i, p); }
}
if !usr.is_empty() {
println!("═══ 用户 PATH ({}) ═══", usr.len());
for (i, p) in usr.iter().enumerate() { println!(" [{}] {}", i, p); }
}
}
}
fn cmd_add(path: String, system: bool, user: bool) {
let target = ensure_single_target(system, user);
load_and_save(system || false, |mut list| {
list.push(path.clone());
list
});
let label = if target == "system" { "系统" } else { "用户" };
println!("已添加到{} PATH: {path}", label);
core::system::broadcast_env_change();
}
fn cmd_remove(index: usize, system: bool) {
let target = ensure_single_target(system, false);
let mut list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let original = list.clone();
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let removed = list.remove(index);
verify_and_save(target, &original, list);
println!("已删除: {removed}");
core::system::broadcast_env_change();
}
fn cmd_edit(index: usize, new_path: String, system: bool) {
let target = ensure_single_target(system, false);
let mut list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let original = list.clone();
let old = std::mem::replace(&mut list[index], new_path.clone());
verify_and_save(target, &original, list);
println!("已编辑: {old}{new_path}");
core::system::broadcast_env_change();
}
fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
load_and_save(system || false, |mut list| {
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let end = if up {
if steps > index { 0 } else { index - steps }
} else {
let max = list.len() - 1;
if index + steps > max { max } else { index + steps }
};
let removed = list.remove(index);
list.insert(end, removed);
list
});
let dir = if up { "上移" } else { "下移" };
println!("{dir} {steps} 格完成");
core::system::broadcast_env_change();
}
fn cmd_clean(system: bool, user: bool, dry_run: bool, json_out: bool) {
let target = ensure_single_target(system, user);
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
let (kept, removed) = core::registry::clean_paths(list.clone());
if json_out {
println!("{}", json!({ "kept": kept, "removed": removed, "kept_count": kept.len(), "removed_count": removed.len() }).to_string());
} else if dry_run {
println!("═══ 将被移除({} 条)═══", removed.len());
for r in &removed { println!("{}", r); }
println!("═══ 将保留({} 条)═══", kept.len());
for k in &kept { println!("{}", k); }
} else {
let kept_count = kept.len();
verify_and_save(target, &list, kept);
println!("清理完成:移除 {} 条,保留 {}", removed.len(), kept_count);
core::system::broadcast_env_change();
if !removed.is_empty() {
for r in &removed { println!(" 已移除: {}", r); }
}
}
}
fn cmd_toggle(index: usize, system: bool, user: bool, enable: bool) {
let target = ensure_single_target(system, user);
let list = if target == "system" {
core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
} else {
core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
};
if index >= list.len() { exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len())); }
let path = &list[index];
let (mut sys_dis, mut usr_dis) = core::disabled::load_disabled_state().unwrap_or_else(|_| (vec![], vec![]));
let target_list: &mut Vec<String> = if target == "system" { &mut sys_dis } else { &mut usr_dis };
if enable {
target_list.retain(|p| p != path);
} else if !target_list.contains(path) {
target_list.push(path.clone());
}
core::disabled::save_disabled_state(sys_dis, usr_dis).unwrap_or_else(|e| exit_err(&e));
let action = if enable { "启用" } else { "禁用" };
println!("{action}: {path}");
}
fn cmd_import(file: String, target: String) {
let content = core::fs::read_text_file(&file).unwrap_or_else(|e| exit_err(&e));
let (sys, usr) = core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e));
match target.as_str() {
"system" => {
let orig = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("system", &orig, sys);
println!("已导入到系统 PATH");
}
"user" => {
let orig = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("user", &orig, usr);
println!("已导入到用户 PATH");
}
_ => {
let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let orig_usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
verify_and_save("system", &orig_sys, sys);
verify_and_save("user", &orig_usr, usr);
println!("已导入到系统 + 用户 PATH");
}
}
core::system::broadcast_env_change();
}
fn cmd_export(format: String, output: Option<String>) {
let sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
let content = core::fs::export_paths(&sys, &usr, &format);
if let Some(path) = output {
std::fs::write(&path, &content).unwrap_or_else(|e| exit_err(&format!("无法写入文件: {e}")));
println!("已导出到: {path}");
} else {
println!("{content}");
}
}
fn cmd_backup() {
let path = core::backup::backup_registry(None).unwrap_or_else(|e| exit_err(&e));
println!("备份已保存: {path}");
}
fn cmd_conflicts(json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let conflicts = core::scanner::scan_conflicts(paths).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&conflicts).unwrap());
} else if conflicts.is_empty() {
println!("未发现可执行文件冲突。");
} else {
println!("═══ 可执行文件冲突({} 个)═══\n", conflicts.len());
for c in &conflicts {
println!(" {}", c.name);
for loc in &c.locations {
println!(" {} {}", if loc.priority == 0 { "✓ 优先" } else { "✗ 遮蔽" }, loc.dir);
}
println!();
}
}
}
fn cmd_scan(query: Option<String>, json_out: bool) {
let mut paths: Vec<String> = vec![];
if let Ok(sys) = core::registry::load_system_paths() { paths.extend(sys); }
if let Ok(usr) = core::registry::load_user_paths() { paths.extend(usr); }
let groups = core::scanner::scan_tools(paths, query.unwrap_or_default()).unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&groups).unwrap());
} else {
for g in &groups {
if !g.exists { println!(" {} (不存在)", g.dir); continue; }
println!("═══ {} ═══", g.dir);
for exe in &g.exes { println!(" {}", exe); }
}
}
}
fn cmd_check_admin(json_out: bool) {
let is_admin = core::system::check_admin();
if json_out {
println!("{}", json!({"admin": is_admin}));
} else {
println!("管理员权限: {}", if is_admin { "" } else { "" });
}
}
fn profile_list(json_out: bool) {
let list = core::profiles::list_profiles().unwrap_or_else(|e| exit_err(&e));
if json_out {
println!("{}", serde_json::to_string_pretty(&list).unwrap());
} else if list.is_empty() {
println!("暂无配置文件。");
} else {
for p in &list { println!(" {} ({})", p.name, p.modified); }
}
}
fn profile_save(name: String) {
let sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
let sys_entries = sys.into_iter().map(|p| core::ProfilePathEntry { path: p, enabled: true }).collect();
let usr_entries = usr.into_iter().map(|p| core::ProfilePathEntry { path: p, enabled: true }).collect();
core::profiles::save_profile(&name, sys_entries, usr_entries).unwrap_or_else(|e| exit_err(&e));
println!("已保存配置: {name}");
}
fn profile_load(name: String) {
let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e));
println!("═══ 系统 PATH ({} 条) ═══", data.sys.len());
for e in &data.sys { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
println!("═══ 用户 PATH ({} 条) ═══", data.user.len());
for e in &data.user { println!(" [{}] {}", if e.enabled { "" } else { "" }, e.path); }
}
fn profile_apply(name: String) {
let data = core::profiles::load_profile(&name).unwrap_or_else(|e| exit_err(&e));
let sys: Vec<String> = data.sys.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
let usr: Vec<String> = data.user.into_iter().filter(|e| e.enabled).map(|e| e.path).collect();
core::registry::save_system_paths(sys).unwrap_or_else(|e| exit_err(&e));
core::registry::save_user_paths(usr).unwrap_or_else(|e| exit_err(&e));
core::system::broadcast_env_change();
println!("配置文件 \"{name}\" 已写入注册表。");
}
fn profile_delete(name: String) {
core::profiles::delete_profile(&name).unwrap_or_else(|e| exit_err(&e));
println!("已删除配置: {name}");
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::List { system, user, json } => cmd_list(system, user, json),
Command::Add { path, system, user } => cmd_add(path, system, user),
Command::Remove { index, system } => cmd_remove(index, system),
Command::Edit { index, new_path, system } => cmd_edit(index, new_path, system),
Command::MoveUp { index, steps, system } => cmd_move(index, steps, system, true),
Command::MoveDown { index, steps, system } => cmd_move(index, steps, system, false),
Command::Clean { system, user, dry_run, json } => cmd_clean(system, user, dry_run, json),
Command::Enable { index, system, user } => cmd_toggle(index, system, user, true),
Command::Disable { index, system, user } => cmd_toggle(index, system, user, false),
Command::Import { file, target } => cmd_import(file, target),
Command::Export { format, output } => cmd_export(format, output),
Command::Backup => cmd_backup(),
Command::Conflicts { json } => cmd_conflicts(json),
Command::Scan { query, json } => cmd_scan(query, json),
Command::CheckAdmin { json } => cmd_check_admin(json),
Command::Profile(cmd) => match cmd {
ProfileCmd::List { json } => profile_list(json),
ProfileCmd::Save { name } => profile_save(name),
ProfileCmd::Load { name } => profile_load(name),
ProfileCmd::Apply { name } => profile_apply(name),
ProfileCmd::Delete { name } => profile_delete(name),
},
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "path-editor-core"
description = "PathEditor core library — shared between GUI and CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
winreg = "0.52"
dirs = "5"
chrono = "0.4"
@@ -1,7 +1,7 @@
use chrono::Local;
use std::path::PathBuf;
use winreg::enums::*;
use crate::commands::registry::{self, SYS_REG_PATH, USER_REG_PATH};
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
fn backup_base_dir() -> PathBuf {
dirs::data_dir()
@@ -12,14 +12,13 @@ fn backup_base_dir() -> PathBuf {
}
/// 获取 APPDATA 路径下的备份目录
#[tauri::command]
pub fn get_appdata_dir() -> String {
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
/// 在保存前调用,备份的是注册表中的当前值(保存前的状态)
#[tauri::command]
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
let backup_dir = match custom_dir {
Some(ref dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
@@ -19,7 +19,6 @@ struct DisabledState {
}
/// 保存禁用路径列表(即时持久化,不依赖注册表保存按钮)
#[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> {
let state = DisabledState { system, user };
let path = disabled_file_path();
@@ -40,7 +39,6 @@ pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(),
}
/// 加载禁用路径列表,返回 (system_disabled, user_disabled)
#[tauri::command]
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
let path = disabled_file_path();
+110
View File
@@ -0,0 +1,110 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
/// 导入路径文件(JSON / CSV / TXT),返回 (系统路径, 用户路径)
pub fn import_paths(path: &str, content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let ext = std::path::Path::new(path)
.extension()
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
let ext = ext.to_string_lossy();
match ext.as_ref() {
"json" => import_json(content),
"csv" => import_csv(content),
"txt" => import_txt(content),
_ => Err(format!("不支持的格式: .{}", ext)),
}
}
fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
#[derive(serde::Deserialize)]
struct ImportData {
#[serde(default)]
system: Vec<String>,
#[serde(default)]
user: Vec<String>,
}
let data: ImportData =
serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((data.system, data.user))
}
fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let mut sys = Vec::new();
let mut usr = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
let fields: Vec<&str> = trimmed.split(',').collect();
if fields.len() >= 2 {
match fields[0].trim().to_lowercase().as_str() {
"system" | "sys" => sys.push(fields[1].trim().to_string()),
"user" | "usr" => usr.push(fields[1].trim().to_string()),
_ => { log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); }
}
} else {
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
}
}
if sys.is_empty() && usr.is_empty() {
return Err("CSV 文件中未找到有效路径".into());
}
Ok((sys, usr))
}
fn import_txt(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let paths: Vec<String> = content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
if paths.is_empty() {
return Err("TXT 文件中未找到路径".into());
}
// TXT 格式全部导入为用户路径
Ok((vec![], paths))
}
/// 导出 PATH 为指定格式字符串
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> String {
match format {
"json" => {
let data = serde_json::json!({
"version": "5.0.0",
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
"system": sys,
"user": usr,
});
serde_json::to_string_pretty(&data).unwrap_or_default()
}
"csv" => {
let mut out = String::from("type,path\n");
for p in sys {
out.push_str(&format!("system,{}\n", p));
}
for p in usr {
out.push_str(&format!("user,{}\n", p));
}
out
}
_ => {
let mut out = String::new();
if !sys.is_empty() {
out.push_str(&format!("# 系统 PATH ({})\n", sys.len()));
for p in sys {
out.push_str(&format!("{}\n", p));
}
}
if !usr.is_empty() {
out.push_str(&format!("# 用户 PATH ({})\n", usr.len()));
for p in usr {
out.push_str(&format!("{}\n", p));
}
}
out
}
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod backup;
pub mod disabled;
pub mod fs;
pub mod profiles;
pub mod registry;
pub mod scanner;
pub mod system;
pub use profiles::{ProfileData, ProfileMeta, ProfilePathEntry};
pub use scanner::{ConflictEntry, ConflictLocation, ToolGroup};
+152
View File
@@ -0,0 +1,152 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".patheditor")
.join("profiles")
}
fn profile_path(name: &str) -> PathBuf {
profiles_dir().join(format!("{}.json", name))
}
/// 内部用的 PathEntry(与前端 PathEntry 字段一致)
#[derive(Serialize, Deserialize, Clone)]
pub struct ProfilePathEntry {
pub path: String,
pub enabled: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileMeta {
pub name: String,
pub created: String,
pub modified: String,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileData {
pub name: String,
pub sys: Vec<ProfilePathEntry>,
pub user: Vec<ProfilePathEntry>,
pub created: String,
pub modified: String,
}
/// 列出所有配置文件的元数据
pub fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
let dir = profiles_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut profiles: Vec<ProfileMeta> = Vec::new();
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取配置目录: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "json") {
continue;
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 {}: {}", path.display(), e))?;
if let Ok(data) = serde_json::from_str::<ProfileData>(&content) {
profiles.push(ProfileMeta {
name: data.name,
created: data.created,
modified: data.modified,
});
}
}
profiles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(profiles)
}
/// 保存当前 PATH 为配置文件
pub fn save_profile(
name: &str,
sys: Vec<ProfilePathEntry>,
user: Vec<ProfilePathEntry>,
) -> Result<(), String> {
let dir = profiles_dir();
fs::create_dir_all(&dir).map_err(|e| format!("无法创建配置目录: {}", e))?;
let path = profile_path(&name);
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
// 覆盖已有配置时保留原始创建时间
let created = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str::<ProfileData>(&c).ok())
.map(|d| d.created)
.unwrap_or_else(|| now.clone())
} else {
now.clone()
};
let data = ProfileData {
name: name.to_string(),
sys,
user,
created,
modified: now,
};
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
}
/// 加载配置文件
pub fn load_profile(name: &str) -> Result<ProfileData, String> {
let path = profile_path(&name);
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
pub fn delete_profile(name: &str) -> Result<(), String> {
let path = profile_path(&name);
fs::remove_file(&path).map_err(|e| format!("无法删除配置文件: {}", e))?;
log::info!("已删除配置: {}", path.display());
Ok(())
}
/// 重命名配置文件
pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
let old_path = profile_path(&old_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.to_string();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let new_path = profile_path(&new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
}
log::info!("已重命名配置: {} -> {}", old_name, new_name);
Ok(())
}
@@ -3,7 +3,7 @@ use winreg::RegKey;
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const USER_REG_PATH: &str = "Environment";
pub(crate) const PATH_VALUE: &str = "Path";
const PATH_VALUE: &str = "Path";
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
let key = RegKey::predef(root);
@@ -18,7 +18,7 @@ pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Res
Ok(split_path(&value))
}
pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
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 字符
@@ -43,36 +43,36 @@ pub(crate) fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths:
Ok(())
}
#[tauri::command]
pub fn load_system_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
}
#[tauri::command]
pub fn load_user_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
}
#[tauri::command]
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
}
#[tauri::command]
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户", &paths)
}
/// 将分号分隔的 PATH 字符串拆分为数组。
/// 注意:TS 端 src/core/validation.ts 有相同逻辑的 split_path,修改时需同步两端。
pub(crate) fn split_path(raw: &str) -> Vec<String> {
fn split_path(raw: &str) -> Vec<String> {
raw.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn join_path(paths: &[String]) -> String {
fn join_path(paths: &[String]) -> String {
paths
.iter()
.map(|p| p.trim())
@@ -81,6 +81,29 @@ pub(crate) fn join_path(paths: &[String]) -> String {
.join(";")
}
/// 清理路径列表:移除不存在的目录 + 重复路径(保留首次出现)
/// 返回 (保留的路径, 被移除的路径)
pub fn clean_paths(paths: Vec<String>) -> (Vec<String>, Vec<String>) {
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
let mut kept = Vec::new();
let mut removed = Vec::new();
for p in paths {
let key = p.trim().to_lowercase();
if seen.contains(&key) {
removed.push(p);
continue;
}
seen.insert(key);
if !p.contains('%') && !std::path::Path::new(&p).is_dir() {
removed.push(p);
continue;
}
kept.push(p);
}
(kept, removed)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -27,7 +27,7 @@ pub struct ToolGroup {
///
/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
#[tauri::command]
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
@@ -70,7 +70,6 @@ pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String>
/// 扫描 PATH 中各目录提供的可执行文件
///
/// query 非空时只返回文件名包含关键词的结果
#[tauri::command]
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, String> {
let query_lower = query.to_lowercase();
let mut groups: Vec<ToolGroup> = Vec::new();
@@ -2,7 +2,6 @@ use winreg::enums::*;
use winreg::RegKey;
/// 检测当前进程是否有管理员权限(尝试写入系统注册表键)
#[tauri::command]
pub fn check_admin() -> bool {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey_with_flags(
@@ -14,7 +13,7 @@ pub fn check_admin() -> bool {
/// 验证路径是否存在于文件系统中(且是目录)
/// 包含 % 的路径(环境变量路径)无法验证,返回 true
#[tauri::command]
pub fn validate_path(path: &str) -> bool {
if path.contains('%') {
return true;
@@ -23,7 +22,6 @@ pub fn validate_path(path: &str) -> bool {
}
/// 展开路径中的环境变量(如 %JAVA_HOME%\bin → C:\Program Files\Java\jdk-17\bin
#[tauri::command]
pub fn expand_env_vars(path: &str) -> String {
if !path.contains('%') {
return path.to_string();
@@ -64,7 +62,6 @@ pub fn expand_env_vars(path: &str) -> String {
}
/// 广播环境变量更改通知(WM_SETTINGCHANGE
#[tauri::command]
pub fn broadcast_env_change() {
const HWND_BROADCAST: isize = 0xFFFF;
const WM_SETTINGCHANGE: u32 = 0x001A;
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist', 'src-tauri'] },
{ ignores: ['dist', 'gui'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+1 -1
View File
@@ -2271,7 +2271,7 @@ dependencies = [
[[package]]
name = "patheditor"
version = "4.0.0"
version = "5.0.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+6 -11
View File
@@ -1,11 +1,11 @@
[package]
name = "patheditor"
version = "4.2.0"
description = "Windows PATH Environment Variable Editor"
authors = ["刘航宇"]
license = "MIT"
repository = "https://github.com/LHY0125/PathEditor"
edition = "2021"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
rust-version = "1.77.2"
[lib]
@@ -16,14 +16,9 @@ crate-type = ["staticlib", "rlib"]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0"
path-editor-core = { path = "../core" }
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
# Windows API
winreg = "0.52"
dirs = "5"
chrono = "0.4"
View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::backup;
#[tauri::command]
pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> { backup::backup_registry(custom_dir) }
#[tauri::command]
pub fn get_appdata_dir() -> String { backup::get_appdata_dir() }
+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::disabled;
#[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(), String> { disabled::save_disabled_state(system, user) }
#[tauri::command]
pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> { disabled::load_disabled_state() }
+4
View File
@@ -0,0 +1,4 @@
use path_editor_core::fs;
#[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> { fs::read_text_file(path) }
@@ -1,6 +1,7 @@
pub mod backup;
pub mod disabled;
pub mod fs;
pub mod profiles;
pub mod registry;
pub mod scanner;
pub mod system;
+12
View File
@@ -0,0 +1,12 @@
use path_editor_core::profiles;
#[tauri::command]
pub fn list_profiles() -> Result<Vec<profiles::ProfileMeta>, String> { profiles::list_profiles() }
#[tauri::command]
pub fn save_profile(name: String, sys: Vec<profiles::ProfilePathEntry>, user: Vec<profiles::ProfilePathEntry>) -> Result<(), String> { profiles::save_profile(&name, sys, user) }
#[tauri::command]
pub fn load_profile(name: String) -> Result<profiles::ProfileData, String> { profiles::load_profile(&name) }
#[tauri::command]
pub fn delete_profile(name: String) -> Result<(), String> { profiles::delete_profile(&name) }
#[tauri::command]
pub fn rename_profile(old_name: String, new_name: String) -> Result<(), String> { profiles::rename_profile(&old_name, &new_name) }
+10
View File
@@ -0,0 +1,10 @@
use path_editor_core::registry;
#[tauri::command]
pub fn load_system_paths() -> Result<Vec<String>, String> { registry::load_system_paths() }
#[tauri::command]
pub fn load_user_paths() -> Result<Vec<String>, String> { registry::load_user_paths() }
#[tauri::command]
pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> { registry::save_system_paths(paths) }
#[tauri::command]
pub fn save_user_paths(paths: Vec<String>) -> Result<(), String> { registry::save_user_paths(paths) }
+6
View File
@@ -0,0 +1,6 @@
use path_editor_core::scanner;
#[tauri::command]
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<scanner::ConflictEntry>, String> { scanner::scan_conflicts(paths) }
#[tauri::command]
pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<scanner::ToolGroup>, String> { scanner::scan_tools(paths, query) }
+10
View File
@@ -0,0 +1,10 @@
use path_editor_core::system;
#[tauri::command]
pub fn check_admin() -> bool { system::check_admin() }
#[tauri::command]
pub fn validate_path(path: &str) -> bool { system::validate_path(path) }
#[tauri::command]
pub fn expand_env_vars(path: &str) -> String { system::expand_env_vars(path) }
#[tauri::command]
pub fn broadcast_env_change() { system::broadcast_env_change() }
+5
View File
@@ -30,6 +30,11 @@ pub fn run() {
commands::disabled::load_disabled_state,
commands::scanner::scan_conflicts,
commands::scanner::scan_tools,
commands::profiles::list_profiles,
commands::profiles::save_profile,
commands::profiles::load_profile,
commands::profiles::delete_profile,
commands::profiles::rename_profile,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "PathEditor",
"version": "4.2.0",
"version": "5.0.0",
"identifier": "com.liuhangyu.patheditor",
"build": {
"frontendDist": "../dist",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "PathEditor v4.2",
"title": "PathEditor v5.0",
"width": 900,
"height": 700,
"minWidth": 800,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "patheditor",
"private": true,
"version": "4.2.0",
"version": "5.0.0",
"type": "module",
"scripts": {
"dev": "vite",
-5
View File
@@ -1,5 +0,0 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
#[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { useAppStore } from '@/store/app-store';
import type { PathEntry } from '@/core/path-entry';
interface ProfileMeta {
name: string;
created: string;
modified: string;
}
interface ProfileData {
name: string;
sys: PathEntry[];
user: PathEntry[];
created: string;
modified: string;
}
interface Props {
open: boolean;
onClose: () => void;
}
export function ProfileDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const [profiles, setProfiles] = useState<ProfileMeta[]>([]);
const [newName, setNewName] = useState('');
const [selected, setSelected] = useState<string | null>(null);
const [selectedData, setSelectedData] = useState<ProfileData | null>(null);
const [saving, setSaving] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState('');
const refreshProfiles = useCallback(async () => {
const list = await invoke<ProfileMeta[]>('list_profiles');
setProfiles(list);
}, []);
useEffect(() => {
if (open) refreshProfiles();
}, [open, refreshProfiles]);
const handleSave = async () => {
if (!newName.trim()) return;
setSaving(true);
const { sysPaths, userPaths } = useAppStore.getState();
await invoke('save_profile', { name: newName.trim(), sys: sysPaths, user: userPaths });
setNewName('');
setSaving(false);
refreshProfiles();
};
const handleLoad = async (name: string) => {
const data = await invoke<ProfileData>('load_profile', { name });
setSelected(name);
setSelectedData(data);
};
const handleApply = async () => {
if (!selected || !selectedData) return;
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
useAppStore.getState().replaceBothPaths(
selectedData.sys.map(e => e.path),
selectedData.user.map(e => e.path),
);
// 同步 disabled 状态
await invoke('save_disabled_state', {
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
});
await useAppStore.getState().savePaths();
onClose();
};
const handleDelete = async (name: string) => {
if (!window.confirm(`删除配置文件 "${name}"`)) return;
await invoke('delete_profile', { name });
if (selected === name) { setSelected(null); setSelectedData(null); }
refreshProfiles();
};
const handleRename = async () => {
if (!selected || !renameValue.trim()) return;
await invoke('rename_profile', { oldName: selected, newName: renameValue.trim() });
setRenameOpen(false);
setSelected(renameValue.trim());
refreshProfiles();
};
return (
<Modal open={open} onClose={onClose}>
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
<h2 className="text-base font-semibold">{t('profile.title')}</h2>
<div className="flex gap-2 items-center">
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder={t('profile.namePlaceholder')}
className="px-2 py-1 text-sm rounded border outline-none w-44"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
/>
<button
className="px-3 py-1 text-sm rounded text-white"
style={{ backgroundColor: '#3b82f6' }}
disabled={saving || !newName.trim()}
onClick={handleSave}
>
{t('profile.save')}
</button>
<button
onClick={onClose}
className="px-2 py-1 text-sm rounded hover:opacity-70 transition-opacity"
style={{ color: 'var(--app-fg)' }}
title="关闭"
>
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* 左侧:列表 */}
<div className="w-48 border-r overflow-auto p-2" style={{ borderColor: 'var(--app-border)' }}>
{profiles.length === 0 ? (
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>{t('profile.noProfiles')}</div>
) : (
profiles.map(p => (
<div
key={p.name}
onClick={() => handleLoad(p.name)}
className="px-2 py-1.5 text-sm rounded cursor-pointer mb-0.5"
style={{
backgroundColor: selected === p.name ? 'rgba(59,130,246,0.15)' : 'transparent',
color: selected === p.name ? '#3b82f6' : 'var(--app-fg)',
}}
>
{p.name}
</div>
))
)}
</div>
{/* 右侧:详情 */}
<div className="flex-1 p-3 overflow-auto">
{!selectedData ? (
<div className="text-center py-10 text-sm" style={{ opacity: 0.4 }}>
{profiles.length === 0 ? t('profile.noProfiles') : '选择一个配置文件'}
</div>
) : (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="font-semibold text-sm">{selectedData.name}</span>
<span className="text-xs" style={{ opacity: 0.5 }}>{selectedData.modified}</span>
</div>
<div className="flex gap-1.5 mb-3">
<button
className="px-3 py-1 text-xs rounded text-white"
style={{ backgroundColor: '#3b82f6' }}
onClick={handleApply}
>
{t('profile.apply')}
</button>
<button
className="px-3 py-1 text-xs rounded"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
onClick={() => { setRenameOpen(true); setRenameValue(selectedData.name); }}
>
{t('profile.rename')}
</button>
<button
className="px-3 py-1 text-xs rounded text-white"
style={{ backgroundColor: '#ef4444' }}
onClick={() => handleDelete(selectedData.name)}
>
{t('profile.delete')}
</button>
</div>
{renameOpen && (
<div className="flex gap-2 mb-2">
<input
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
className="px-2 py-1 text-xs rounded border outline-none"
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
/>
<button className="px-2 py-1 text-xs rounded text-white" style={{ backgroundColor: '#3b82f6' }} onClick={handleRename}>
</button>
</div>
)}
<PathSection title={`系统 PATH (${selectedData.sys.length})`} paths={selectedData.sys} />
<PathSection title={`用户 PATH (${selectedData.user.length})`} paths={selectedData.user} />
</div>
)}
</div>
</div>
</div>
</Modal>
);
}
function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
return (
<div className="mb-2">
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>{title}</div>
{paths.length === 0 ? (
<div className="text-xs" style={{ opacity: 0.4 }}></div>
) : (
<div className="space-y-0.5 max-h-48 overflow-auto">
{paths.map((e, i) => (
<div
key={i}
className="text-xs font-mono px-2 py-0.5 rounded flex items-center gap-1.5"
style={{
backgroundColor: 'var(--app-list-bg)',
color: e.enabled ? 'var(--app-fg)' : '#ef4444',
textDecoration: e.enabled ? 'none' : 'line-through',
opacity: e.enabled ? 1 : 0.5,
}}
>
<span style={{ color: e.enabled ? '#22c55e' : '#ef4444', fontSize: 10 }}>
{e.enabled ? '●' : '○'}
</span>
{e.path}
</div>
))}
</div>
)}
</div>
);
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { PathEditDialog } from '@/components/dialogs/PathEditDialog';
import { HelpDialog } from '@/components/dialogs/HelpDialog';
import { ImportDialog } from '@/components/dialogs/ImportDialog';
import { AnalyzeDialog } from '@/components/dialogs/AnalyzeDialog';
import { ProfileDialog } from '@/components/dialogs/ProfileDialog';
import { useAppActions, type DialogState } from '@/hooks/use-app-actions';
/** Tauri's File object includes the native filesystem path */
@@ -35,10 +36,11 @@ export function AppShell() {
open: false, system: [], user: [],
});
const [analyzeOpen, setAnalyzeOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const actions = useAppActions(activeTab, {
editDialog, newDialog, helpOpen, importDialog,
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen,
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
});
const tabConfig: { id: TabId; label: string }[] = [
@@ -86,6 +88,7 @@ export function AppShell() {
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
i18n.changeLanguage(current === 'zh-CN' ? 'en' : 'zh-CN');
}}
onProfiles={() => setProfilesOpen(true)}
onAnalyze={() => setAnalyzeOpen(true)}
onDarkMode={() => useThemeStore.getState().toggle()}
/>
@@ -116,6 +119,7 @@ export function AppShell() {
<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: [] })} />
<AnalyzeDialog open={analyzeOpen} onClose={() => setAnalyzeOpen(false)} />
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
</div>
);
}
+1 -1
View File
@@ -188,7 +188,7 @@ export function PathTable({ tabId }: PathTableProps) {
className="cursor-pointer select-none"
style={{
backgroundColor: isSelected
? 'rgba(59, 130, 246, 0.3)'
? 'var(--app-select-row)'
: rowIdx % 2 === 0
? 'var(--app-list-bg)'
: 'var(--app-list-alt)',
+4
View File
@@ -21,6 +21,7 @@ interface ToolBarProps {
onLanguage: () => void;
onDarkMode: () => void;
onAnalyze: () => void;
onProfiles: () => void;
}
export function ToolBar(props: ToolBarProps) {
@@ -70,6 +71,9 @@ export function ToolBar(props: ToolBarProps) {
<button className={btnClass} style={btnStyle} onClick={props.onAnalyze}>
{t('button.analyze')}
</button>
<button className={btnClass} style={btnStyle} onClick={props.onProfiles}>
{t('button.profiles')}
</button>
<button className={btnClass} style={btnStyle} onClick={props.onDarkMode}>
{t('button.darkMode')}
</button>
+1 -1
View File
@@ -27,7 +27,7 @@ export function join_path(paths: string[]): string {
}
/** 分割 PATH 字符串。
* 注意:Rust 端 src-tauri/src/commands/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
* 注意:Rust 端 core/src/registry.rs 有相同逻辑的 split_path,修改时需同步两端。 */
export function split_path(raw: string): string[] {
return raw
.split(';')
+1
View File
@@ -20,6 +20,7 @@ export interface DialogState {
setHelpOpen: (v: boolean) => void;
setImportDialog: (v: DialogState['importDialog']) => void;
setAnalyzeOpen: (v: boolean) => void;
setProfilesOpen: (v: boolean) => void;
}
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
+14
View File
@@ -22,6 +22,7 @@
"cancel": "Cancel",
"help": "Help",
"analyze": "Analyze",
"profiles": "Profiles",
"undo": "Undo",
"redo": "Redo",
"darkMode": "Dark Mode",
@@ -83,6 +84,19 @@
"searchPlaceholder": "Search executable name...",
"conflictCount": "{{count}} file conflict(s) found"
},
"profile": {
"title": "PATH Profiles",
"saveCurrent": "Save Current as Profile",
"namePlaceholder": "Profile name...",
"save": "Save",
"load": "Load",
"apply": "Apply",
"delete": "Delete",
"rename": "Rename",
"noProfiles": "No saved profiles",
"applyConfirm": "This will overwrite current PATH with profile \"{{name}}\" and write to registry. Confirm?",
"deleted": "Profile \"{{name}}\" deleted"
},
"help": {
"content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
}
+14
View File
@@ -22,6 +22,7 @@
"cancel": "取消",
"help": "帮助",
"analyze": "分析",
"profiles": "配置",
"undo": "撤销",
"redo": "重做",
"darkMode": "深色模式",
@@ -83,6 +84,19 @@
"searchPlaceholder": "搜索可执行文件名...",
"conflictCount": "发现 {{count}} 个文件冲突"
},
"profile": {
"title": "PATH 配置文件",
"saveCurrent": "保存当前 PATH 为配置",
"namePlaceholder": "配置名称...",
"save": "保存",
"load": "加载",
"apply": "应用",
"delete": "删除",
"rename": "重命名",
"noProfiles": "暂无配置文件",
"applyConfirm": "将用配置 \"{{name}}\" 覆盖当前 PATH 并写入注册表,确定吗?",
"deleted": "已删除配置 \"{{name}}\""
},
"help": {
"content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor"
}
+2
View File
@@ -41,6 +41,7 @@ body {
--app-fg: var(--color-light-fg);
--app-border: var(--color-light-border);
--app-hover: var(--color-light-hover);
--app-select-row: rgba(59, 130, 246, 0.18);
}
/* 深色模式 */
@@ -51,6 +52,7 @@ body {
--app-fg: var(--color-dark-fg);
--app-border: var(--color-dark-border);
--app-hover: var(--color-dark-hover);
--app-select-row: rgba(96, 165, 250, 0.35);
}
/* 滚动条样式 */
+1 -1
View File
@@ -8,6 +8,6 @@ export default defineConfig({
},
},
test: {
exclude: ['e2e/**', 'node_modules/**', 'src-tauri/**'],
exclude: ['e2e/**', 'node_modules/**', 'gui/**'],
},
});