diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d51cc3..963f4de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 🐛 Fixed +- Sidebar: preserve collapsed projects and Space groups across app restarts, while keeping their disclosure choices independent. (#411) - Terminal: route local desktop image paste to native Pi, Kimi, Codex, Claude Code, and OMP keybindings while preserving text paste. (#409) - Terminal: execute quick commands after session attachment, preserving one-time execution and cancelling pending input when its window is removed. (#408) - Workspace: keep Space archive and other operation overlays local to their target Space, preventing unrelated Space backgrounds from tinting window nodes. (#401) diff --git a/docs/architecture/PERSISTENCE.md b/docs/architecture/PERSISTENCE.md index 23424bb5..a861ff51 100644 --- a/docs/architecture/PERSISTENCE.md +++ b/docs/architecture/PERSISTENCE.md @@ -228,6 +228,14 @@ Flush 失败时必须记录并返回 degraded,不能静默当作 durable shutd ## Write Ownership +Sidebar project and Space-group disclosure preferences live in settings as +`sidebarCollapsedWorkspaceIds` and `sidebarCollapsedSpaceGroupIds`. The latter uses +the sidebar's workspace-scoped group identity (`workspaceId:groupId`). Only `true` +entries are retained; missing or invalid legacy values default to expanded. Sidebar +mounting, project switching, and temporary tree absence must not reset these records. +The existing app-state hydration, write scheduling, and quit flush own their durability; +the Sidebar does not maintain a second local persistence store. + - Workspace/app state:SQLite persistence store。 - Endpoint/mount registry:Worker topology store。 - Approved local roots:approved workspace store。 diff --git a/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx b/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx new file mode 100644 index 00000000..f76da535 --- /dev/null +++ b/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { DEFAULT_AGENT_SETTINGS } from '@contexts/settings/domain/agentSettings' +import { + DEFAULT_WORKSPACE_VIEWPORT, + type WorkspaceState, +} from '@contexts/workspace/presentation/renderer/types' +import { useAppStore } from '../store/useAppStore' +import { Sidebar } from './Sidebar' + +beforeEach(() => { + useAppStore.setState({ agentSettings: DEFAULT_AGENT_SETTINGS }) +}) + +it('retains project collapse after remount and saves a later explicit expansion', () => { + const workspace: WorkspaceState = { + id: 'project-a', + name: 'Project A', + path: '/tmp/project-a', + worktreesRoot: '', + nodes: [], + spaces: [ + { + id: 'space-a', + name: 'Space A', + directoryPath: '/tmp/project-a', + targetMountId: null, + nodeIds: [], + rect: null, + labelColor: null, + }, + ], + activeSpaceId: null, + spaceArchiveRecords: [], + viewport: DEFAULT_WORKSPACE_VIEWPORT, + isMinimapVisible: false, + } + const sidebar = ( + + ) + const first = render(sidebar) + const toggle = () => screen.getByTestId('workspace-item-toggle-project-a') + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('false') + first.unmount() + render(sidebar) + expect(toggle().getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('true') + expect(useAppStore.getState().agentSettings).toMatchObject({ sidebarCollapsedWorkspaceIds: {} }) +}) diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx index 013283b8..1a338e2e 100644 --- a/src/app/renderer/shell/components/Sidebar.spec.tsx +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -7,6 +7,8 @@ import { type WorkspaceState, } from '@contexts/workspace/presentation/renderer/types' import { Sidebar } from './Sidebar' +import { useAppStore } from '../store/useAppStore' +import { DEFAULT_AGENT_SETTINGS } from '@contexts/settings/domain/agentSettings' const dndState = vi.hoisted(() => ({ draggingId: null as string | null, onDragStart: null as ((event: { active: { id: string } }) => void) | null, @@ -179,9 +181,9 @@ function createWorkspace( spaceArchiveRecords: [], } } - describe('Sidebar', () => { beforeEach(() => { + useAppStore.setState({ agentSettings: DEFAULT_AGENT_SETTINGS }) dndState.draggingId = null dndState.onDragStart = null dndState.onDragEnd = null diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index e8730736..9318668f 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -30,6 +30,8 @@ import { type SidebarDragItemData, } from './SidebarDnd' import { useSidebarListScroll } from './useSidebarListScroll' +import { useAppStore } from '../store/useAppStore' +import { toggleSidebarCollapsedId } from '@contexts/settings/domain/sidebarTreeSettings' export type SidebarVariant = 'docked' | 'rail' | 'peek' type SidebarTransition = 'collapsing' | 'expanding' | null @@ -94,8 +96,13 @@ export function Sidebar({ }), ) const [activeDragItem, setActiveDragItem] = useState(null) - const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = useState>({}) - const [collapsedSpaceGroupIds, setCollapsedSpaceGroupIds] = useState>({}) + const collapsedWorkspaceIds = useAppStore( + state => state.agentSettings.sidebarCollapsedWorkspaceIds, + ) + const collapsedSpaceGroupIds = useAppStore( + state => state.agentSettings.sidebarCollapsedSpaceGroupIds, + ) + const setAgentSettings = useAppStore(state => state.setAgentSettings) const [sidebarTransition, setSidebarTransition] = useState(null) const previousVariantRef = useRef(variant) const transitionTimeoutRef = useRef(null) @@ -184,19 +191,31 @@ export function Sidebar({ ], ) - const handleToggleProject = useCallback((workspaceId: string): void => { - setCollapsedWorkspaceIds(prev => ({ - ...prev, - [workspaceId]: prev[workspaceId] !== true, - })) - }, []) + const handleToggleProject = useCallback( + (workspaceId: string): void => { + setAgentSettings(prev => ({ + ...prev, + sidebarCollapsedWorkspaceIds: toggleSidebarCollapsedId( + prev.sidebarCollapsedWorkspaceIds, + workspaceId, + ), + })) + }, + [setAgentSettings], + ) - const handleToggleSpaceGroup = useCallback((groupKey: string): void => { - setCollapsedSpaceGroupIds(prev => ({ - ...prev, - [groupKey]: prev[groupKey] !== true, - })) - }, []) + const handleToggleSpaceGroup = useCallback( + (groupKey: string): void => { + setAgentSettings(prev => ({ + ...prev, + sidebarCollapsedSpaceGroupIds: toggleSidebarCollapsedId( + prev.sidebarCollapsedSpaceGroupIds, + groupKey, + ), + })) + }, + [setAgentSettings], + ) const activeDragData = activeDragItem?.data ?? null const activeDragWorkspaceId = activeDragData?.workspaceId ?? null diff --git a/src/contexts/settings/domain/agentSettings.defaults.ts b/src/contexts/settings/domain/agentSettings.defaults.ts index 72365fa6..880b085d 100644 --- a/src/contexts/settings/domain/agentSettings.defaults.ts +++ b/src/contexts/settings/domain/agentSettings.defaults.ts @@ -10,6 +10,8 @@ export const DEFAULT_AGENT_SETTINGS: AgentSettings = { language: DEFAULT_UI_LANGUAGE, uiTheme: 'dark', isPrimarySidebarCollapsed: false, + sidebarCollapsedWorkspaceIds: {}, + sidebarCollapsedSpaceGroupIds: {}, workspaceSearchPanelWidth: 420, defaultProvider: 'codex', agentProviderOrder: [...AGENT_PROVIDERS], diff --git a/src/contexts/settings/domain/agentSettings.ts b/src/contexts/settings/domain/agentSettings.ts index 26a0161a..e38dd245 100644 --- a/src/contexts/settings/domain/agentSettings.ts +++ b/src/contexts/settings/domain/agentSettings.ts @@ -12,6 +12,7 @@ import { type AgentProvider, } from './agentSettings.providers' import { normalizeFocusNodeTargetZoom } from './focusNodeTargetZoom' +import { normalizeSidebarCollapsedIds } from './sidebarTreeSettings' import { isValidUiLanguage, isValidUiTheme } from './uiSettings' import { isValidUpdateChannel, @@ -382,6 +383,10 @@ export function normalizeAgentSettings(value: unknown): AgentSettings { language, uiTheme, isPrimarySidebarCollapsed, + sidebarCollapsedWorkspaceIds: normalizeSidebarCollapsedIds(value.sidebarCollapsedWorkspaceIds), + sidebarCollapsedSpaceGroupIds: normalizeSidebarCollapsedIds( + value.sidebarCollapsedSpaceGroupIds, + ), workspaceSearchPanelWidth, defaultProvider, agentProviderOrder, diff --git a/src/contexts/settings/domain/agentSettings.types.ts b/src/contexts/settings/domain/agentSettings.types.ts index 9d65a273..bf568180 100644 --- a/src/contexts/settings/domain/agentSettings.types.ts +++ b/src/contexts/settings/domain/agentSettings.types.ts @@ -36,6 +36,8 @@ export interface AgentSettings { language: UiLanguage uiTheme: UiTheme isPrimarySidebarCollapsed: boolean + sidebarCollapsedWorkspaceIds: Record + sidebarCollapsedSpaceGroupIds: Record workspaceSearchPanelWidth: number defaultProvider: AgentProvider agentProviderOrder: AgentProvider[] diff --git a/src/contexts/settings/domain/sidebarTreeSettings.ts b/src/contexts/settings/domain/sidebarTreeSettings.ts new file mode 100644 index 00000000..84039c44 --- /dev/null +++ b/src/contexts/settings/domain/sidebarTreeSettings.ts @@ -0,0 +1,20 @@ +export function normalizeSidebarCollapsedIds(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + return Object.fromEntries( + Object.entries(value).filter(([id, collapsed]) => id.length > 0 && collapsed === true), + ) +} + +export function toggleSidebarCollapsedId( + previous: Record, + id: string, +): Record { + if (previous[id] !== true) { + return { ...previous, [id]: true } + } + const next = { ...previous } + delete next[id] + return next +} diff --git a/tests/e2e/sidebar-tree.persistence.spec.ts b/tests/e2e/sidebar-tree.persistence.spec.ts new file mode 100644 index 00000000..f45ddd01 --- /dev/null +++ b/tests/e2e/sidebar-tree.persistence.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type ElectronApplication, type Page } from '@playwright/test' +import { + createTestUserDataDir, + launchApp, + removePathWithRetry, + seedWorkspaceState, + testWorkspacePath, +} from './workspace-canvas.helpers' +import { createRailAgent } from './sidebar-test-fixtures' + +async function expectPersistedDisclosure(window: Page, collapsed: boolean): Promise { + await expect + .poll(async () => { + const raw = await window.evaluate(() => + window.opencoveApi.persistence.readWorkspaceStateRaw(), + ) + const state = raw ? JSON.parse(raw) : null + return { + projects: state?.settings?.sidebarCollapsedWorkspaceIds, + spaces: state?.settings?.sidebarCollapsedSpaceGroupIds, + } + }) + .toEqual({ + projects: collapsed ? { 'project-a': true } : {}, + spaces: collapsed ? { 'project-a:space-0': true } : {}, + }) +} + +async function quitApp(electronApp: ElectronApplication): Promise { + const appProcess = electronApp.process() + await electronApp.evaluate(({ app }) => app.quit()).catch(() => undefined) + await electronApp.close() + // The shared close helper bounds Worker cleanup; require actual process exit before reopening. + await expect.poll(() => appProcess.exitCode !== null || appProcess.signalCode !== null).toBe(true) +} + +test('restores project and Space group disclosure after two cold restarts', async ({ + browserName: _, +}, testInfo) => { + const userDataDir = await createTestUserDataDir() + let app: ElectronApplication | null = null + try { + let launched = await launchApp({ userDataDir, cleanupUserDataDir: false }) + app = launched.electronApp + let window = launched.window + await seedWorkspaceState(window, { + activeWorkspaceId: 'project-a', + settings: { standardWindowSizeBucket: 'regular' }, + workspaces: ['project-a', 'project-b'].map((id, index) => ({ + id, + name: id, + path: testWorkspacePath, + nodes: [ + { + ...createRailAgent( + `${id}-agent`, + `${id} agent`, + 400, + 'Test', + '2026-01-01T00:00:00.000Z', + ), + status: 'stopped' as const, + }, + ], + spaces: [ + { + id: `space-${index}`, + name: `Space ${index}`, + directoryPath: testWorkspacePath, + nodeIds: [`${id}-agent`], + rect: null, + }, + ], + })), + }) + const projectToggle = (id: string) => window.getByTestId(`workspace-item-toggle-${id}`) + const spaceToggle = () => + window.getByTestId('workspace-space-item-project-a-space-0').locator('button[aria-expanded]') + await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true') + await spaceToggle().click() + await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'false') + await projectToggle('project-a').click() + await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'false') + await expect(projectToggle('project-b')).toHaveAttribute('aria-expanded', 'true') + + await expectPersistedDisclosure(window, true) + await quitApp(app) + app = null + launched = await launchApp({ userDataDir, cleanupUserDataDir: false }) + app = launched.electronApp + window = launched.window + await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'false') + await expect(projectToggle('project-b')).toHaveAttribute('aria-expanded', 'true') + await projectToggle('project-a').click() + await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'false') + await expect(window.getByTestId('workspace-agent-item-project-a-project-a-agent')).toHaveCount( + 0, + ) + await testInfo.attach('restored-project-and-space-disclosure', { + body: await window.screenshot(), + contentType: 'image/png', + }) + await spaceToggle().click() + await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true') + + await expectPersistedDisclosure(window, false) + await quitApp(app) + app = null + launched = await launchApp({ userDataDir, cleanupUserDataDir: false }) + app = launched.electronApp + window = launched.window + await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'true') + await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true') + } finally { + await app?.close().catch(() => undefined) + await removePathWithRetry(userDataDir) + } +}) diff --git a/tests/unit/contexts/sidebarTreeSettings.spec.ts b/tests/unit/contexts/sidebarTreeSettings.spec.ts new file mode 100644 index 00000000..59814ed7 --- /dev/null +++ b/tests/unit/contexts/sidebarTreeSettings.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentSettings } from '../../../src/contexts/settings/domain/agentSettings' + +describe('sidebar tree settings recovery', () => { + it('preserves collapsed project and scoped Space identities across normalization', () => { + const settings = normalizeAgentSettings({ + sidebarCollapsedWorkspaceIds: { 'project-a': true, 'project-b': false }, + sidebarCollapsedSpaceGroupIds: { 'project-a:space-a': true }, + }) + expect(settings).toMatchObject({ + sidebarCollapsedWorkspaceIds: { 'project-a': true }, + sidebarCollapsedSpaceGroupIds: { 'project-a:space-a': true }, + }) + expect(normalizeAgentSettings(JSON.parse(JSON.stringify(settings)))).toEqual(settings) + }) + + it.each([ + undefined, + null, + {}, + { sidebarCollapsedWorkspaceIds: [], sidebarCollapsedSpaceGroupIds: 1 }, + ])('restores legacy or malformed settings with expanded trees: %j', input => { + expect(normalizeAgentSettings(input)).toMatchObject({ + sidebarCollapsedWorkspaceIds: {}, + sidebarCollapsedSpaceGroupIds: {}, + }) + }) + + it('rejects truthy non-boolean entries and preserves exact stable IDs', () => { + expect( + normalizeAgentSettings({ + sidebarCollapsedWorkspaceIds: { keep: true, no: false, invalid: 'true', '': true }, + sidebarCollapsedSpaceGroupIds: { 'project-a:root': true, 'project-b:root': false }, + }), + ).toMatchObject({ + sidebarCollapsedWorkspaceIds: { keep: true }, + sidebarCollapsedSpaceGroupIds: { 'project-a:root': true }, + }) + }) +})