fix: 全面审查修复 14 个 bug,新增 Rust 单元测试

CRITICAL:
- PathTable/MergePreview 操作后不重新渲染(加 dataVersion 版本号机制)
- moveUp/moveDown 后 selectedIndices 过时(更新到新位置)

HIGH:
- ImportDialog 显示 "true" 而非路径数量(改为 number 类型)
- F1 快捷键无效果(添加 onHelp 回调)
- useKeyboard 每次渲染重复注册事件(改用 ref 模式)
- batch delete 撤销顺序错误(拆分为独立记录)
- importPaths 存储数组引用而非副本
- StringList.all 暴露内部数组(改为返回副本)
- expand_env_vars 静默吞 API 错误(加 log::warn)
- join_path 写入前未修剪路径(加 trim 避免注册表污染)

MEDIUM:
- handleClean 总传 () => true 不验证无效路径
- HelpDialog/ImportDialog 缺 Escape 关闭
- initDarkMode 不同步 Zustand store
- 多处硬编码中文改为 i18n.t()
- Rust unsafe 块补全 SAFETY 注释

新增 Rust 测试: system.rs 4 个单元测试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 23:14:26 +08:00
parent b1acb3690c
commit 2ceec54790
11 changed files with 173 additions and 93 deletions
+7 -2
View File
@@ -77,7 +77,12 @@ fn split_path(raw: &str) -> Vec<String> {
.collect()
}
/// 用分号连接路径列表
/// 用分号连接路径列表(去除首尾空格避免污染注册表)
fn join_path(paths: &[String]) -> String {
paths.join(";")
paths
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join(";")
}
+41 -3
View File
@@ -29,28 +29,32 @@ pub fn expand_env_vars(path: &str) -> String {
return path.to_string();
}
// 转为 UTF-16 宽字符串
// 转为 UTF-16 宽字符串(以 null 结尾)
let wide_path: Vec<u16> = path
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// 先查询需要的缓冲区大小 (lpDst=NULL)
// SAFETY: wide_path 是以 null 结尾的 UTF-16 字符串,lpDst 为 null 且 nSize 为 0
// 根据 MSDN 文档此时 API 只查询所需缓冲区大小而不写入数据
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
if required == 0 {
log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}");
return path.to_string();
}
// 实际展开
// SAFETY: buffer 容量为 requiredAPI 返回的精确大小),wide_path 以 null 结尾,
// 且两个指针指向不同的内存区域,不存在重叠
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
if result == 0 {
log::warn!("expand_env_vars: 展开失败, 返回原始路径: {path}");
return path.to_string();
}
@@ -66,8 +70,11 @@ pub fn broadcast_env_change() {
const WM_SETTINGCHANGE: u32 = 0x001A;
const SMTO_ABORTIFHUNG: u32 = 0x0002;
// SAFETY: env_str 是以 null 结尾的 UTF-16 字符串,所有指针和常量均遵循 Win32 API 约定
let env_str: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: env_str.as_ptr() 指向以 null 结尾的字符串,HWND_BROADCAST 是合法句柄,
// lpdwResult 为 null 表示不需要返回值,其他参数均为常量
let result = unsafe {
SendMessageTimeoutW(
HWND_BROADCAST,
@@ -108,3 +115,34 @@ extern "system" {
lpdwResult: *mut usize,
) -> 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));
}
}
+10
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface HelpDialogProps {
@@ -8,6 +9,15 @@ interface HelpDialogProps {
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const { t } = useTranslation();
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 (
+20 -10
View File
@@ -1,22 +1,32 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface ImportDialogProps {
open: boolean;
hasSystem: boolean;
hasUser: boolean;
systemCount: number;
userCount: number;
onSelect: (target: 'system' | 'user' | 'both') => void;
onCancel: () => void;
}
export function ImportDialog({
open,
hasSystem,
hasUser,
systemCount,
userCount,
onSelect,
onCancel,
}: ImportDialogProps) {
const { t } = useTranslation();
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onCancel]);
if (!open) return null;
return (
@@ -32,12 +42,12 @@ export function ImportDialog({
>
<h2 className="text-lg font-semibold mb-4">{t('dialog.importTarget')}</h2>
<p className="text-sm mb-4 opacity-70">
{hasSystem && `系统变量: ${hasSystem}`}
{hasSystem && hasUser && ' | '}
{hasUser && `用户变量: ${hasUser}`}
{systemCount > 0 && `系统变量: ${systemCount}`}
{systemCount > 0 && userCount > 0 && ' | '}
{userCount > 0 && `用户变量: ${userCount}`}
</p>
<div className="flex flex-col gap-2">
{hasSystem && (
{systemCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
@@ -46,7 +56,7 @@ export function ImportDialog({
{t('dialog.importSystem')}
</button>
)}
{hasUser && (
{userCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
@@ -55,7 +65,7 @@ export function ImportDialog({
{t('dialog.importUser')}
</button>
)}
{hasSystem && hasUser && (
{systemCount > 0 && userCount > 0 && (
<button
className="px-4 py-2 text-sm rounded border text-left"
style={{ borderColor: 'var(--app-border)' }}
+3 -2
View File
@@ -168,6 +168,7 @@ export function AppShell() {
onDelete: handleDelete,
onUndo: () => useAppStore.getState().undo(),
onRedo: () => useAppStore.getState().redo(),
onHelp: () => setHelpOpen(true),
});
// ── 双击编辑监听 ──
@@ -318,8 +319,8 @@ export function AppShell() {
<ImportDialog
open={importDialog.open}
hasSystem={importDialog.system.length > 0}
hasUser={importDialog.user.length > 0}
systemCount={importDialog.system.length}
userCount={importDialog.user.length}
onSelect={handleImportSelect}
onCancel={() => setImportDialog({ open: false, system: [], user: [] })}
/>
@@ -2,6 +2,8 @@ import { useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
export function MergePreview() {
const dataVersion = useAppStore((s) => s.dataVersion);
void dataVersion; // 订阅版本号强制重渲染
const sysPaths = useAppStore((s) => s.sysPaths);
const userPaths = useAppStore((s) => s.userPaths);
const searchQuery = useAppStore((s) => s.searchQuery);
+2
View File
@@ -12,6 +12,8 @@ interface PathRow {
}
export function PathTable({ tabId }: PathTableProps) {
const dataVersion = useAppStore((s) => s.dataVersion);
void dataVersion; // 订阅版本号强制重渲染
const sysPaths = useAppStore((s) => s.sysPaths);
const userPaths = useAppStore((s) => s.userPaths);
const searchQuery = useAppStore((s) => s.searchQuery);
+1 -1
View File
@@ -79,6 +79,6 @@ export class StringList {
/** 只读数组 */
get all(): readonly string[] {
return this.items;
return [...this.items];
}
}
+19 -12
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
interface KeyboardActions {
@@ -7,18 +7,21 @@ interface KeyboardActions {
onDelete: () => void;
onUndo: () => 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) {
const isAdmin = useAppStore((s) => s.isAdmin);
const actionsRef = useRef(actions);
actionsRef.current = actions;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// 如果焦点在输入框中,只响应 Escape
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
@@ -29,32 +32,36 @@ export function useKeyboard(actions: KeyboardActions) {
return;
}
if (!isAdmin) return;
const a = actionsRef.current;
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === 'z') {
if (!isAdmin) return;
e.preventDefault();
actions.onUndo();
a.onUndo();
} else if (ctrl && e.key === 'y') {
if (!isAdmin) return;
e.preventDefault();
actions.onRedo();
a.onRedo();
} else if (ctrl && e.key === 'n') {
if (!isAdmin) return;
e.preventDefault();
actions.onNew();
a.onNew();
} else if (ctrl && e.key === 's') {
if (!isAdmin) return;
e.preventDefault();
actions.onSave();
a.onSave();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (!isAdmin) return;
e.preventDefault();
actions.onDelete();
a.onDelete();
} else if (e.key === 'F1') {
e.preventDefault();
// 帮助由 AppShell 处理
a.onHelp();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isAdmin, actions]);
}, [isAdmin]); // 只依赖 isAdminactions 通过 ref 读取
}
+56 -59
View File
@@ -12,12 +12,11 @@ import { pathClean } from '@/core/path-manager';
export type TabId = 'system' | 'user' | 'merged';
interface AppState {
// 数据(用 StringList 而非 string[] 以支持 undo/redo
sysPaths: StringList;
userPaths: StringList;
undoRedo: UndoRedoManager;
dataVersion: number;
// UI 状态
activeTab: TabId;
searchQuery: string;
selectedIndices: number[];
@@ -26,13 +25,11 @@ interface AppState {
isModified: boolean;
isLoading: boolean;
// 基本操作
setActiveTab: (tab: TabId) => void;
setSearchQuery: (query: string) => void;
setSelectedIndices: (indices: number[]) => void;
setStatusMessage: (msg: string) => void;
// CRUD(带撤销/重做)
addPath: (path: string, target: TargetType) => void;
editPath: (index: number, newPath: string, target: TargetType) => void;
deletePaths: (indices: number[], target: TargetType) => void;
@@ -42,29 +39,26 @@ interface AppState {
importPaths: (target: TargetType, importPaths: string[]) => void;
clearPaths: (target: TargetType) => void;
// 撤销/重做
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
// 数据加载/保存
loadPaths: () => Promise<void>;
savePaths: () => Promise<void>;
loadFromStringLists: (sys: string[], user: string[]) => void;
// 初始化
initialize: () => Promise<void>;
// 内部辅助
_getTargetList: (target: TargetType) => StringList;
_markModified: () => void;
_bumpVersion: () => void;
}
export const useAppStore = create<AppState>((set, get) => ({
sysPaths: new StringList(),
userPaths: new StringList(),
undoRedo: new UndoRedoManager(50),
dataVersion: 0,
activeTab: 'system',
searchQuery: '',
@@ -84,7 +78,7 @@ export const useAppStore = create<AppState>((set, get) => ({
return target === TargetType.SYSTEM ? sysPaths : userPaths;
},
_markModified: () => set({ isModified: true }),
_bumpVersion: () => set((s) => ({ isModified: true, dataVersion: s.dataVersion + 1 })),
// ── CRUD ──
@@ -99,13 +93,13 @@ export const useAppStore = create<AppState>((set, get) => ({
oldPaths: [],
newPaths: [path],
});
get()._markModified();
get()._bumpVersion();
},
editPath: (index, newPath, target) => {
const list = get()._getTargetList(target);
const oldPath = list.get(index);
if (!oldPath) return;
if (oldPath === undefined) return;
get().undoRedo.push({
type: OperationType.EDIT,
@@ -116,41 +110,32 @@ export const useAppStore = create<AppState>((set, get) => ({
newPaths: [newPath],
});
list.set(index, newPath);
get()._markModified();
get()._bumpVersion();
},
deletePaths: (indices, target) => {
const list = get()._getTargetList(target);
// 从大到小排
if (indices.length === 0) return;
// 从大到小排序
const sorted = [...indices].sort((a, b) => b - a);
const oldPaths = sorted.map((i) => list.get(i)!);
// 记录单个 DELETE(合并多个删除为一条记录)
if (sorted.length === 1) {
// 每个删除独立记录,保证撤销时顺序正确
for (const idx of sorted) {
const oldPath = list.get(idx)!;
get().undoRedo.push({
type: OperationType.DELETE,
target,
index: sorted[0],
index: idx,
count: 1,
oldPaths,
newPaths: [],
});
} else {
get().undoRedo.push({
type: OperationType.DELETE,
target,
index: sorted[sorted.length - 1],
count: sorted.length,
oldPaths,
oldPaths: [oldPath],
newPaths: [],
});
list.removeAt(idx);
}
for (const i of sorted) {
list.removeAt(i);
}
set({ selectedIndices: [] });
get()._markModified();
get()._bumpVersion();
},
moveUp: (index, target) => {
@@ -165,7 +150,8 @@ export const useAppStore = create<AppState>((set, get) => ({
newPaths: [],
});
list.swap(index, index - 1);
get()._markModified();
set({ selectedIndices: [index - 1] });
get()._bumpVersion();
},
moveDown: (index, target) => {
@@ -180,7 +166,8 @@ export const useAppStore = create<AppState>((set, get) => ({
newPaths: [],
});
list.swap(index, index + 1);
get()._markModified();
set({ selectedIndices: [index + 1] });
get()._bumpVersion();
},
cleanPaths: (target, validateFn) => {
@@ -197,7 +184,8 @@ export const useAppStore = create<AppState>((set, get) => ({
oldPaths,
newPaths: list.toArray(),
});
get()._markModified();
set({ selectedIndices: [] });
get()._bumpVersion();
}
return removed;
@@ -207,22 +195,23 @@ export const useAppStore = create<AppState>((set, get) => ({
if (importPaths.length === 0) return;
const list = get()._getTargetList(target);
const oldPaths = list.toArray();
const copied = [...importPaths];
get().undoRedo.push({
type: OperationType.IMPORT,
target,
index: 0,
count: importPaths.length,
count: copied.length,
oldPaths,
newPaths: importPaths,
newPaths: copied,
});
// 覆盖为导入的路径
list.clear();
for (const p of importPaths) {
for (const p of copied) {
list.add(p);
}
get()._markModified();
set({ selectedIndices: [] });
get()._bumpVersion();
},
clearPaths: (target) => {
@@ -239,7 +228,7 @@ export const useAppStore = create<AppState>((set, get) => ({
newPaths: [],
});
list.clear();
get()._markModified();
get()._bumpVersion();
},
// ── 撤销/重做 ──
@@ -248,6 +237,7 @@ export const useAppStore = create<AppState>((set, get) => ({
const { undoRedo, sysPaths, userPaths } = get();
if (undoRedo.undo(sysPaths, userPaths)) {
set({ isModified: true, selectedIndices: [] });
get()._bumpVersion();
}
},
@@ -255,6 +245,7 @@ export const useAppStore = create<AppState>((set, get) => ({
const { undoRedo, sysPaths, userPaths } = get();
if (undoRedo.redo(sysPaths, userPaths)) {
set({ isModified: true, selectedIndices: [] });
get()._bumpVersion();
}
},
@@ -264,9 +255,14 @@ export const useAppStore = create<AppState>((set, get) => ({
// ── 数据加载/保存 ──
loadFromStringLists: (sys: string[], user: string[]) => {
const sysPaths = StringList.fromArray(sys);
const userPaths = StringList.fromArray(user);
set({ sysPaths, userPaths, isModified: false, isLoading: false });
set({
sysPaths: StringList.fromArray(sys),
userPaths: StringList.fromArray(user),
undoRedo: new UndoRedoManager(50),
isModified: false,
isLoading: false,
dataVersion: get().dataVersion + 1,
});
},
loadPaths: async () => {
@@ -277,44 +273,45 @@ export const useAppStore = create<AppState>((set, get) => ({
invoke<string[]>('load_user_paths'),
]);
const sysPaths = StringList.fromArray(sysArr);
const userPaths = StringList.fromArray(userArr);
const undoRedo = new UndoRedoManager(50);
set({
sysPaths,
userPaths,
undoRedo,
sysPaths: StringList.fromArray(sysArr),
userPaths: StringList.fromArray(userArr),
undoRedo: new UndoRedoManager(50),
isLoading: false,
isModified: false,
dataVersion: get().dataVersion + 1,
statusMessage: i18n.t('status.loaded', {
sysCount: sysArr.length,
userCount: userArr.length,
}),
});
} catch (e) {
set({ isLoading: false, statusMessage: `加载失败: ${e}` });
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${e}` });
}
},
savePaths: async () => {
const { sysPaths, userPaths } = get();
set({ statusMessage: '正在保存...' });
set({ statusMessage: i18n.t('status.saving') });
try {
await invoke('save_system_paths', { paths: sysPaths.toArray() });
await invoke('save_user_paths', { paths: userPaths.toArray() });
await invoke('broadcast_env_change');
set({ isModified: false, statusMessage: '保存成功' });
set({ isModified: false, statusMessage: i18n.t('status.saved') });
} catch (e) {
set({ statusMessage: `保存失败: ${e}` });
set({ statusMessage: `${i18n.t('status.error')}: ${e}` });
}
},
initialize: async () => {
const isAdmin: boolean = await invoke('check_admin');
set({ isAdmin });
if (!isAdmin) {
set({ statusMessage: '只读模式 — 需要管理员权限才能编辑' });
try {
const isAdmin: boolean = await invoke('check_admin');
set({ isAdmin });
if (!isAdmin) {
set({ statusMessage: i18n.t('status.readonly') });
}
} catch {
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
}
await get().loadPaths();
},
+12 -4
View File
@@ -5,8 +5,16 @@ interface ThemeState {
toggle: () => void;
}
function getSavedDarkMode(): boolean {
try {
return localStorage.getItem('darkMode') === '1';
} catch {
return false;
}
}
export const useThemeStore = create<ThemeState>((set) => ({
isDark: false,
isDark: getSavedDarkMode(),
toggle: () =>
set((state) => {
const next = !state.isDark;
@@ -21,10 +29,10 @@ export const useThemeStore = create<ThemeState>((set) => ({
}),
}));
/** 初始化深色模式状态(从 localStorage 读取 */
/** 初始化深色模式DOM 类名 + store 状态 */
export function initDarkMode(): void {
const saved = localStorage.getItem('darkMode');
if (saved === '1') {
if (getSavedDarkMode()) {
document.documentElement.classList.add('dark');
useThemeStore.setState({ isDark: true });
}
}