mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:37:22 +08:00
重构(应用状态存储): 调整savePaths返回结构化结果并更新所有调用处
- 新增SaveResult类型统一标准化保存操作的结果状态 - 修改savePaths函数返回结构化结果而非布尔值,完善长路径超限、部分失败等场景的处理逻辑,部分失败时重新加载路径避免状态偏移 - 更新useAppActions与ProfileDialog的保存逻辑,适配新API并添加长路径确认弹窗 - 补充相关测试用例,修正导入导出测试的版本号预期
This commit is contained in:
@@ -73,8 +73,19 @@ export function ProfileDialog({ open, onClose }: Props) {
|
|||||||
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
|
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
|
||||||
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
|
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
|
||||||
});
|
});
|
||||||
await useAppStore.getState().savePaths();
|
const result = await useAppStore.getState().savePaths();
|
||||||
onClose();
|
if (result.kind === 'success') {
|
||||||
|
onClose();
|
||||||
|
} else if (result.kind === 'warning') {
|
||||||
|
const { ask } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const confirmed = await ask(t('status.saveWarningLongPaths'), { title: t('dialog.backupTitle'), kind: 'warning' });
|
||||||
|
if (confirmed) {
|
||||||
|
const forceResult = await useAppStore.getState().savePaths(true);
|
||||||
|
if (forceResult.kind === 'success') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const saved = await useAppStore.getState().savePaths();
|
const result = await useAppStore.getState().savePaths();
|
||||||
if (!saved && !useAppStore.getState().isSaving) {
|
if (result.kind === 'warning') {
|
||||||
// 长度超限,需要用户确认
|
// 长度超限,需要用户确认
|
||||||
const { ask } = await import('@tauri-apps/plugin-dialog');
|
const { ask } = await import('@tauri-apps/plugin-dialog');
|
||||||
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' });
|
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' });
|
||||||
|
|||||||
+24
-7
@@ -8,6 +8,13 @@ import appConfig from '@/config/default.json';
|
|||||||
|
|
||||||
export type TabId = 'system' | 'user' | 'merged';
|
export type TabId = 'system' | 'user' | 'merged';
|
||||||
|
|
||||||
|
export type SaveResult =
|
||||||
|
| { kind: 'success' }
|
||||||
|
| { kind: 'warning'; reason: 'lengthExceeded' }
|
||||||
|
| { kind: 'failure'; message: string }
|
||||||
|
| { kind: 'partial'; message: string }
|
||||||
|
| { kind: 'blocked' };
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
sysPaths: PathEntry[];
|
sysPaths: PathEntry[];
|
||||||
userPaths: PathEntry[];
|
userPaths: PathEntry[];
|
||||||
@@ -45,7 +52,7 @@ interface AppState {
|
|||||||
redo: () => void;
|
redo: () => void;
|
||||||
|
|
||||||
loadPaths: () => Promise<void>;
|
loadPaths: () => Promise<void>;
|
||||||
savePaths: (force?: boolean) => Promise<boolean>;
|
savePaths: (force?: boolean) => Promise<SaveResult>;
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -324,7 +331,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
|
|
||||||
savePaths: async (force?: boolean) => {
|
savePaths: async (force?: boolean) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.isSaving) return false;
|
if (state.isSaving) return { kind: 'blocked' };
|
||||||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||||
|
|
||||||
// 只保存 enabled 的路径到注册表
|
// 只保存 enabled 的路径到注册表
|
||||||
@@ -337,7 +344,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
||||||
if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) {
|
if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) {
|
||||||
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
|
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
|
||||||
return false;
|
return { kind: 'warning', reason: 'lengthExceeded' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||||
@@ -359,14 +366,24 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
set({ isModified: false, isSaving: false,
|
set({ isModified: false, isSaving: false,
|
||||||
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
|
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
|
||||||
_savedSys: savedSys, _savedUser: savedUser });
|
_savedSys: savedSys, _savedUser: savedUser });
|
||||||
return true;
|
return { kind: 'success' };
|
||||||
} else {
|
} else {
|
||||||
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
||||||
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||||
const parts = [sysErr, usrErr].filter(Boolean);
|
const parts = [sysErr, usrErr].filter(Boolean);
|
||||||
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`;
|
|
||||||
set({ isSaving: false, statusMessage: msg });
|
const msg = sysOk ? `用户 PATH 保存失败: ${usrErr}` : userOk ? `系统 PATH 保存失败: ${sysErr}` : `保存失败: ${parts.join('; ')}`;
|
||||||
return false;
|
|
||||||
|
if (sysOk || userOk) {
|
||||||
|
// partial success
|
||||||
|
set({ isSaving: false });
|
||||||
|
await get().loadPaths(); // reload to avoid state drift
|
||||||
|
set({ statusMessage: msg }); // restore the error message overwritten by loadPaths
|
||||||
|
return { kind: 'partial', message: msg };
|
||||||
|
} else {
|
||||||
|
set({ isSaving: false, statusMessage: msg });
|
||||||
|
return { kind: 'failure', message: msg };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -239,19 +239,26 @@ describe('savePaths', () => {
|
|||||||
|
|
||||||
it('保存成功', async () => {
|
it('保存成功', async () => {
|
||||||
mockedInvoke.mockResolvedValue(undefined);
|
mockedInvoke.mockResolvedValue(undefined);
|
||||||
await useAppStore.getState().savePaths();
|
const result = await useAppStore.getState().savePaths();
|
||||||
|
expect(result).toEqual({ kind: 'success' });
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.isSaving).toBe(false);
|
expect(s.isSaving).toBe(false);
|
||||||
expect(s.isModified).toBe(false);
|
expect(s.isModified).toBe(false);
|
||||||
expect(s.statusMessage).toBe('保存成功');
|
expect(s.statusMessage).toBe('保存成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('部分失败时报告具体 hive', async () => {
|
it('部分失败时报告具体 hive 并回读', async () => {
|
||||||
mockedInvoke
|
mockedInvoke
|
||||||
.mockResolvedValueOnce(undefined) // backup_registry
|
.mockResolvedValueOnce(undefined) // backup_registry
|
||||||
.mockResolvedValueOnce(undefined) // save_system_paths
|
.mockResolvedValueOnce(undefined) // save_system_paths
|
||||||
.mockRejectedValueOnce('权限不足'); // save_user_paths
|
.mockRejectedValueOnce('权限不足') // save_user_paths
|
||||||
await useAppStore.getState().savePaths();
|
// 以下为 partial 触发的 loadPaths 调用
|
||||||
|
.mockResolvedValueOnce(['A']) // load_system_paths
|
||||||
|
.mockResolvedValueOnce(['B']) // load_user_paths
|
||||||
|
.mockResolvedValueOnce([[], []]); // load_disabled_state
|
||||||
|
|
||||||
|
const result = await useAppStore.getState().savePaths();
|
||||||
|
expect(result.kind).toBe('partial');
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
expect(s.isSaving).toBe(false);
|
expect(s.isSaving).toBe(false);
|
||||||
expect(s.statusMessage).toContain('用户 PATH 保存失败');
|
expect(s.statusMessage).toContain('用户 PATH 保存失败');
|
||||||
@@ -268,8 +275,8 @@ describe('savePaths', () => {
|
|||||||
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true)
|
// 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true)
|
||||||
const r2 = useAppStore.getState().savePaths();
|
const r2 = useAppStore.getState().savePaths();
|
||||||
|
|
||||||
// 第二次调用同步返回 false(被守卫拦截)
|
// 第二次调用同步返回 blocked(被守卫拦截)
|
||||||
await expect(r2).resolves.toBe(false);
|
await expect(r2).resolves.toEqual({ kind: 'blocked' });
|
||||||
|
|
||||||
// 放行第一次调用的所有 invoke
|
// 放行第一次调用的所有 invoke
|
||||||
resolveAll!(undefined);
|
resolveAll!(undefined);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('exportToJson', () => {
|
|||||||
it('导出结构化 JSON', () => {
|
it('导出结构化 JSON', () => {
|
||||||
const json = exportToJson(sampleData);
|
const json = exportToJson(sampleData);
|
||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
expect(parsed.version).toBe('5.0.0');
|
expect(parsed.version).toBe('5.1.0');
|
||||||
expect(parsed.timestamp).toBeDefined();
|
expect(parsed.timestamp).toBeDefined();
|
||||||
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(sampleData.system.map(e => e.path));
|
expect(parsed.system.map((e: { path: string }) => e.path)).toEqual(sampleData.system.map(e => e.path));
|
||||||
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(sampleData.user.map(e => e.path));
|
expect(parsed.user.map((e: { path: string }) => e.path)).toEqual(sampleData.user.map(e => e.path));
|
||||||
|
|||||||
@@ -208,21 +208,22 @@ describe('useAppActions', () => {
|
|||||||
|
|
||||||
it('handleSave 正常保存', async () => {
|
it('handleSave 正常保存', async () => {
|
||||||
mockedInvoke.mockResolvedValue(undefined);
|
mockedInvoke.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(useAppStore.getState(), 'savePaths').mockResolvedValue({ kind: 'success' });
|
||||||
const { useAppActions } = await import('@/hooks/use-app-actions');
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
const { result } = renderHook(() => useAppActions('system', dialogs));
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
await act(async () => { await result.current.handleSave(); });
|
await act(async () => { await result.current.handleSave(); });
|
||||||
// invoke 被调用(backup + save_system + save_user + broadcast)
|
// savePaths is called
|
||||||
expect(mockedInvoke).toHaveBeenCalled();
|
expect(useAppStore.getState().savePaths).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handleSave 超长确认后强制保存', async () => {
|
it('handleSave 超长确认后强制保存', async () => {
|
||||||
// 第一次 savePaths 返回 false(超长)
|
// 第一次 savePaths 返回 warning(超长)
|
||||||
// 第二次(force=true)返回 true
|
// 第二次(force=true)返回 success
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async (force?: boolean) => {
|
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async (force?: boolean) => {
|
||||||
callCount++;
|
callCount++;
|
||||||
if (!force) return false; // 第一次:超长警告
|
if (!force) return { kind: 'warning', reason: 'lengthExceeded' }; // 第一次:超长警告
|
||||||
return true; // 第二次:强制保存成功
|
return { kind: 'success' }; // 第二次:强制保存成功
|
||||||
});
|
});
|
||||||
const { useAppActions } = await import('@/hooks/use-app-actions');
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
const { result } = renderHook(() => useAppActions('system', dialogs));
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
@@ -230,4 +231,17 @@ describe('useAppActions', () => {
|
|||||||
expect(callCount).toBe(2);
|
expect(callCount).toBe(2);
|
||||||
expect(mockAsk).toHaveBeenCalled();
|
expect(mockAsk).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handleSave 普通失败不弹确认框', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
return { kind: 'failure', message: '权限不足' };
|
||||||
|
});
|
||||||
|
const { useAppActions } = await import('@/hooks/use-app-actions');
|
||||||
|
const { result } = renderHook(() => useAppActions('system', dialogs));
|
||||||
|
await act(async () => { await result.current.handleSave(); });
|
||||||
|
expect(callCount).toBe(1); // 仅调用一次,不重试
|
||||||
|
expect(mockAsk).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user