diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts new file mode 100644 index 00000000000..c91e4e0d803 --- /dev/null +++ b/src/renderer/src/store/slices/ui-hydration-workspace-cleanup-browse.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' +import { createUIStore, makePersistedUI } from './ui-slice-test-harness' + +function persistedWithIdleDays(idleMinDays: number | null) { + const browse = createDefaultWorkspaceCleanupBrowseState() + browse.filters.activity.idleMinDays = idleMinDays + return makePersistedUI({ workspaceCleanup: { dismissals: {}, browse } }) +} + +describe('workspace cleanup browse hydration', () => { + it('restores persisted filters on startup', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI(persistedWithIdleDays(30), 'startup') + + expect(store.getState().workspaceCleanupBrowse.filters.activity.idleMinDays).toBe(30) + }) + + it('does not let a sync broadcast revert a filter the user just set', () => { + // The browse writer debounces 250ms, so any other ui.set inside that window + // broadcasts state that still holds the previous filters. Re-hydrating it here + // reverted the edit, and the pending debounce then persisted the revert. + const store = createUIStore() + store.getState().hydratePersistedUI(persistedWithIdleDays(30), 'startup') + + const edited = createDefaultWorkspaceCleanupBrowseState() + edited.filters.activity.idleMinDays = 45 + store.setState({ workspaceCleanupBrowse: edited }) + + store.getState().hydratePersistedUI(persistedWithIdleDays(30), 'sync') + + expect(store.getState().workspaceCleanupBrowse.filters.activity.idleMinDays).toBe(45) + }) + + it('does not let a sync broadcast revert a typed query', () => { + const store = createUIStore() + const edited = createDefaultWorkspaceCleanupBrowseState() + edited.filters.query = 'release' + store.setState({ workspaceCleanupBrowse: edited }) + + store.getState().hydratePersistedUI(persistedWithIdleDays(null), 'sync') + + expect(store.getState().workspaceCleanupBrowse.filters.query).toBe('release') + }) + + it('defaults an absent source to sync, so an unlabelled broadcast cannot revert either', () => { + const store = createUIStore() + const edited = createDefaultWorkspaceCleanupBrowseState() + edited.filters.activity.idleMinDays = 45 + store.setState({ workspaceCleanupBrowse: edited }) + + store.getState().hydratePersistedUI(persistedWithIdleDays(30)) + + expect(store.getState().workspaceCleanupBrowse.filters.activity.idleMinDays).toBe(45) + }) + + it('still hydrates dismissals on sync, which are main-owned and not edited here', () => { + const store = createUIStore() + const dismissal = { + worktreeId: 'repo-1::/repo/one', + dismissedAt: Date.now(), + fingerprint: 'fp-1', + classifierVersion: 2 + } + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceCleanup: { + dismissals: { [dismissal.worktreeId]: dismissal }, + browse: createDefaultWorkspaceCleanupBrowseState() + } + }), + 'sync' + ) + + expect(store.getState().workspaceCleanupDismissals[dismissal.worktreeId]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e89e990719d..7165119e6f0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -2625,7 +2625,15 @@ export const createUISlice: StateCreator = (set, get) // Why the normalizer rather than a cast: this blob is hand-editable and // may come from an older or newer build; it degrades field by field // instead of bricking the cleanup dialog. - workspaceCleanupBrowse: normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse), + // Why startup-only, like activeView below: the browse writer debounces 250ms, so + // any other ui.set inside that window broadcasts state still holding the previous + // filters. Re-hydrating it here reverted the edit, and the pending debounce then + // persisted the revert. Nothing else edits browse state (mobile has no cleanup + // consumer, the popout is a separate store), so filters are per-window. + workspaceCleanupBrowse: + source === 'startup' + ? normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse) + : s.workspaceCleanupBrowse, // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. activeView: source === 'startup'