diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fac25b82a..b3f837580 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -233,7 +233,7 @@ describe('Store', () => { const store = await createStore() const ui = store.getUI() expect(ui.sidebarWidth).toBe(280) - expect(ui.groupBy).toBe('repo') + expect(ui.groupBy).toBe('workspace-status') expect(ui.lastActiveRepoId).toBeNull() expect(ui.dismissedUpdateVersion).toBeNull() expect(ui.lastUpdateCheckAt).toBeNull() @@ -1116,7 +1116,7 @@ describe('Store', () => { store.updateUI({ sidebarWidth: 400 }) const ui = store.getUI() expect(ui.sidebarWidth).toBe(400) - expect(ui.groupBy).toBe('repo') // default preserved + expect(ui.groupBy).toBe('workspace-status') // default preserved expect(ui.dismissedUpdateVersion).toBeNull() }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index a70e80bd0..dbd736b22 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { + ALL_GROUP_META, buildRows, getGroupKeyForWorktree, getLineageGroupKey, @@ -55,8 +56,8 @@ describe('getPRGroupKey', () => { }) describe('getGroupKeyForWorktree', () => { - it('returns no group key for the ungrouped mode', () => { - expect(getGroupKeyForWorktree('none', worktree, repoMap, null)).toBeNull() + it('returns the all group key for the ungrouped mode', () => { + expect(getGroupKeyForWorktree('none', worktree, repoMap, null)).toBe('all') }) it('returns a workspace-status key only in status grouping mode', () => { @@ -71,32 +72,46 @@ describe('buildRows with pinned worktrees', () => { const unpinned1 = { ...worktree, id: 'wt-1', displayName: 'alpha' } const unpinned2 = { ...worktree, id: 'wt-2', displayName: 'beta' } - it('emits a Pinned header followed by pinned items in groupBy none', () => { + it('emits Pinned and All headers in groupBy none', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned', count: 1 }) expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } }) + expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 }) + expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon }) }) - it('renders a flat list without status headers in groupBy none', () => { + it('groups all worktrees under All in groupBy none', () => { const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) expect(rows).toMatchObject([ + { type: 'header', key: 'all', label: 'All', count: 2 }, { type: 'item', worktree: { id: 'wt-1' } }, { type: 'item', worktree: { id: 'wt-2' } } ]) }) - it('keeps pinned worktrees above the flat list', () => { + it('keeps pinned worktrees above the All group', () => { const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set()) expect(rows).toMatchObject([ { type: 'header', key: 'pinned', count: 1 }, { type: 'item', worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'all', count: 2 }, { type: 'item', worktree: { id: 'wt-1' } }, { type: 'item', worktree: { id: 'wt-2' } } ]) }) + it('collapses the All group in groupBy none', () => { + const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all'])) + + expect(rows).toMatchObject([ + { type: 'header', key: 'pinned', count: 1 }, + { type: 'item', worktree: { id: 'wt-pinned' } }, + { type: 'header', key: 'all', count: 2 } + ]) + }) + it('emits status headers for unpinned worktrees in groupBy workspace-status', () => { const rows = buildRows( 'workspace-status', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index b5def7bf5..ceeed11ab 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */ -import { CircleX, Folder, Pin } from 'lucide-react' +import { CircleX, Folder, List, Pin } from 'lucide-react' import type React from 'react' import type { Repo, @@ -105,6 +105,14 @@ export const PINNED_GROUP_META = { icon: Pin } as const +export const ALL_GROUP_KEY = 'all' + +export const ALL_GROUP_META = { + label: 'All', + tone: 'text-foreground', + icon: List +} as const + export const MISSING_PARENT_GROUP_META = { label: 'Missing parent' } as const @@ -373,11 +381,23 @@ export function buildRows( const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees if (groupBy === 'none') { - appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, { - nestLineage, - showLineageContext: nestLineage, - collapsedGroups - }) + if (unpinned.length > 0) { + result.push({ + type: 'header', + key: ALL_GROUP_KEY, + label: ALL_GROUP_META.label, + count: unpinned.length, + tone: ALL_GROUP_META.tone, + icon: ALL_GROUP_META.icon + }) + if (!collapsedGroups.has(ALL_GROUP_KEY)) { + appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, { + nestLineage, + showLineageContext: nestLineage, + collapsedGroups + }) + } + } return result } @@ -513,7 +533,7 @@ export function getGroupKeyForWorktree( workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses() ): string | null { if (groupBy === 'none') { - return null + return ALL_GROUP_KEY } if (groupBy === 'workspace-status') { return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) diff --git a/src/renderer/src/lib/startup-ui-hydration.test.ts b/src/renderer/src/lib/startup-ui-hydration.test.ts index fd035ed93..6f2bee86b 100644 --- a/src/renderer/src/lib/startup-ui-hydration.test.ts +++ b/src/renderer/src/lib/startup-ui-hydration.test.ts @@ -52,7 +52,7 @@ describe('startup UI hydration fallback', () => { expect(hydratePersistedUI).toHaveBeenCalledTimes(1) expect(hydratePersistedUI.mock.calls[0][0].sidebarWidth).toBe(280) - expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('repo') + expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('workspace-status') expect(hydratePersistedUI.mock.calls[0][0].sortBy).toBe('name') }) diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 716328785..1906da610 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -34,7 +34,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState lastActiveWorktreeId: null, sidebarWidth: 280, rightSidebarWidth: 350, - groupBy: 'repo', + groupBy: 'workspace-status', sortBy: 'name', showActiveOnly: false, hideDefaultBranchWorkspace: false, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 000585a7c..fc60260e0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -770,7 +770,7 @@ export const createUISlice: StateCreator = (set, get) return { trustedOrcaHooks: next } }), - groupBy: 'repo', + groupBy: 'workspace-status', // Why: group keys are mode-specific (e.g. repo id vs PR status), so // collapsed state from one mode is meaningless in another. Clearing // also prevents unbounded accumulation of stale keys across mode switches. diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a39fb4bbb..f7bc13c8a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -338,7 +338,7 @@ export function getDefaultUIState(): PersistedUIState { lastActiveWorktreeId: null, sidebarWidth: 280, rightSidebarWidth: 350, - groupBy: 'repo', + groupBy: 'workspace-status', sortBy: 'recent', showActiveOnly: false, hideDefaultBranchWorkspace: false,