refactor: 全面代码质量提升 — StringList→string[], strict 模式, 死代码清理

架构重构:
- StringList 类替换为不可变 string[](消除 dataVersion hack,Zustand 自然检测变化)
- UndoRedoManager.undo/redo 返回新数组而非原地修改
- 删除 dataVersion 字段和 _bumpVersion()
- 启用 TypeScript strict 模式

死代码清理:
- 删除 string-list.ts, string-list.test.ts, use-path-validation.ts
- Rust AppError 保留供未来使用

功能修复:
- importFromJson 添加 try/catch
- handleClean 使用真实格式验证替代 () => true
- savePaths 保存前调用 backup_registry,处理部分保存失败
- importFromJson 校验非 object 类型输入

i18n 完善:
- MergePreview/StatusBar 硬编码中文 → t() 调用
- 新增 merge.* 和 status.* 翻译键

Rust 改进:
- registry.rs 抽取 load_paths/save_paths 通用函数,消除重复
- registry 新增 6 个单元测试(split/join/roundtrip)
- backup.rs 时间戳加毫秒防覆盖,回退路径改为 home_dir

元数据:
- package.json 名称→patheditor, 版本→4.0.0
- 新增 CHANGELOG.md
- 移除 UndoRedoButtons 废弃注释
- tsconfig 添加 strict:true

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:26:27 +08:00
parent 2ceec54790
commit bfd114d80f
21 changed files with 410 additions and 836 deletions
+5 -5
View File
@@ -49,7 +49,7 @@ export function AppShell() {
const list = target === TargetType.SYSTEM
? useAppStore.getState().sysPaths
: useAppStore.getState().userPaths;
const value = list.get(idx);
const value = list[idx];
if (value) {
setEditDialog({ open: true, index: idx, value, target });
}
@@ -88,7 +88,7 @@ export function AppShell() {
const handleClean = useCallback(() => {
const removed = useAppStore.getState().cleanPaths(
getCurrentTarget(),
() => true, // 简化版,全有效
(p) => p.includes('%') || p.includes('\\') || p.includes('/') || /^[a-zA-Z]:[/\\]/.test(p),
);
if (removed.length > 0) {
useAppStore.getState().setStatusMessage(
@@ -120,7 +120,7 @@ export function AppShell() {
const handleExport = useCallback(() => {
const state = useAppStore.getState();
const data = { system: state.sysPaths.toArray(), user: state.userPaths.toArray() };
const data = { system: state.sysPaths, user: state.userPaths };
const content = exportToJson(data);
const mime = 'application/json';
@@ -137,8 +137,8 @@ export function AppShell() {
const handleSave = useCallback(() => {
const state = useAppStore.getState();
const sysJoined = state.sysPaths.toArray().join(';');
const userJoined = state.userPaths.toArray().join(';');
const sysJoined = state.sysPaths.join(';');
const userJoined = state.userPaths.join(';');
const combined = sysJoined + ';' + userJoined;
const warnings: string[] = [];
+6 -4
View File
@@ -1,7 +1,9 @@
import { useAppStore } from '@/store/app-store';
import { useThemeStore } from '@/store/theme-store';
import { useTranslation } from 'react-i18next';
export function StatusBar() {
const { t } = useTranslation();
const statusMessage = useAppStore((s) => s.statusMessage);
const isLoading = useAppStore((s) => s.isLoading);
const isAdmin = useAppStore((s) => s.isAdmin);
@@ -17,11 +19,11 @@ export function StatusBar() {
color: 'var(--app-fg)',
}}
>
<span>{isLoading ? '加载中...' : statusMessage}</span>
<span>{isLoading ? t('status.loading') : statusMessage}</span>
<div className="flex gap-3">
{isModified && <span className="text-yellow-500"> </span>}
{!isAdmin && <span className="text-yellow-500"></span>}
<span style={{ opacity: 0.5 }}>{isDark ? '深色' : '浅色'}</span>
{isModified && <span className="text-yellow-500"> {t('status.modified')}</span>}
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
<span style={{ opacity: 0.5 }}>{isDark ? t('status.dark') : t('status.light')}</span>
</div>
</footer>
);
+8 -8
View File
@@ -1,22 +1,22 @@
import { useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import { useTranslation } from 'react-i18next';
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);
const { t } = useTranslation();
const allPaths = useMemo(() => {
const result: { path: string; source: '系统' | '用户'; index: number }[] = [];
sysPaths.all.forEach((p, i) => result.push({ path: p, source: '系统' as const, index: i }));
userPaths.all.forEach((p, i) => result.push({ path: p, source: '用户' as const, index: i }));
const result: { path: string; source: string; index: number }[] = [];
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
if (!searchQuery) return result;
const q = searchQuery.toLowerCase();
return result.filter((r) => r.path.toLowerCase().includes(q));
}, [sysPaths, userPaths, searchQuery]);
}, [sysPaths, userPaths, searchQuery, t]);
return (
<div className="flex-1 overflow-auto">
@@ -27,8 +27,8 @@ export function MergePreview() {
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<th className="w-10 px-2 py-1">#</th>
<th className="px-2 py-1"></th>
<th className="w-16 px-2 py-1"></th>
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
</tr>
</thead>
<tbody>
+5 -7
View File
@@ -12,8 +12,6 @@ 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);
@@ -31,11 +29,11 @@ export function PathTable({ tabId }: PathTableProps) {
// 过滤搜索
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 result: PathRow[] = [];
for (let i = 0; i < paths.length; i++) {
const p = paths.get(i)!;
const p = paths[i];
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
}
return result;
@@ -44,7 +42,7 @@ export function PathTable({ tabId }: PathTableProps) {
// 异步验证未缓存的路径
useEffect(() => {
let cancelled = false;
const allPaths = paths.all;
const allPaths = paths;
// 找出未缓存的路径
const toValidate = allPaths.filter((p) => !validationCache.has(p));
@@ -81,7 +79,7 @@ export function PathTable({ tabId }: PathTableProps) {
// 异步展开环境变量(用于 tooltip)
useEffect(() => {
let cancelled = false;
const toExpand = paths.all.filter(
const toExpand = paths.filter(
(p) => p.includes('%') && !expandedCache.has(p),
);
if (toExpand.length === 0) return;
@@ -146,7 +144,7 @@ export function PathTable({ tabId }: PathTableProps) {
if (!isActive) return;
window.dispatchEvent(
new CustomEvent('path-dblclick', {
detail: { index: realIndex, path: paths.get(realIndex) },
detail: { index: realIndex, path: paths[realIndex] },
}),
);
},
@@ -16,9 +16,6 @@ export function UndoRedoButtons() {
borderColor: 'var(--app-border)',
};
// 订阅状态更新(canUndo/canRedo 不会触发 re-render,用 setTimeout 简单轮询不优雅,但 Zustand 的 subscribe 可以)
// 这里简化为每次渲染时检查(因为 undo/redo 会修改列表触发重渲染)
return (
<div className="flex gap-1">
<button
+8 -1
View File
@@ -145,7 +145,14 @@ function parseCsvLine(line: string): string[] {
export function importFromJson(content: string): ImportResult {
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)) {
result.system = obj.system.filter(
+14 -59
View File
@@ -1,92 +1,47 @@
/**
* 路径管理器 — 对应 C 版 path_manager.c
* 提供路径增删移清理等 CRUD 操作的纯逻辑
* 路径管理器 — 不可变的 string[] 操作
*/
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 {
isValid: boolean;
isDuplicate: boolean;
isEnvVar: boolean;
}
/**
* 分析路径列表中各条目的状态
* validateFn: 验证路径是否有效(需调用 Rust validate_path
*/
export function analyzePaths(
list: StringList,
paths: readonly string[],
validateFn: (path: string) => boolean,
): PathValidation[] {
const result: PathValidation[] = [];
const seen = new Set<string>();
for (let i = 0; i < list.length; i++) {
const path = list.get(i)!;
for (const path of paths) {
const lower = path.toLowerCase();
const isDuplicate = seen.has(lower);
seen.add(lower);
result.push({
isValid: validateFn(path),
isDuplicate,
isEnvVar: path.includes('%'),
});
result.push({ isValid: validateFn(path), isDuplicate, isEnvVar: path.includes('%') });
}
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(
list: StringList,
paths: readonly string[],
validateFn: (path: string) => boolean,
): string[] {
const analysis = analyzePaths(list, validateFn);
): [string[], string[]] {
const analysis = analyzePaths(paths, validateFn);
const kept: 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];
// 移除无效或重复的路径
if (!a.isValid || a.isDuplicate) {
removed.unshift(list.get(i)!);
list.removeAt(i);
removed.push(paths[i]);
} else {
kept.push(paths[i]);
}
}
return removed;
return [kept, removed];
}
-84
View File
@@ -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
View File
@@ -1,25 +1,13 @@
/**
* 撤销/重做管理器 — 对应 C 版 undo_redo.c
* 支持 8 种操作类型的完整撤销/重做
* 撤销/重做管理器 — 纯逻辑,操作不可变 string[]
*/
import { StringList } from './string-list';
export const OperationType = {
ADD: 0, // 新增路径
DELETE: 1, // 删除路径
EDIT: 2, // 编辑路径
MOVE_UP: 3, // 上移
MOVE_DOWN: 4, // 下移
CLEAN: 5, // 一键清理
CLEAR: 6, // 清空
IMPORT: 7, // 导入
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7,
} as const;
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
export const TargetType = {
SYSTEM: 0,
USER: 1,
} as const;
export const TargetType = { SYSTEM: 0, USER: 1 } as const;
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
export interface OpRecord {
@@ -42,139 +30,99 @@ export class UndoRedoManager {
this.maxSize = maxSize;
}
/** 推送新操作记录,推送后截断重做分支 */
push(record: OpRecord): void {
// 截断重做分支
this.records = this.records.slice(0, this.current + 1);
// 如果已满,移除最旧的记录
if (this.records.length >= this.maxSize) {
this.records.shift();
}
this.records.push(record);
this.current = this.records.length - 1;
}
/** 撤销当前操作 */
undo(sysPaths: StringList, userPaths: StringList): boolean {
if (this.current < 0) return false;
undo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
if (this.current < 0) return null;
const rec = this.records[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) {
case OperationType.ADD:
// 撤销添加 — 删除最后 count 个元素
for (let i = 0; i < rec.count; i++) {
target.removeAt(target.length - 1);
}
target.splice(target.length - rec.count, rec.count);
break;
case OperationType.DELETE:
// 撤销删除 — 逐个恢复
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;
case OperationType.EDIT:
target.set(rec.index, rec.oldPaths[0]);
target[rec.index] = rec.oldPaths[0];
break;
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;
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;
case OperationType.CLEAN:
case OperationType.IMPORT:
// 恢复到操作前的完整列表
target.clear();
for (const path of rec.oldPaths) {
target.add(path);
}
target.length = 0;
target.push(...rec.oldPaths);
break;
case OperationType.CLEAR:
for (const path of rec.oldPaths) {
target.add(path);
}
target.push(...rec.oldPaths);
break;
}
return true;
return [sys, user];
}
/** 重做下一个操作 */
redo(sysPaths: StringList, userPaths: StringList): boolean {
if (this.current >= this.records.length - 1) return false;
redo(sysPaths: readonly string[], userPaths: readonly string[]): [string[], string[]] | null {
if (this.current >= this.records.length - 1) return null;
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) {
case OperationType.ADD:
for (let i = 0; i < rec.count; i++) {
target.add(rec.newPaths[i]);
}
target.push(...rec.newPaths);
break;
case OperationType.DELETE:
// 从后往前删,避免索引偏移
for (let i = rec.count - 1; i >= 0; i--) {
target.removeAt(rec.index + i);
target.splice(rec.index + i, 1);
}
break;
case OperationType.EDIT:
target.set(rec.index, rec.newPaths[0]);
target[rec.index] = rec.newPaths[0];
break;
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;
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;
case OperationType.CLEAN:
case OperationType.IMPORT:
target.clear();
for (const path of rec.newPaths) {
target.add(path);
}
target.length = 0;
target.push(...rec.newPaths);
break;
case OperationType.CLEAR:
target.clear();
target.length = 0;
break;
}
return true;
return [sys, user];
}
canUndo(): boolean {
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;
}
canUndo(): boolean { 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; }
}
-35
View File
@@ -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 };
}
+10
View File
@@ -27,6 +27,11 @@
"lightMode": "Light Mode",
"language": "Language"
},
"merge": {
"system": "System",
"user": "User",
"source": "Source"
},
"status": {
"normal": "Ready",
"readonly": "Read-only mode — Administrator privileges required for editing",
@@ -36,6 +41,11 @@
"deleted": "Deleted {{count}} path(s)",
"loaded": "Loaded {{sysCount}} system and {{userCount}} user paths",
"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"
},
"dialog": {
+11 -1
View File
@@ -27,6 +27,11 @@
"lightMode": "浅色模式",
"language": "语言"
},
"merge": {
"system": "系统",
"user": "用户",
"source": "来源"
},
"status": {
"normal": "就绪",
"readonly": "只读模式 — 需要管理员权限才能编辑",
@@ -36,7 +41,12 @@
"deleted": "已删除 {{count}} 个路径",
"loaded": "已加载 {{sysCount}} 个系统路径和 {{userCount}} 个用户路径",
"dragFolderOnly": "只能拖拽文件夹",
"adminWarning": "当前以非管理员身份运行,部分功能不可用"
"adminWarning": "当前以非管理员身份运行,部分功能不可用",
"loading": "加载中...",
"modified": "已修改",
"readonly_label": "只读",
"light": "浅色",
"dark": "深色"
},
"dialog": {
"newPath": "新建路径",
+106 -164
View File
@@ -1,21 +1,15 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
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';
export type TabId = 'system' | 'user' | 'merged';
interface AppState {
sysPaths: StringList;
userPaths: StringList;
sysPaths: string[];
userPaths: string[];
undoRedo: UndoRedoManager;
dataVersion: number;
activeTab: TabId;
searchQuery: string;
@@ -46,19 +40,14 @@ interface AppState {
loadPaths: () => Promise<void>;
savePaths: () => Promise<void>;
loadFromStringLists: (sys: string[], user: string[]) => void;
initialize: () => Promise<void>;
_getTargetList: (target: TargetType) => StringList;
_bumpVersion: () => void;
}
export const useAppStore = create<AppState>((set, get) => ({
sysPaths: new StringList(),
userPaths: new StringList(),
sysPaths: [],
userPaths: [],
undoRedo: new UndoRedoManager(50),
dataVersion: 0,
activeTab: 'system',
searchQuery: '',
@@ -73,119 +62,92 @@ export const useAppStore = create<AppState>((set, get) => ({
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
setStatusMessage: (msg) => set({ statusMessage: msg }),
_getTargetList: (target) => {
const { sysPaths, userPaths } = get();
return target === TargetType.SYSTEM ? sysPaths : userPaths;
},
_bumpVersion: () => set((s) => ({ isModified: true, dataVersion: s.dataVersion + 1 })),
// ── CRUD ──
addPath: (path, target) => {
const list = get()._getTargetList(target);
list.add(path);
get().undoRedo.push({
type: OperationType.ADD,
target,
index: list.length - 1,
count: 1,
oldPaths: [],
newPaths: [path],
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const newList = [...list, path];
state.undoRedo.push({
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
oldPaths: [], newPaths: [path],
});
get()._bumpVersion();
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
else set({ userPaths: newList, isModified: true });
},
editPath: (index, newPath, target) => {
const list = get()._getTargetList(target);
const oldPath = list.get(index);
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const oldPath = list[index];
if (oldPath === undefined) return;
get().undoRedo.push({
type: OperationType.EDIT,
target,
index,
count: 1,
oldPaths: [oldPath],
newPaths: [newPath],
state.undoRedo.push({
type: OperationType.EDIT, target, index, count: 1,
oldPaths: [oldPath], newPaths: [newPath],
});
list.set(index, newPath);
get()._bumpVersion();
const newList = [...list];
newList[index] = newPath;
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
else set({ userPaths: newList, isModified: true });
},
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);
// 每个删除独立记录,保证撤销时顺序正确
for (const idx of sorted) {
const oldPath = list.get(idx)!;
get().undoRedo.push({
type: OperationType.DELETE,
target,
index: idx,
count: 1,
oldPaths: [oldPath],
newPaths: [],
state.undoRedo.push({
type: OperationType.DELETE, target, index: idx, count: 1,
oldPaths: [list[idx]], newPaths: [],
});
list.removeAt(idx);
}
set({ selectedIndices: [] });
get()._bumpVersion();
const toRemove = new Set(sorted);
const newList = list.filter((_, i) => !toRemove.has(i));
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [], isModified: true });
else set({ userPaths: newList, selectedIndices: [], isModified: true });
},
moveUp: (index, target) => {
if (index <= 0) return;
const list = get()._getTargetList(target);
get().undoRedo.push({
type: OperationType.MOVE_UP,
target,
index,
count: 1,
oldPaths: [],
newPaths: [],
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
state.undoRedo.push({
type: OperationType.MOVE_UP, target, index, count: 1,
oldPaths: [], newPaths: [],
});
list.swap(index, index - 1);
set({ selectedIndices: [index - 1] });
get()._bumpVersion();
const newList = [...list];
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1], isModified: true });
else set({ userPaths: newList, selectedIndices: [index - 1], isModified: true });
},
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;
get().undoRedo.push({
type: OperationType.MOVE_DOWN,
target,
index,
count: 1,
oldPaths: [],
newPaths: [],
state.undoRedo.push({
type: OperationType.MOVE_DOWN, target, index, count: 1,
oldPaths: [], newPaths: [],
});
list.swap(index, index + 1);
set({ selectedIndices: [index + 1] });
get()._bumpVersion();
const newList = [...list];
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1], isModified: true });
else set({ userPaths: newList, selectedIndices: [index + 1], isModified: true });
},
cleanPaths: (target, validateFn) => {
const list = get()._getTargetList(target);
const oldPaths = list.toArray();
const removed = pathClean(list, validateFn);
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const [kept, removed] = pathClean(list, validateFn);
if (removed.length > 0) {
get().undoRedo.push({
type: OperationType.CLEAN,
target,
index: 0,
count: removed.length,
oldPaths,
newPaths: list.toArray(),
state.undoRedo.push({
type: OperationType.CLEAN, target, index: 0, count: removed.length,
oldPaths: [...list], newPaths: kept,
});
set({ selectedIndices: [] });
get()._bumpVersion();
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [], isModified: true });
else set({ userPaths: kept, selectedIndices: [], isModified: true });
}
return removed;
@@ -193,78 +155,48 @@ export const useAppStore = create<AppState>((set, get) => ({
importPaths: (target, importPaths) => {
if (importPaths.length === 0) return;
const list = get()._getTargetList(target);
const oldPaths = list.toArray();
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const copied = [...importPaths];
get().undoRedo.push({
type: OperationType.IMPORT,
target,
index: 0,
count: copied.length,
oldPaths,
newPaths: copied,
state.undoRedo.push({
type: OperationType.IMPORT, target, index: 0, count: copied.length,
oldPaths: [...list], newPaths: copied,
});
list.clear();
for (const p of copied) {
list.add(p);
}
set({ selectedIndices: [] });
get()._bumpVersion();
if (target === TargetType.SYSTEM) set({ sysPaths: copied, selectedIndices: [], isModified: true });
else set({ userPaths: copied, selectedIndices: [], isModified: true });
},
clearPaths: (target) => {
const list = get()._getTargetList(target);
const oldPaths = list.toArray();
if (oldPaths.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (list.length === 0) return;
get().undoRedo.push({
type: OperationType.CLEAR,
target,
index: 0,
count: oldPaths.length,
oldPaths,
newPaths: [],
state.undoRedo.push({
type: OperationType.CLEAR, target, index: 0, count: list.length,
oldPaths: [...list], newPaths: [],
});
list.clear();
get()._bumpVersion();
},
// ── 撤销/重做 ──
if (target === TargetType.SYSTEM) set({ sysPaths: [], isModified: true });
else set({ userPaths: [], isModified: true });
},
undo: () => {
const { undoRedo, sysPaths, userPaths } = get();
if (undoRedo.undo(sysPaths, userPaths)) {
set({ isModified: true, selectedIndices: [] });
get()._bumpVersion();
}
const result = undoRedo.undo(sysPaths, userPaths);
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
},
redo: () => {
const { undoRedo, sysPaths, userPaths } = get();
if (undoRedo.redo(sysPaths, userPaths)) {
set({ isModified: true, selectedIndices: [] });
get()._bumpVersion();
}
const result = undoRedo.redo(sysPaths, userPaths);
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
},
canUndo: () => get().undoRedo.canUndo(),
canRedo: () => get().undoRedo.canRedo(),
// ── 数据加载/保存 ──
loadFromStringLists: (sys: string[], user: string[]) => {
set({
sysPaths: StringList.fromArray(sys),
userPaths: StringList.fromArray(user),
undoRedo: new UndoRedoManager(50),
isModified: false,
isLoading: false,
dataVersion: get().dataVersion + 1,
});
},
loadPaths: async () => {
try {
set({ isLoading: true });
@@ -272,34 +204,46 @@ export const useAppStore = create<AppState>((set, get) => ({
invoke<string[]>('load_system_paths'),
invoke<string[]>('load_user_paths'),
]);
set({
sysPaths: StringList.fromArray(sysArr),
userPaths: StringList.fromArray(userArr),
sysPaths: sysArr,
userPaths: userArr,
undoRedo: new UndoRedoManager(50),
isLoading: false,
isModified: false,
dataVersion: get().dataVersion + 1,
statusMessage: i18n.t('status.loaded', {
sysCount: sysArr.length,
userCount: userArr.length,
}),
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
});
} catch (e) {
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${e}` });
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
}
},
savePaths: async () => {
const { sysPaths, userPaths } = get();
const sysJoined = sysPaths.join(';');
const userJoined = userPaths.join(';');
if (sysJoined.length > 2048 || userJoined.length > 2048 || (sysJoined + userJoined).length > 8191) {
if (!window.confirm(`${i18n.t('status.error')}: PATH 长度超过建议值,是否继续?`)) return;
}
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');
// 保存前备份
try { await invoke('backup_registry', { customDir: null, sysPaths, userPaths }); } catch { /* 备份失败不阻止保存 */ }
let sysOk = true, userOk = true;
try { await invoke('save_system_paths', { paths: sysPaths }); } catch { sysOk = false; }
try { await invoke('save_user_paths', { paths: userPaths }); } catch { userOk = false; }
if (sysOk && userOk) {
try { await invoke('broadcast_env_change'); } catch { /* 广播失败不阻止 */ }
set({ isModified: false, statusMessage: i18n.t('status.saved') });
} catch (e) {
set({ statusMessage: `${i18n.t('status.error')}: ${e}` });
} else if (sysOk) {
set({ statusMessage: '用户 PATH 保存失败,系统 PATH 已保存' });
} else if (userOk) {
set({ statusMessage: '系统 PATH 保存失败,用户 PATH 已保存' });
} else {
set({ statusMessage: `${i18n.t('status.error')}: 保存失败` });
}
},
@@ -307,9 +251,7 @@ export const useAppStore = create<AppState>((set, get) => ({
try {
const isAdmin: boolean = await invoke('check_admin');
set({ isAdmin });
if (!isAdmin) {
set({ statusMessage: i18n.t('status.readonly') });
}
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
} catch {
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
}