diff --git a/src/components/dialogs/ProfileDialog.tsx b/src/components/dialogs/ProfileDialog.tsx index fc2a028..b59615b 100644 --- a/src/components/dialogs/ProfileDialog.tsx +++ b/src/components/dialogs/ProfileDialog.tsx @@ -73,8 +73,19 @@ export function ProfileDialog({ open, onClose }: Props) { 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 result = await useAppStore.getState().savePaths(); + 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) => { diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index d19f53f..5b344e2 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -118,8 +118,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) { }, []); const handleSave = useCallback(async () => { - const saved = await useAppStore.getState().savePaths(); - if (!saved && !useAppStore.getState().isSaving) { + const result = await useAppStore.getState().savePaths(); + if (result.kind === 'warning') { // 长度超限,需要用户确认 const { ask } = await import('@tauri-apps/plugin-dialog'); const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' }); diff --git a/src/store/app-store.ts b/src/store/app-store.ts index 6797af7..3e029b9 100644 --- a/src/store/app-store.ts +++ b/src/store/app-store.ts @@ -8,6 +8,13 @@ import appConfig from '@/config/default.json'; 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 { sysPaths: PathEntry[]; userPaths: PathEntry[]; @@ -45,7 +52,7 @@ interface AppState { redo: () => void; loadPaths: () => Promise; - savePaths: (force?: boolean) => Promise; + savePaths: (force?: boolean) => Promise; initialize: () => Promise; } @@ -324,7 +331,7 @@ export const useAppStore = create((set, get) => { savePaths: async (force?: boolean) => { const state = get(); - if (state.isSaving) return false; + if (state.isSaving) return { kind: 'blocked' }; set({ isSaving: true, statusMessage: i18n.t('status.saving') }); // 只保存 enabled 的路径到注册表 @@ -337,7 +344,7 @@ export const useAppStore = create((set, get) => { const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path; if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) { set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') }); - return false; + return { kind: 'warning', reason: 'lengthExceeded' }; } // 备份当前注册表(保存前备份旧值,失败仅警告不中断) @@ -359,14 +366,24 @@ export const useAppStore = create((set, get) => { set({ isModified: false, isSaving: false, statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'), _savedSys: savedSys, _savedUser: savedUser }); - return true; + return { kind: 'success' }; } else { const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : ''; const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : ''; const parts = [sysErr, usrErr].filter(Boolean); - const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${parts.join('; ')}`; - set({ isSaving: false, statusMessage: msg }); - return false; + + const msg = sysOk ? `用户 PATH 保存失败: ${usrErr}` : userOk ? `系统 PATH 保存失败: ${sysErr}` : `保存失败: ${parts.join('; ')}`; + + 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 }; + } } }, diff --git a/tests/unit/app-store.test.ts b/tests/unit/app-store.test.ts index 6385773..36bcb92 100644 --- a/tests/unit/app-store.test.ts +++ b/tests/unit/app-store.test.ts @@ -239,19 +239,26 @@ describe('savePaths', () => { it('保存成功', async () => { mockedInvoke.mockResolvedValue(undefined); - await useAppStore.getState().savePaths(); + const result = await useAppStore.getState().savePaths(); + expect(result).toEqual({ kind: 'success' }); const s = useAppStore.getState(); expect(s.isSaving).toBe(false); expect(s.isModified).toBe(false); expect(s.statusMessage).toBe('保存成功'); }); - it('部分失败时报告具体 hive', async () => { + it('部分失败时报告具体 hive 并回读', async () => { mockedInvoke .mockResolvedValueOnce(undefined) // backup_registry .mockResolvedValueOnce(undefined) // save_system_paths - .mockRejectedValueOnce('权限不足'); // save_user_paths - await useAppStore.getState().savePaths(); + .mockRejectedValueOnce('权限不足') // save_user_paths + // 以下为 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(); expect(s.isSaving).toBe(false); expect(s.statusMessage).toContain('用户 PATH 保存失败'); @@ -268,8 +275,8 @@ describe('savePaths', () => { // 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true) const r2 = useAppStore.getState().savePaths(); - // 第二次调用同步返回 false(被守卫拦截) - await expect(r2).resolves.toBe(false); + // 第二次调用同步返回 blocked(被守卫拦截) + await expect(r2).resolves.toEqual({ kind: 'blocked' }); // 放行第一次调用的所有 invoke resolveAll!(undefined); diff --git a/tests/unit/import-export.test.ts b/tests/unit/import-export.test.ts index 4c96a28..aec495c 100644 --- a/tests/unit/import-export.test.ts +++ b/tests/unit/import-export.test.ts @@ -24,7 +24,7 @@ describe('exportToJson', () => { it('导出结构化 JSON', () => { const json = exportToJson(sampleData); 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.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)); diff --git a/tests/unit/use-app-actions.test.tsx b/tests/unit/use-app-actions.test.tsx index c349dab..a779123 100644 --- a/tests/unit/use-app-actions.test.tsx +++ b/tests/unit/use-app-actions.test.tsx @@ -208,21 +208,22 @@ describe('useAppActions', () => { it('handleSave 正常保存', async () => { mockedInvoke.mockResolvedValue(undefined); + vi.spyOn(useAppStore.getState(), 'savePaths').mockResolvedValue({ kind: 'success' }); const { useAppActions } = await import('@/hooks/use-app-actions'); const { result } = renderHook(() => useAppActions('system', dialogs)); await act(async () => { await result.current.handleSave(); }); - // invoke 被调用(backup + save_system + save_user + broadcast) - expect(mockedInvoke).toHaveBeenCalled(); + // savePaths is called + expect(useAppStore.getState().savePaths).toHaveBeenCalled(); }); it('handleSave 超长确认后强制保存', async () => { - // 第一次 savePaths 返回 false(超长) - // 第二次(force=true)返回 true + // 第一次 savePaths 返回 warning(超长) + // 第二次(force=true)返回 success let callCount = 0; vi.spyOn(useAppStore.getState(), 'savePaths').mockImplementation(async (force?: boolean) => { callCount++; - if (!force) return false; // 第一次:超长警告 - return true; // 第二次:强制保存成功 + if (!force) return { kind: 'warning', reason: 'lengthExceeded' }; // 第一次:超长警告 + return { kind: 'success' }; // 第二次:强制保存成功 }); const { useAppActions } = await import('@/hooks/use-app-actions'); const { result } = renderHook(() => useAppActions('system', dialogs)); @@ -230,4 +231,17 @@ describe('useAppActions', () => { expect(callCount).toBe(2); 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(); + }); });