diff --git a/config/scripts/check-workspace-record-read-ratchet.mjs b/config/scripts/check-workspace-record-read-ratchet.mjs new file mode 100644 index 00000000000..97b6c364a23 --- /dev/null +++ b/config/scripts/check-workspace-record-read-ratchet.mjs @@ -0,0 +1,105 @@ +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { readTrackedSources, runCountRatchet } from './ratchet-baseline.mjs' + +// Ratchet on renderer reads of the workspace record fields outside their selector module. The +// sidebar's records move to main's feed (sidebar design §2, §9), so a reader that touches the +// store field directly would silently keep reading the old copy. Counts may only shrink. +// A lower bound: destructuring and computed access are not seen; the compiler is the second +// guard once the fields leave AppState. Writers stay in the baseline +// until their cut moves them. + +const BASELINE_PATH = 'config/workspace-record-read-baseline.txt' +const SCAN_ROOTS = ['src/renderer/src/'] +// `x.worktreesByRepo`, `x?.folderWorkspaces`; not `...worktreesByRepo` or `window.api.folderWorkspaces`. +const RAW_READ = + /(? 0) { + counts.set(rel, count) + } + } + return counts +} + +export function main(root = process.cwd(), arg = undefined) { + return runCountRatchet( + { + name: 'workspace-record-read', + baselinePath: BASELINE_PATH, + unit: 'direct workspace record field read(s)', + header: [ + '# Renderer reads of `worktreesByRepo` / `folderWorkspaces` / `detectedWorktreesByRepo`', + '# outside src/renderer/src/store/workspace-record-selectors.ts, as ` # `.', + '# This is a RATCHET: counts may only go DOWN. Read through the selectors instead.', + '# `P5b-2 must clear` rows read store state through a loosely typed or optional field: once the', + '# fields leave the store they read undefined and silently fall back, so P5b-2 must move them.', + '# Prune after a fix: pnpm check:workspace-record-read-ratchet --prune' + ], + collect: collectWorkspaceRecordReads, + fix: [ + 'Read workspace records through src/renderer/src/store/workspace-record-selectors.ts:', + ' selectWorkspaceByHostTarget(state, target) the owner of a HostWorktreeTarget', + ' selectWorktreesByRepo / selectFolderWorkspaces / selectDetectedWorktreesByRepo', + ' compatibility views of the old fields' + ] + }, + root, + arg + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main(process.cwd(), process.argv[2])) +} diff --git a/config/scripts/check-workspace-record-read-ratchet.test.mjs b/config/scripts/check-workspace-record-read-ratchet.test.mjs new file mode 100644 index 00000000000..70034e3dbac --- /dev/null +++ b/config/scripts/check-workspace-record-read-ratchet.test.mjs @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { + countWorkspaceRecordReads, + isScannedFile, + SELECTOR_FILES +} from './check-workspace-record-read-ratchet.mjs' + +describe('countWorkspaceRecordReads', () => { + it('counts direct reads of the record fields', () => { + const text = [ + 'const rows = state.worktreesByRepo[repoId]', + 'useAppStore((s) => s.folderWorkspaces)', + 'const detected = get().detectedWorktreesByRepo', + 'const maybe = input?.worktreesByRepo ?? {}' + ].join('\n') + expect(countWorkspaceRecordReads(text)).toBe(4) + }) + + it('ignores the preload API, spreads, comments, strings and selector calls', () => { + const text = [ + 'await window.api.folderWorkspaces.update(args)', + 'const next = { ...worktreesByRepo, [repoId]: rows }', + '// state.worktreesByRepo used to be read here', + "t('sidebar.index.folderWorkspaces')", + "type T = AppState['detectedWorktreesByRepo']", + 'const rows = selectWorktreesByRepo(state)' + ].join('\n') + expect(countWorkspaceRecordReads(text)).toBe(0) + }) +}) + +describe('isScannedFile', () => { + it('scans renderer sources but not tests or the selector module', () => { + expect(isScannedFile('src/renderer/src/lib/foo.ts')).toBe(true) + expect(isScannedFile('src/renderer/src/components/Foo.tsx')).toBe(true) + expect(isScannedFile('src/renderer/src/lib/foo.test.ts')).toBe(false) + expect(isScannedFile('src/renderer/src/lib/foo.test-fixture.ts')).toBe(false) + for (const file of SELECTOR_FILES.keys()) { + expect(isScannedFile(file)).toBe(false) + } + }) +}) diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt new file mode 100644 index 00000000000..fe3e9e8c111 --- /dev/null +++ b/config/workspace-record-read-baseline.txt @@ -0,0 +1,84 @@ +# Renderer reads of `worktreesByRepo` / `folderWorkspaces` / `detectedWorktreesByRepo` +# outside src/renderer/src/store/workspace-record-selectors.ts, as ` # `. +# This is a RATCHET: counts may only go DOWN. Read through the selectors instead. +# `P5b-2 must clear` rows read store state through a loosely typed or optional field: once the +# fields leave the store they read undefined and silently fall back, so P5b-2 must move them. +# Prune after a fix: pnpm check:workspace-record-read-ratchet --prune + +3 src/renderer/src/attention/notification-subject-owner.ts # P5b-2 must clear: loosely typed state param +4 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent +3 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty +1 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty +1 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts # P5b-2 must clear: optional state field defaults to empty +4 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent +2 src/renderer/src/components/dashboard/useRetainedAgents.ts # reads a passed-in copy +6 src/renderer/src/components/editor/closed-editor-tab-controller.ts # P5b-2 must clear: identity compare of store slices passes when absent +1 src/renderer/src/components/editor/markdown-document-worktree-path-selector.ts # P5b-2 must clear: optional state field +1 src/renderer/src/components/feature-wall/feature-wall-setup-progress.ts # reads a passed-in copy +3 src/renderer/src/components/quick-open-file-list.ts # P5b-2 must clear: store read; file at the max-lines limit +2 src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts # P5b-2 must clear: optional state fields default to empty +1 src/renderer/src/components/sidebar/add-repo-skip-finalization.ts # P5b-2 must clear: loosely typed state param +1 src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts # reads a passed-in copy +1 src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts # reads a passed-in copy +3 src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts # reads a passed-in copy +2 src/renderer/src/components/sidebar/worktree-list/listing/use-window-list-model.ts # reads a passed-in copy +1 src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts # reads a passed-in copy +2 src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts # reads a passed-in copy +2 src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx # reads a passed-in copy +1 src/renderer/src/components/sidebar/worktree-manual-order-catalog.ts # reads a passed-in copy +2 src/renderer/src/components/skills/skill-install-workspace-choices.ts # reads a passed-in copy +1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts # P5b-2 must clear: session store snapshot +1 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts # P5b-2 must clear: loosely typed state param +1 src/renderer/src/components/workspace-space/space-window-state.ts # reads a passed-in copy +1 src/renderer/src/hooks/composer-state/composer-source-state.ts # P5b-2 must clear: composer store snapshot +2 src/renderer/src/hooks/composer-state/composer-target-state.ts # P5b-2 must clear: composer store snapshot +5 src/renderer/src/lib/agent-background-session-test-state.ts # test state builder +1 src/renderer/src/lib/connection-owner-resolution.ts # P5b-2 must clear: optional-chain compare of a previous slice +2 src/renderer/src/lib/direct-ssh-target-scope.ts # P5b-2 must clear: optional input fields default to empty +3 src/renderer/src/lib/duplicate-paired-servers.ts # P5b-2 must clear: optional state fields default to empty +1 src/renderer/src/lib/folder-workspace-runtime-owner.ts # P5b-2 must clear: loosely typed state param +1 src/renderer/src/lib/http-link-routing.ts # P5b-2 must clear: optional state field defaults to empty +2 src/renderer/src/lib/local-project-runtime-workspace.ts # P5b-2 must clear: optional state field defaults to empty +3 src/renderer/src/lib/resolve-owner.ts # P5b-2 must clear: optional state fields; a missing copy reads as no match +3 src/renderer/src/lib/resolved-worktree-execution-host.ts # P5b-2 must clear: loosely typed state param +2 src/renderer/src/lib/runtime-session-mirror-owners.ts # P5b-2 must clear: optional state fields default to empty +3 src/renderer/src/lib/sleeping-record-execution-host-scope.ts # P5b-2 must clear: loosely typed state param +4 src/renderer/src/lib/terminal-worktree-route.ts # P5b-2 must clear: fails open for spawn when the fields are undefined +3 src/renderer/src/lib/typing-latency/diagnostic-summary.ts # P5b-2 must clear: optional state field +2 src/renderer/src/lib/unread-badge-count.ts # reads a passed-in copy +1 src/renderer/src/lib/workspace-directory.ts # P5b-2 must clear: optional state field defaults to empty +3 src/renderer/src/lib/workspace-session-host-persistence.ts # P5b-2 must clear: loosely typed state param +1 src/renderer/src/lib/workspace-session.ts # P5b-2 must clear: store snapshot read; file at the max-lines limit +2 src/renderer/src/lib/workspace-terminal-host-authority.ts # P5b-2 must clear: optional state fields +1 src/renderer/src/lib/worktree-activation-pty-inventory.ts # P5b-2 must clear: loosely typed state param +1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts # P5b-2 must clear: treats an undefined field as a narrow adapter +2 src/renderer/src/lib/worktree-operation-catalog-route.ts # P5b-2 must clear: loosely typed state param +6 src/renderer/src/lib/worktree-operation-route.ts # P5b-2 must clear: optional state fields default to empty +2 src/renderer/src/lib/worktree-runtime-owner.ts # P5b-2 must clear: loosely typed state param +3 src/renderer/src/runtime/local-structured-session-tab-retirement.ts # P5b-2 must clear: optional state fields default to empty +2 src/renderer/src/runtime/sync-runtime-graph/sync-key.ts # reads a passed-in copy +3 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts # store writer +7 src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts # store writer +5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts # store writer +1 src/renderer/src/store/project-groups/project-group-removal-state.ts # store writer +4 src/renderer/src/store/repos/converted-ssh-host-rows.ts # store writer +5 src/renderer/src/store/repos/repo-catalog-merge.ts # store writer +5 src/renderer/src/store/repos/repo-removal.ts # store writer +6 src/renderer/src/store/slices/diff-comment-persistence.ts # store writer +12 src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts # store writer +2 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts # store writer +1 src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts # P5b-2 must clear: feeds an existing row-type cast +3 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts # store writer +13 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts # store writer +6 src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts # store writer +3 src/renderer/src/store/slices/worktrees/listing/worktree-sort-order-patch.ts # store writer +6 src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts # store writer +6 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts # store writer +8 src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts # store writer +3 src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts # store writer +1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts # store writer +6 src/renderer/src/store/slices/worktrees/session/worktree-activity-bump.ts # store writer +6 src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-row-removal.ts # store writer +8 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts # store writer +1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts # store writer +8 src/renderer/src/store/terminals/workspace-terminal-hydration.ts # store writer diff --git a/package.json b/package.json index 32b4ee27433..2ebef71f759 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:host-list-render-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:workspace-record-read-ratchet && pnpm run check:host-list-render-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -45,6 +45,7 @@ "check:unproven-pty-retirement-ratchet": "node config/scripts/check-unproven-pty-retirement-ratchet.mjs", "check:id-only-store-write-ratchet": "node config/scripts/check-id-only-store-write-ratchet.mjs", "check:repo-host-read-ratchet": "node config/scripts/check-repo-host-read-ratchet.mjs", + "check:workspace-record-read-ratchet": "node config/scripts/check-workspace-record-read-ratchet.mjs", "check:worktree-id-composer-ratchet": "node config/scripts/check-worktree-id-composer-ratchet.mjs", "check:e2e-typecheck-ratchet": "node config/scripts/check-e2e-typecheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", diff --git a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts index 07fdaa59d08..91c3cb05ba7 100644 --- a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts +++ b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts @@ -11,6 +11,7 @@ import { reviewRefreshIntervalMs, REVIEW_REFRESH_COOLDOWN_MS } from '../../../shared/review-refresh-policy' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function visibleReviewWorktreeIdsForState(state: AppState): string[] { const ids = new Set(state.visibleReviewCardWorktreeIds ?? []) @@ -109,7 +110,7 @@ export function createVisibleReviewReportIdentitySelector(): (state: AppState) = rightSidebarTab: state.rightSidebarTab, visibleReviewCardWorktreeIds: state.visibleReviewCardWorktreeIds, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), settings: state.settings, sshConnectionStates: state.sshConnectionStates, sshConnectedGeneration: state.sshConnectedGeneration, diff --git a/src/renderer/src/attention/hidden-workspace-notification-mute.ts b/src/renderer/src/attention/hidden-workspace-notification-mute.ts index 42c7eda9bd7..2ba622eb8ea 100644 --- a/src/renderer/src/attention/hidden-workspace-notification-mute.ts +++ b/src/renderer/src/attention/hidden-workspace-notification-mute.ts @@ -6,6 +6,7 @@ import { sidebarHasActiveFilters } from '@/components/sidebar/sidebar-filter-act import { computeVisibleWorktreeIds } from '@/components/sidebar/compute-visible-worktrees' import { buildVisibleWorktreeOptionsFromState } from '@/components/sidebar/visible-worktree-options-from-state' import { isFolderWorkspaceFromOtherDevice } from '../../../shared/workspace-creator-visibility' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** * Whether attention from this workspace must raise nothing — no unread, banner, sound or push — @@ -32,14 +33,14 @@ export function isWorkspaceNotificationMutedBySidebarFilters( const inputs = buildVisibleWorktreeOptionsFromState(state, repoMap) const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { - const folder = state.folderWorkspaces.find((item) => item.id === scope.folderWorkspaceId) + const folder = selectFolderWorkspaces(state).find((item) => item.id === scope.folderWorkspaceId) return ( folder !== undefined && inputs.hideWorkspacesFromOtherDevices && isFolderWorkspaceFromOtherDevice(folder, inputs.pairedDeviceIdsByEnvironment) ) } - const known = getIndexedAllWorktrees(state.worktreesByRepo).some( + const known = getIndexedAllWorktrees(selectWorktreesByRepo(state)).some( (worktree) => worktree.id === workspaceId && !worktree.isArchived ) if (!known) { @@ -47,7 +48,7 @@ export function isWorkspaceNotificationMutedBySidebarFilters( } // Why the full sidebar pass per call: measured at ~0.2-0.3 ms with 1,000 workspaces, and a // per-row shortcut would have to mirror every way the sidebar adds rows back. - const visibleIds = computeVisibleWorktreeIds(state.worktreesByRepo, [], { + const visibleIds = computeVisibleWorktreeIds(selectWorktreesByRepo(state), [], { ...inputs, filterRepoIds: [], workspaceHostScope: ALL_EXECUTION_HOSTS_SCOPE, diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index dc874807c12..5d84b175e1d 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -18,6 +18,7 @@ import { useLinearIssueWorkspaceDetail } from './linear-issue-workspace-detail-s import { LinearIssueWorkspaceHeader } from './linear-issue-workspace-header' import { LinearIssueWorkspaceSidebar } from './linear-issue-workspace-sidebar' import { defaultScopeSource } from '@/lib/default-creation-host' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type LinearIssueWorkspaceProps = { issue: LinearIssue | null @@ -52,7 +53,7 @@ function LinearIssueWorkspaceIssue({ const settings = useAppStore((state) => state.settings) const providerSettings = sourceContext ?? defaultScopeSource(settings) const allWorktrees = useAllWorktrees() - const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const attachmentWorkspaces = useMemo( () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], [allWorktrees, folderWorkspaces] diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index f1051444a7b..21469c4846b 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -16,6 +16,7 @@ import { type LinearLocalComment } from '@/components/linear-item-drawer-types' import { renderLinearItemDrawerSheet } from '@/components/linear-item-drawer-sheet' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' import { defaultScopeSource } from '@/lib/default-creation-host' export { LinearIssueEditSection } from '@/components/linear-item-drawer-edit-section' @@ -40,7 +41,7 @@ export default function LinearItemDrawer({ const settings = useAppStore((s) => s.settings) const providerSettings = sourceContext ?? defaultScopeSource(settings) const allWorktrees = useAllWorktrees() - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const attachmentWorkspaces = useMemo( () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], [allWorktrees, folderWorkspaces] diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 3621235aa2b..2cf9c3d2b38 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -22,6 +22,11 @@ import { } from './activity-terminal-portal-status' import type { ActivityTerminalPortalSlotId } from './activity-thread-types' import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export * from './activity-prototype-page-exports' @@ -260,19 +265,23 @@ export default function ActivityPrototypePage(): React.JSX.Element { setSelectedPaneKey }) + const worktreesByRepo = selectWorktreesByRepo(storeData) + const detectedWorktreesByRepo = selectDetectedWorktreesByRepo(storeData) + const folderWorkspaces = selectFolderWorkspaces(storeData) + const canJumpToWorkspace = useCallback( (thread: Parameters[0]) => hasActivityThreadWorkspace(thread, { - worktreesByRepo: storeData.worktreesByRepo, - detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, - folderWorkspaces: storeData.folderWorkspaces, + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ - storeData.worktreesByRepo, - storeData.detectedWorktreesByRepo, - storeData.folderWorkspaces, + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, storeData.floatingWorkspacePath, storeData.defaultHostId ] diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 5c587f8eed3..875ce1a3dbf 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -23,6 +23,11 @@ import { buildActivityThreadGroups, isActivitySearchQueryTooLarge } from './activity-thread-grouping' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' import type { ActivityGroupBy, ActivityThreadGroup, @@ -124,10 +129,10 @@ export function useAgentPaneThreads(args: { return projected })(), repos: s.repos, - worktreesByRepo: s.worktreesByRepo, - folderWorkspaces: s.folderWorkspaces, + worktreesByRepo: selectWorktreesByRepo(s), + folderWorkspaces: selectFolderWorkspaces(s), floatingWorkspacePath: s.floatingWorkspacePath, - detectedWorktreesByRepo: s.detectedWorktreesByRepo, + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(s), getKnownWorktreeById: s.getKnownWorktreeById, worktreeMap: getWorktreeMapFromState(s), repoMap: getRepoMapFromState(s), diff --git a/src/renderer/src/components/automations/use-automation-editor-actions.ts b/src/renderer/src/components/automations/use-automation-editor-actions.ts index a0c44035216..95e80b1df24 100644 --- a/src/renderer/src/components/automations/use-automation-editor-actions.ts +++ b/src/renderer/src/components/automations/use-automation-editor-actions.ts @@ -12,6 +12,7 @@ import type { AutomationsPageDestinationState } from './use-automations-page-des import type { AutomationsPageDestinationFormState } from './use-automations-page-destination-form' import type { AutomationsPageLocalState } from './use-automations-page-local-state' import type { AutomationsPageStoreState } from './use-automations-page-store-state' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Dialog open/edit actions, including host-captured re-reads and project changes. */ export function useAutomationEditorActions({ @@ -152,7 +153,7 @@ export function useAutomationEditorActions({ baseBranch: '' })) void fetchWorktrees(projectId, worktreeFetchOptions).then(() => { - const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? [] + const latestWorktrees = selectWorktreesByRepo(useAppStore.getState())[projectId] ?? [] const latestWorktree = getDefaultWorktree(latestWorktrees) if (!latestWorktree) { return diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.ts b/src/renderer/src/components/automations/use-automations-page-store-state.ts index b0737b064d3..d817789bf76 100644 --- a/src/renderer/src/components/automations/use-automations-page-store-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-store-state.ts @@ -11,6 +11,7 @@ import { automationWorktreeForRow, type AutomationListRow } from './automation-list-row-identity' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' const AGENTS = getAgentCatalog().map((agent) => agent.id) @@ -18,8 +19,8 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id) export function useAutomationsPageStoreState() { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index 1a727698c3d..158a836335d 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -9,6 +9,7 @@ import { runActiveNativeChatSplit } from '@/components/native-chat/native-chat-layout-actions' import type { NativeChatSplitDirection } from '@/components/native-chat/native-chat-split-shortcut' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type CmdJUnavailableReason = | 'loading' @@ -160,7 +161,7 @@ export function buildCmdJQuickActionContext(args: { }): CmdJQuickActionContext { const activeWorktreeId = args.state.activeWorktreeId const activeWorktree = activeWorktreeId - ? (findWorktreeById(args.state.worktreesByRepo, activeWorktreeId) ?? null) + ? (findWorktreeById(selectWorktreesByRepo(args.state), activeWorktreeId) ?? null) : null const activeGroupId = resolveCmdJActiveGroupId( args.state, @@ -168,7 +169,7 @@ export function buildCmdJQuickActionContext(args: { args.activeGroupSnapshot ) const isLoading = - args.state.repos.length > 0 && Object.keys(args.state.worktreesByRepo).length === 0 + args.state.repos.length > 0 && Object.keys(selectWorktreesByRepo(args.state)).length === 0 const runtimeMode = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ && args.state.settings?.activeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts index abdab81ebaa..e2b9e8f8703 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts @@ -20,6 +20,7 @@ import { startWorktreeAgentRowsCachePass, type WorktreeAgentRowsCache } from './worktree-agent-rows-cache' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' const EMPTY_COUNTS: Record = { attention: 0, @@ -76,7 +77,7 @@ function selectActiveDashboardWorkspaces( if ( memo && memo.repos === state.repos && - memo.worktreesByRepo === state.worktreesByRepo && + memo.worktreesByRepo === selectWorktreesByRepo(state) && memo.folderWorkspaces === state.folderWorkspaces && memo.projectGroups === state.projectGroups ) { @@ -86,7 +87,7 @@ function selectActiveDashboardWorkspaces( if (cache) { cache.activeWorkspaces = { repos: state.repos, - worktreesByRepo: state.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), folderWorkspaces: state.folderWorkspaces, projectGroups: state.projectGroups, workspaces diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts index 0d8f2a33d93..4b5662d672e 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -18,6 +18,7 @@ import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-w import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type ActiveDashboardWorkspace = { projectId: string @@ -91,7 +92,7 @@ export function collectActiveDashboardWorkspaces( } for (const repo of state.repos ?? []) { - for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { + for (const worktree of selectWorktreesByRepo(state)?.[repo.id] ?? []) { if (worktree.isArchived) { continue } diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts index 649b961a680..549b7a62f17 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -13,6 +13,7 @@ import { import type { TuiAgent } from '../../../../shared/tui-agent' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type DashboardLaunchDetectionState = Pick< AppState, @@ -39,7 +40,7 @@ function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): Dashboa groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), worktreesByRepoAndId: new Map( - Object.entries(state.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + Object.entries(selectWorktreesByRepo(state) ?? {}).map(([repoId, worktrees]) => [ repoId, new Map(worktrees.map((worktree) => [worktree.id, worktree])) ]) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 31d210ac3ff..96f7b737253 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -6,6 +6,7 @@ import { buildDashboardBucketCounts, createDashboardBucketCountsCache } from './build-dashboard-bucket-counts' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type AgentBucketCounts = Record @@ -51,7 +52,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState if ( previous !== null && previous.repos === s.repos && - previous.worktreesByRepo === s.worktreesByRepo && + selectWorktreesByRepo(previous) === selectWorktreesByRepo(s) && previous.tabsByWorktree === s.tabsByWorktree && previous.unifiedTabsByWorktree === s.unifiedTabsByWorktree && previous.agentStatusByPaneKey === s.agentStatusByPaneKey && @@ -62,7 +63,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState previous.ptyIdsByTabId === s.ptyIdsByTabId && previous.runtimePaneTitlesByTabId === s.runtimePaneTitlesByTabId && previous.paneForegroundAgentByPaneKey === s.paneForegroundAgentByPaneKey && - previous.folderWorkspaces === s.folderWorkspaces && + selectFolderWorkspaces(previous) === selectFolderWorkspaces(s) && previous.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey && previous.agentStatusEpoch === s.agentStatusEpoch ) { @@ -70,7 +71,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState } previousState = { repos: s.repos, - worktreesByRepo: s.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(s), tabsByWorktree: s.tabsByWorktree, unifiedTabsByWorktree: s.unifiedTabsByWorktree, agentStatusByPaneKey: s.agentStatusByPaneKey, @@ -81,7 +82,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey, - folderWorkspaces: s.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(s), acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch, settings: null diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 98bc30b7c4a..d768e9777f5 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -6,6 +6,7 @@ import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' import { launchDashboardAgent } from './launch-dashboard-agent' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree @@ -45,7 +46,7 @@ export function dashboardSnapshotInputsChanged( ): boolean { return ( state.repos !== previousState.repos || - state.worktreesByRepo !== previousState.worktreesByRepo || + selectWorktreesByRepo(state) !== selectWorktreesByRepo(previousState) || state.tabsByWorktree !== previousState.tabsByWorktree || state.unifiedTabsByWorktree !== previousState.unifiedTabsByWorktree || state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 52270167ef9..da3e8c6e1fa 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -3,6 +3,11 @@ import { useAppStore } from '@/store' import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { buildDashboardSnapshot } from './build-dashboard-snapshot' import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' /** * Builds the dashboard snapshot directly from the live renderer store for the @@ -14,7 +19,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const rowsCacheRef = useRef>(undefined!) rowsCacheRef.current ??= createWorktreeAgentRowsCache() const repos = useAppStore((s) => s.repos) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) @@ -40,10 +45,10 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { // bytes for the host the pty used to run on. All are low-frequency (each // writer bails out when nothing changed) next to agentStatusByPaneKey, which // already rebuilds this memo on every status ping. - const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const detectedWorktreesByRepo = useAppStore((s) => selectDetectedWorktreesByRepo(s)) // Why: a folder workspace is not a git worktree — its host resolves through // these two instead of worktreesByRepo. - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const projectGroups = useAppStore((s) => s.projectGroups) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index a741d6aa2a1..e4f08eb417f 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -21,6 +21,8 @@ import { createWorktreeTabBucketProjection, type WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' + // Why: when an agent finishes or its terminal closes, the store cleans up the // explicit status entry and the agent vanishes from the live status set. // Retaining the last-known "done" snapshot in the store lets the inline @@ -150,8 +152,8 @@ export function useRetainedAgentsSync(): void { (s) => [ s.repos, - s.worktreesByRepo, - s.folderWorkspaces, + selectWorktreesByRepo(s), + selectFolderWorkspaces(s), tabTopologyProjectionRef.current!.project(s.tabsByWorktree), s.agentStatusEpoch ] as const @@ -163,8 +165,8 @@ export function useRetainedAgentsSync(): void { const state = useAppStore.getState() const { currentAgents, existingWorktreeIds, tabIndex } = buildRetainedAgentsSyncSnapshot({ repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - folderWorkspaces: state.folderWorkspaces, + worktreesByRepo: selectWorktreesByRepo(state), + folderWorkspaces: selectFolderWorkspaces(state), tabsByWorktree: state.tabsByWorktree, agentStatusByPaneKey: state.agentStatusByPaneKey, now: Date.now() diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index 7032b21477e..15bf685e78e 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -12,6 +12,7 @@ import { shouldShowEditorPanelHeader } from './editor-header' import { getUntitledFileRoot } from './untitled-file-rename-path' import { translate } from '@/i18n/i18n' import type { ArtifactWriteRequest } from '../../../../shared/artifacts' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type EditorPanelRenderModel = ReturnType @@ -170,7 +171,7 @@ export function EditorPanelShell({ ? getUntitledFileRoot( renameDialogFile, findWorktreeById( - useAppStore.getState().worktreesByRepo, + selectWorktreesByRepo(useAppStore.getState()), renameDialogFile.worktreeId )?.path ) diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index e5283b7cb85..478ef725fca 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -30,6 +30,7 @@ import { import { useIpynbCellExecution } from './useIpynbCellExecution' import { resolveNotebookExecutionHost } from './ipynb-execution-host' import { useIpynbScrollRestoration } from './useIpynbScrollRestoration' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type IpynbViewerProps = { content: string @@ -53,7 +54,9 @@ export default function IpynbViewer({ onSave }: IpynbViewerProps): React.JSX.Element { const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) - const rootPath = useAppStore((s) => findWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null) + const rootPath = useAppStore( + (s) => findWorktreeById(selectWorktreesByRepo(s), worktreeId)?.path ?? null + ) const executionHost = useAppStore((s) => resolveNotebookExecutionHost(s, worktreeId)) const [editingCellKey, setEditingCellKey] = useState(null) const parsed = useMemo(() => { diff --git a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx index 30824fcd81d..addf87b4094 100644 --- a/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx +++ b/src/renderer/src/components/editor/MonacoGutterContextMenu.tsx @@ -13,6 +13,7 @@ import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getRuntimeGitRemoteFileUrl } from '@/runtime/runtime-git-working-tree-client' import { formatPathLineReference } from './line-copy-path' import { translate } from '@/i18n/i18n' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type MonacoGutterContextMenuProps = { open: boolean @@ -69,7 +70,7 @@ export function MonacoGutterContextMenu({ if (!activeFile) { return } - const worktree = findWorktreeById(state.worktreesByRepo, activeFile.worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(state), activeFile.worktreeId) if (!worktree) { return } diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts index afcb3600efa..2f4c71a8367 100644 --- a/src/renderer/src/components/editor/check-annotation-open.ts +++ b/src/renderer/src/components/editor/check-annotation-open.ts @@ -6,6 +6,7 @@ import { getOpenableAnnotationLine, resolveAnnotationPathInsideWorktree } from './check-annotation-path' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export { getOpenableAnnotationLine } @@ -18,7 +19,7 @@ export function openAnnotationLocation(params: { }): void { const { worktreeId, path, line, revealRafRef, revealInnerRafRef } = params const store = useAppStore.getState() - const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(store), worktreeId) if (!worktree) { return } diff --git a/src/renderer/src/components/editor/check-run-details-fix-context.ts b/src/renderer/src/components/editor/check-run-details-fix-context.ts index 64c740c7764..f8c9b770bc1 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-context.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-context.ts @@ -14,6 +14,7 @@ import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { isGitHubPRSuppressed } from '../../../../shared/worktree/github-pr-suppression' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function resolveCheckRunDetailsFixCheck( check: PRCheckDetail, @@ -40,7 +41,7 @@ export function resolveHostedReviewForCheckRunDetailsFix( worktreeId: string ): HostedReviewInfo | null { const store = useAppStore.getState() - const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(store), worktreeId) if (!worktree) { return null } @@ -121,7 +122,7 @@ export function getCheckRunDetailsFixDisabledReason(worktreeId: string | null): ) } const store = useAppStore.getState() - const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(store), worktreeId) if (!worktree) { return translate( 'auto.components.editor.check.run.details.fix.with.ai.1a8c4e2b90', @@ -149,7 +150,7 @@ export function resolveCheckRunDetailsFixRepo(worktreeId: string | null): Repo | return null } const store = useAppStore.getState() - const worktree = findWorktreeById(store.worktreesByRepo, worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(store), worktreeId) if (!worktree) { return null } diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts index 0fa2c9b67f5..d2257a15750 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -26,6 +26,7 @@ import type { } from '../../../../shared/source-control-ai-actions' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { translate } from '@/i18n/i18n' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export { buildCheckRunDetailsFixBasePrompt, @@ -130,7 +131,7 @@ export function useCheckRunDetailsFixWithAI(args: { if (!args.worktreeId) { return null } - return findWorktreeById(useAppStore.getState().worktreesByRepo, args.worktreeId) + return findWorktreeById(selectWorktreesByRepo(useAppStore.getState()), args.worktreeId) }, [args.worktreeId]) const canFixWithAI = isCheckRunDetailsFixCandidate(args.check, args.details) const disabledReason = getCheckRunDetailsFixDisabledReason(args.worktreeId) diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts index 64a61e4b79d..067d6bc6881 100644 --- a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts @@ -21,6 +21,7 @@ import type { DiffSection } from '../../diff-section-types' import type { DiffSectionItemProps } from '../../diff-section-item-props' import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature' import { writeEditorSave } from '../../editor-save-disk-precondition' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type CombinedDiffSectionActions = { handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef'] @@ -191,7 +192,7 @@ export function useCombinedDiffSectionActions({ try { const state = useAppStore.getState() const worktree = file.worktreeId - ? findWorktreeById(state.worktreesByRepo, file.worktreeId) + ? findWorktreeById(selectWorktreesByRepo(state), file.worktreeId) : null const writeResult = await writeEditorSave( getEditorFileOperationContext( diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index 38abffeac12..37730bae04e 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -35,6 +35,7 @@ import { type EditorSaveResult, type EditorSaveTrigger } from './editor-save-disk-precondition' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type AppStoreApi = Pick, 'getState' | 'subscribe'> @@ -159,7 +160,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { return 'failed' } const worktree = liveFile.worktreeId - ? findWorktreeById(state.worktreesByRepo ?? {}, liveFile.worktreeId) + ? findWorktreeById(selectWorktreesByRepo(state) ?? {}, liveFile.worktreeId) : null const fileContext = getEditorFileOperationContext(state, liveFile, worktree?.path ?? null) const connectionId = fileContext.connectionId diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 91dbd276316..2839a8afd4a 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -15,6 +15,7 @@ import { buildRichMarkdownImageInsertContent } from './rich-markdown-image-inser import { showRichMarkdownImageInsertionCanceled } from './rich-markdown-image-insertion-feedback' import type { RichMarkdownImageInsertionRange } from './rich-markdown-image-insertion-target' import { richMarkdownClipboardInsertionOrderKey } from './rich-markdown-clipboard-insertion-order' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type RichMarkdownImageInsertArgs = { editor: Editor @@ -140,11 +141,11 @@ function getWorktreePath(worktreeId: string | null): string | null { const parsedWorkspaceKey = parseWorkspaceKey(worktreeId) if (parsedWorkspaceKey?.type === 'folder') { return ( - state.folderWorkspaces.find( + selectFolderWorkspaces(state).find( (workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId )?.folderPath ?? null ) } - const worktrees = Object.values(state.worktreesByRepo ?? {}).flat() + const worktrees = Object.values(selectWorktreesByRepo(state) ?? {}).flat() return worktrees.find((worktree) => worktree.id === worktreeId)?.path ?? null } diff --git a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts index 480c78b4930..916b71f1268 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts @@ -16,6 +16,7 @@ import { } from './markdown-preview-source-routing' import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function useMarkdownPreviewSourceFoundation({ content, @@ -65,7 +66,7 @@ export function useMarkdownPreviewSourceFoundation({ const updateDiffComment = useAppStore((s) => s.updateDiffComment) const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments) const keybindings = useAppStore((s) => s.keybindings) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const sourceOpenFile = useAppStore((s) => findMarkdownPreviewSourceOpenFile(s.openFiles, { sourceFileId, diff --git a/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts b/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts index 1cd89aec9cf..bb7c741c1b8 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSuperscriptLinkSetup.ts @@ -7,6 +7,7 @@ import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import type { AppState } from '@/store/types' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function resolveRichMarkdownWorktreeRoot( state: Pick, @@ -14,9 +15,10 @@ export function resolveRichMarkdownWorktreeRoot( ): string | null { const workspaceScope = parseWorkspaceKey(worktreeId) return workspaceScope?.type === 'folder' - ? (state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) - ?.folderPath ?? null) - : (getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null) + ? (selectFolderWorkspaces(state).find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + )?.folderPath ?? null) + : (getIndexedWorktreeById(selectWorktreesByRepo(state), worktreeId)?.path ?? null) } export function useRichMarkdownSuperscriptLinkSetup({ diff --git a/src/renderer/src/components/native-chat-resume-workspace-lookups.ts b/src/renderer/src/components/native-chat-resume-workspace-lookups.ts index f3bf30ecead..ab0a9d24d90 100644 --- a/src/renderer/src/components/native-chat-resume-workspace-lookups.ts +++ b/src/renderer/src/components/native-chat-resume-workspace-lookups.ts @@ -15,6 +15,7 @@ import { import type { ExecutionHostId } from '../../../shared/execution-host' import { useAppStore } from '../store' import type { ResumeWorkspaceGroup } from './native-chat-resume-on-restart-grouping' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' // Store lookups the resume tree makes for its workspaces, each on the workspace's own host. @@ -50,7 +51,7 @@ export function useRepoIdByWorkspace( export function useLineageAncestors( workspaces: readonly ResumeWorkspaceGroup[] ): (group: ResumeWorkspaceGroup) => readonly string[] { - const worktreesByRepo = useAppStore((store) => store.worktreesByRepo) + const worktreesByRepo = useAppStore((store) => selectWorktreesByRepo(store)) const worktreeLineageById = useAppStore((store) => store.worktreeLineageById) const ancestors = useMemo(() => { const state = { worktreesByRepo } diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index c7d17b6c7f8..be9b8141ec3 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -14,6 +14,11 @@ import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-wor import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' /** The transcript must not read until ownership and its path are both known. */ export type NativeChatImageRuntimeContext = RuntimeFileOperationArgs | null @@ -46,9 +51,9 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { return { settings: state.settings, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), + folderWorkspaces: selectFolderWorkspaces(state), floatingWorkspacePath: state.floatingWorkspacePath, structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, projectGroups: state.projectGroups, diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index ac1716f5a09..ecf51b95774 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -10,6 +10,11 @@ import { resolveNativeChatTabDirectoryResolution, type NativeChatTabDirectoryState } from './native-chat-tab-directory' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export type NativeChatSkillStateInputs = Pick< AppState, @@ -50,9 +55,9 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki // Why: the owner resolver needs the picked host and detected rows to tell twins apart. activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, activeWorktreeId: state.activeWorktreeId, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), floatingWorkspacePath: state.floatingWorkspacePath, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, projects: state.projects, repos: state.repos, @@ -61,7 +66,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) } } diff --git a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx index b090a2ae854..e50f797f9ba 100644 --- a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx +++ b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx @@ -43,6 +43,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { useRepoMap } from '@/store/selectors' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Single-line row: text-sm leading (20px) over py-2. Set as the row's explicit height. */ const NO_PARENT_ROW_HEIGHT = 36 @@ -96,7 +97,7 @@ function ComposerParentWorktreePickerImpl({ if (!value) { return null } - const parent = getIndexedWorktreeById(s.worktreesByRepo, value) + const parent = getIndexedWorktreeById(selectWorktreesByRepo(s), value) return parent && !parent.isArchived ? parent.displayName : null }) @@ -214,7 +215,7 @@ function ParentWorktreeCandidateList({ activeFolderWorkspaceId, onSelect }: ParentWorktreeCandidateListProps): React.JSX.Element { - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const repoMap = useRepoMap() const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 7d4684e0ea1..3cf05c68134 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -48,6 +48,7 @@ import { } from './use-ai-vault-session-keyboard-navigation' import { useAiVaultPanelHandle, type AiVaultPanelProps } from './ai-vault-panel-handle' import { aiVaultSearchHitHostLabels } from './ai-vault-search-hit-hosts' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export default function AiVaultPanel({ ref, @@ -70,10 +71,10 @@ export default function AiVaultPanel({ useShallow((state) => ({ projects: state.projects, settings: state.settings, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, repos: state.repos, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) })) ) const settings = useAppStore((s) => s.settings) diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index 5693de11724..5d6e21914e9 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -14,6 +14,7 @@ import { runLimitedParentPrChecksRefreshes } from './parent-pr-checks-refresh' import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type FolderWorkspacePrChecksPanelProps = { isVisible?: boolean @@ -24,10 +25,10 @@ export default function FolderWorkspacePrChecksPanel({ }: FolderWorkspacePrChecksPanelProps): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const repos = useAppStore((s) => s.repos) const settings = useAppStore((s) => s.settings) const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx index d69c677fef7..95688f2490f 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx @@ -9,14 +9,15 @@ import { translate } from '@/i18n/i18n' import type { Worktree } from '../../../../shared/worktree/types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { useState } from 'react' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const repos = useAppStore((s) => s.repos) const [collapsedLineageWorktreeIds, setCollapsedLineageWorktreeIds] = useState< ReadonlySet diff --git a/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.ts b/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.ts index a46c01586c2..b357b0fd98d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.ts @@ -9,6 +9,7 @@ import { buildAiVaultProjectContext } from './ai-vault-session-projects' import { selectAiVaultPanelWorkspace } from './ai-vault-panel-workspace' import type { AiVaultSessionListRequest } from './ai-vault-session-list-request' import { readAiVaultViewOptions } from './ai-vault-view-options-persistence' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** The request the panel sends by default while `workspaceId` is the active workspace, read from * the store through the same selectors and derivations the panel's hooks use. */ @@ -17,7 +18,7 @@ export function resolveAiVaultPanelSessionListRequest( workspaceId: string ): AiVaultSessionListRequest { const { worktree, repo } = selectAiVaultPanelWorkspace(state, { worktreeId: workspaceId }) - const allWorktrees = getIndexedAllWorktrees(state.worktreesByRepo) + const allWorktrees = getIndexedAllWorktrees(selectWorktreesByRepo(state)) const projectHostSetupProjection = getProjectHostSetupProjectionFromState(state) const { activeProjectKey } = buildAiVaultProjectContext({ repos: state.repos, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts index c4417ab2d0d..16733be300a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts @@ -12,6 +12,7 @@ import { isKnownAiVaultResumeWorkspaceTarget, type AiVaultSessionResumeTargetState } from './ai-vault-session-resume' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function resolveAiVaultTargetWorkspacePath( state: AiVaultSessionResumeTargetState, @@ -20,12 +21,12 @@ export function resolveAiVaultTargetWorkspacePath( const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { return ( - state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) + selectFolderWorkspaces(state).find((workspace) => workspace.id === scope.folderWorkspaceId) ?.folderPath ?? null ) } const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId - return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null + return findWorktreeById(selectWorktreesByRepo(state), worktreeId)?.path ?? null } export type AiVaultSessionLaunchTarget = diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts index 5b941719ad4..1d725f160cd 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -7,16 +7,17 @@ import { translate } from '@/i18n/i18n' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type AiVaultLogSession = Pick function worktreeStillExists(state: AppState, worktreeId: string): boolean { - if (findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)) { + if (findWorktreeById(selectWorktreesByRepo(state) ?? {}, worktreeId)) { return true } // Why: the AI Vault panel can be active inside a folder workspace, whose id is // keyed differently from repo worktrees. - return (state.folderWorkspaces ?? []).some( + return (selectFolderWorkspaces(state) ?? []).some( (workspace) => folderWorkspaceKey(workspace.id) === worktreeId ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 3cff2bc03f4..08bb3731225 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -20,6 +20,7 @@ import { resolveAiVaultSessionWorktreeInfo, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type AiVaultSessionResumeTargetState = Pick< AppState, @@ -171,13 +172,13 @@ export function isKnownAiVaultResumeWorkspaceTarget( const workspaceKey = parseWorkspaceKey(workspaceId) if (workspaceKey?.type === 'folder') { - return state.folderWorkspaces.some( + return selectFolderWorkspaces(state).some( (workspace) => workspace.id === workspaceKey.folderWorkspaceId ) } const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId - return getIndexedWorktreeMap(state.worktreesByRepo).has(worktreeId) + return getIndexedWorktreeMap(selectWorktreesByRepo(state)).has(worktreeId) } function resolveSupportedResumeWorktreeId(args: { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts index da91baae493..20f9d149c84 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts @@ -20,6 +20,7 @@ import { import { fileExplorerRefreshConcurrency } from './file-explorer-refresh-concurrency' import { createFileExplorerWatchRefreshScheduler } from './file-explorer-watch-refresh-scheduler' import { processFileExplorerFsPayload } from './file-explorer-watch-reconcile' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export { canonicalizeFileExplorerWatchPath, @@ -58,7 +59,7 @@ export function getFileExplorerWatchRuntimeEnvironmentId( activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId, settings: state.settings, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), detectedWorktreesByRepo: state.detectedWorktreesByRepo ?? {}, folderWorkspaces: state.folderWorkspaces ?? [], projectGroups: state.projectGroups ?? [], diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index b8c3b77bfd0..13ae069fc75 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -22,6 +22,7 @@ import { type GitStatusRefreshReason, type GitStatusRefreshScheduler } from './git-status-refresh-scheduler' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' const STATUS_SAFETY_INTERVAL_MS = 60_000 const STATUS_ACTIVITY_DEBOUNCE_MS = 125 @@ -295,7 +296,9 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const getRepoWorktreePathsForSignal = useCallback( (repoId: string) => - (useAppStore.getState().worktreesByRepo[repoId] ?? []).map((worktree) => worktree.path), + (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).map( + (worktree) => worktree.path + ), [] ) diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 17d1455423b..bbff96fe928 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -39,6 +39,7 @@ import { } from '../../../../shared/file-write-disk-precondition' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type McpConfigSectionProps = { repo: Repo @@ -58,7 +59,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const repos = useAppStore((state) => state.repos) const allWorktreesForRepo = useAppStore( - (state) => state.worktreesByRepo[repo.id] ?? EMPTY_WORKTREES + (state) => selectWorktreesByRepo(state)[repo.id] ?? EMPTY_WORKTREES ) // Why: a path only means something on the host that owns this repo row, not the focused one. const repoHostId = getRepoExecutionHostId(repo) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index a301dd021bb..a786c304010 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -30,6 +30,7 @@ import { getSetupGuideProgressReady, getSetupScriptProbeSignature } from './setup-guide-progress-readiness' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { readSetupScriptProbeCache, setSetupScriptProbeCache, @@ -45,7 +46,7 @@ export function useSetupGuideProgress( ): FeatureWallSetupProgress { const settings = useAppStore((s) => s.settings) const featureInteractions = useAppStore((s) => s.featureInteractions) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) diff --git a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx index e800ed8a16b..db7c1a07bf6 100644 --- a/src/renderer/src/components/sidebar/HostRemoveDialog.tsx +++ b/src/renderer/src/components/sidebar/HostRemoveDialog.tsx @@ -22,6 +22,7 @@ import type { HostRemovalTarget } from './host-rename-remove' import { resolveSshHostRemoval } from './ssh-host-remove-resolution' import { clearSshHostWorkspaces } from './ssh-host-remove-workspaces' import { removeWslHost } from './wsl-host-removal' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type HostRemoveDialogProps = { open: boolean @@ -48,7 +49,7 @@ export function HostRemoveDialog({ const mountedRef = useMountedRef() const repos = useAppStore((s) => s.repos) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) // Resolve which workspaces live on the target so the dialog can offer to diff --git a/src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.tsx b/src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.tsx index 7e233a19f7e..822132c36f3 100644 --- a/src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.tsx +++ b/src/renderer/src/components/sidebar/LocalGitToolchainScanBanner.tsx @@ -7,6 +7,7 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { findLocalToolchainBlock, type LocalToolchainFailureKind } from './repo-scan-failure' +import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' const FIXES: Record< LocalToolchainFailureKind, @@ -43,14 +44,15 @@ const FIXES: Record< /** One sidebar-level notice for local Git toolchain failures that block every local repo at once. */ export function LocalGitToolchainScanBanner(): React.JSX.Element | null { const repos = useAppStore((s) => s.repos) - const detectedByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const detectedByRepo = useAppStore((s) => selectDetectedWorktreesByRepo(s)) const block = findLocalToolchainBlock(repos, detectedByRepo) const [pending, setPending] = React.useState(false) // Why: reads the store at call time so the focus listener below never needs re-subscribing. const rescan = React.useCallback(async () => { const state = useAppStore.getState() - const blocked = findLocalToolchainBlock(state.repos, state.detectedWorktreesByRepo)?.repos ?? [] + const blocked = + findLocalToolchainBlock(state.repos, selectDetectedWorktreesByRepo(state))?.repos ?? [] if (blocked.length === 0) { return } diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 57d856f26b4..0801c7b4fa6 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -20,6 +20,7 @@ import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' import { worktreeRefreshOptions } from './add-repo-runtime-owner' import { addRuntimeSshRepo } from '@/runtime/runtime-ssh-target-management' import type { Repo } from '../../../../shared/repo-types' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' // Why: the SSH target is a paired server's own host, and only that server adds a project on it. async function addSshFolderProject(args: { @@ -100,11 +101,9 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { // dialog closes and the UI shows no visible change, making the // add feel like a no-op. Activating the synthetic folder // worktree reveals it in the sidebar and opens the workspace. - const folderWorktree = useAppStore - .getState() - .worktreesByRepo[repo.id]?.find( - (worktree) => worktree.hostId === ownerOptions.executionHostId - ) + const folderWorktree = selectWorktreesByRepo(useAppStore.getState())[repo.id]?.find( + (worktree) => worktree.hostId === ownerOptions.executionHostId + ) if (folderWorktree) { const onboarding = await window.api.onboarding.get().catch(() => null) // Why: SSH users can hit this dialog from Add Project after diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx index 25f10c73925..1676b189090 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isFolderRepo } from '../../../../shared/repo-kind' import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type ProjectAddedModalData = { repoId?: string @@ -80,7 +81,7 @@ export default function ProjectAddedDialog(): null { if (cancelled) { return } - const folderWorktree = useAppStore.getState().worktreesByRepo[repoId]?.[0] + const folderWorktree = selectWorktreesByRepo(useAppStore.getState())[repoId]?.[0] if (folderWorktree) { activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto' }) } diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 9e6c5d9cf4d..71766c1b009 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -17,6 +17,7 @@ import { } from '../../../../shared/execution-host' import { countOpenRepoTerminalTabs } from '@/store/repos/repo-removal' import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' // Why: interpolated into the sentence so locales control where the name sits; // U+0000 cannot appear in a real project name, so the split is unambiguous. @@ -58,7 +59,7 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const onRecipeVmServer = useAppStore( (s) => getRecipeVmSshTargetForHost(s.runtimeEnvironments, hostId) !== null || - (s.worktreesByRepo[repoId] ?? []).some( + (selectWorktreesByRepo(s)[repoId] ?? []).some( (worktree) => worktree.hostId === hostId && worktree.ephemeralVmCheckoutMode === 'provisioned-root' ) diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index 22114f5b150..0fd75004bac 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -10,6 +10,11 @@ import { ActivityThreadListPane } from '@/components/activity/activity-thread-li import { useAgentPaneThreads } from '@/components/activity/use-agent-pane-threads' import { ActivityThreadOptionsMenu } from '@/components/activity/activity-thread-controls' import type { ActivityGroupBy, ThreadReadFilter } from '@/components/activity/activity-thread-types' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' /** * The Activity thread list, hosted in the sidebar as a navigator: selecting a @@ -96,19 +101,23 @@ export default function SidebarAgentsList({ setSelectedPaneKey }) + const worktreesByRepo = selectWorktreesByRepo(storeData) + const detectedWorktreesByRepo = selectDetectedWorktreesByRepo(storeData) + const folderWorkspaces = selectFolderWorkspaces(storeData) + const canJumpToWorkspace = useCallback( (thread: Parameters[0]) => hasActivityThreadWorkspace(thread, { - worktreesByRepo: storeData.worktreesByRepo, - detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, - folderWorkspaces: storeData.folderWorkspaces, + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ - storeData.worktreesByRepo, - storeData.detectedWorktreesByRepo, - storeData.folderWorkspaces, + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, storeData.floatingWorkspacePath, storeData.defaultHostId ] diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 63e7880fa03..abcaf3c5e9d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -21,6 +21,7 @@ import { useWorkspaceKanbanNativeDrag } from './use-workspace-kanban-native-drag import { useWorkspaceKanbanRenderLifecycle } from './use-workspace-kanban-render-lifecycle' import { useWorkspaceKanbanDrawerLingering } from './use-workspace-kanban-drawer-lingering' import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties @@ -53,7 +54,7 @@ function WorkspaceKanbanDrawerContent({ onMenuOpenChange }: WorkspaceKanbanDrawerProps): React.JSX.Element { const allWorktrees = useAllWorktrees() - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const repoMap = useRepoMap() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 728cb5470fb..999cf7e5d14 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -35,6 +35,10 @@ import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-s import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations' import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate' import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces +} from '@/store/workspace-record-selectors' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -68,7 +72,7 @@ const WorktreeList = React.memo(function WorktreeList({ const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) - const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const detectedWorktreesByRepo = useAppStore((s) => selectDetectedWorktreesByRepo(s)) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) @@ -92,7 +96,7 @@ const WorktreeList = React.memo(function WorktreeList({ const collapsedGroups = useAppStore((s) => s.collapsedGroups) const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const settings = useAppStore((s) => s.settings) const cardProps = useAppStore((s) => s.worktreeCardProperties) const { prCache, hostedReviewCache } = useAppStore( diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 3eadb6ca0d4..e99a71a4966 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -57,6 +57,7 @@ import { } from './worktree-visibility-use-global' import { createWorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' +import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const activeModal = useAppStore((s) => s.activeModal) @@ -65,7 +66,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const repos = useAppStore((s) => s.repos) const updateRepo = useAppStore((s) => s.updateRepo) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) - const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const detectedWorktreesByRepo = useAppStore((s) => selectDetectedWorktreesByRepo(s)) const settings = useAppStore((s) => s.settings) const [actionState, setActionState] = useState(null) const [busyPath, setBusyPath] = useState(null) diff --git a/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts index 487b4c138b9..0185db478ad 100644 --- a/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts +++ b/src/renderer/src/components/sidebar/child-workspaces-toggle-target.ts @@ -18,6 +18,7 @@ import { getHostScopedWorktreeLineageInputs, getWorktreeLineageAncestors } from '../../../../shared/sidebar-list/worktree-lineage-projection' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type ChildWorkspacesToggleState = Pick< AppState, @@ -91,7 +92,7 @@ export function getRenderedLineageChipKeys(state: AppState): Set { ) } const visibleWorktrees = computeVisibleWorktrees( - state.worktreesByRepo, + selectWorktreesByRepo(state), [], buildVisibleWorktreeOptionsFromState(state, getRepoMapFromState(state)) ) diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.ts index 33ab7dadd07..a10b25a3c53 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.ts @@ -10,6 +10,10 @@ import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' type DefaultCheckoutHandoffReason = EventProps<'add_repo_default_checkout_handoff'>['reason'] @@ -91,7 +95,7 @@ async function revealDetectedHiddenLinkedExternalWorktrees( const state = useAppStore.getState() if ( !hasDetectedHiddenLinkedExternalWorktrees( - state.detectedWorktreesByRepo[repoId], + selectDetectedWorktreesByRepo(state)[repoId], executionHostId ) ) { @@ -124,7 +128,7 @@ async function findDetectedDefaultCheckout( reason: DefaultCheckoutHandoffReason }> { const state = useAppStore.getState() - const detected = state.detectedWorktreesByRepo[repoId] + const detected = selectDetectedWorktreesByRepo(state)[repoId] const detectedDefaultCheckout = getDetectedProjectDefaultCheckout(detected, executionHostId) if (!detectedDefaultCheckout) { return { @@ -155,7 +159,7 @@ async function findDetectedDefaultCheckout( } const worktree = getProjectDefaultCheckout( getProjectWorktreesForHost( - useAppStore.getState().worktreesByRepo[repoId] ?? [], + selectWorktreesByRepo(useAppStore.getState())[repoId] ?? [], executionHostId ) ) @@ -191,7 +195,7 @@ export async function openProjectDefaultCheckout({ }): Promise { let defaultCheckout = getProjectDefaultCheckout( getProjectWorktreesForHost( - useAppStore.getState().worktreesByRepo[repoId] ?? [], + selectWorktreesByRepo(useAppStore.getState())[repoId] ?? [], executionHostId ) ) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index 91f57770dfc..e372db680e7 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -30,6 +30,7 @@ import { filterProjectGroupsForVisibleHosts, getVisibleSidebarHostIdSet } from '../../../../shared/sidebar-list/host-filtering' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' const EMPTY_REPO_ID_SET: ReadonlySet = Object.freeze(new Set()) const EMPTY_IMPORTED_BY_REPO: ReadonlyMap = new Map() @@ -98,7 +99,7 @@ export function computeRenderedSidebarRows( pendingCreations: EMPTY_PENDING_CREATIONS, projectGrouping: { projects: projection.projects, projectHostSetups: projection.setups }, folderWorkspaces: filterFolderWorkspacesForVisibleHosts( - state.folderWorkspaces, + selectFolderWorkspaces(state), projectGroups, visibleHostIdSet, defaultHostId diff --git a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts index a7ec91232c0..11f0f27fbca 100644 --- a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts +++ b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts @@ -9,6 +9,7 @@ import { import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' import { finishProjectAddWithDefaultCheckout } from './project-added-default-checkout' import type { ExecutionHostId } from '../../../../shared/execution-host' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type CompleteGitRepoAddOptions = { closeModal: () => void @@ -40,7 +41,7 @@ export function useCompleteGitRepoAdd({ source: AddRepoExistingWorkspaceSource, executionHostId?: ExecutionHostId ): Promise => { - const worktrees = (useAppStore.getState().worktreesByRepo[repoId] ?? []).filter( + const worktrees = (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).filter( (worktree) => executionHostId === undefined || worktree.hostId === executionHostId || diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index f9f1acae5c7..7c0bedc05e6 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -13,6 +13,7 @@ import { } from '../../../../shared/workspace-creator-visibility' import { getStructuredChatWorktreeIds } from './visible-worktree-activity-inputs' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type UseVisibleWorkspaceKanbanWorktreeIdsParams = { allWorktrees: readonly Worktree[] @@ -27,7 +28,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, repoMap }: UseVisibleWorkspaceKanbanWorktreeIdsParams): ReadonlySet { - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) diff --git a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts index c18783be44d..d17488d9f36 100644 --- a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts +++ b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts @@ -9,6 +9,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import type { Worktree } from '../../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Resolves the edited workspace within its execution host, including folders. */ export function useWorktreeMetaWorkspace(args: { @@ -27,10 +28,10 @@ export function useWorktreeMetaWorkspace(args: { // reports as ambiguous rather than guessing. The row that opened the dialog // knows which one it is; the index stays the fallback for callers that cannot. const owner = executionHostId - ? findIndexedWorktreeOwnerForHost(s.worktreesByRepo, worktreeId, executionHostId) - : findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId) + ? findIndexedWorktreeOwnerForHost(selectWorktreesByRepo(s), worktreeId, executionHostId) + : findIndexedWorktreeOwner(selectWorktreesByRepo(s), worktreeId) return owner && (!ownerRepoId || owner.repoId === ownerRepoId) - ? s.worktreesByRepo[owner.repoId]?.find((item) => item === owner) + ? selectWorktreesByRepo(s)[owner.repoId]?.find((item) => item === owner) : undefined }) // Why: folder workspaces are absent from worktreesByRepo, so the lookup above @@ -42,7 +43,7 @@ export function useWorktreeMetaWorkspace(args: { if (workspaceScope?.type !== 'folder') { return null } - const candidates = s.folderWorkspaces.filter( + const candidates = selectFolderWorkspaces(s).filter( (item) => item.id === workspaceScope.folderWorkspaceId && (!executionHostId || getFolderWorkspaceHostId(item, s.projectGroups) === executionHostId) diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 2a870de153e..f1a4f3499b9 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -12,6 +12,7 @@ import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { resolveAddRepoRuntimeTarget, worktreeRefreshOptions } from './add-repo-runtime-owner' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -170,13 +171,11 @@ export function useCreateRepo( if (!isCurrent()) { return } - const folderWorktree = useAppStore - .getState() - .worktreesByRepo[repo.id]?.find( - (worktree) => - ownerOptions.executionHostId === undefined || - worktree.hostId === ownerOptions.executionHostId - ) + const folderWorktree = selectWorktreesByRepo(useAppStore.getState())[repo.id]?.find( + (worktree) => + ownerOptions.executionHostId === undefined || + worktree.hostId === ownerOptions.executionHostId + ) if (folderWorktree) { activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 3c08d9ad667..c67e83d0450 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -20,6 +20,7 @@ export { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' @@ -101,7 +102,7 @@ export function getVisibleWorktreeIds(): string[] { } const visibleIds = computeVisibleWorktreeIds( - state.worktreesByRepo, + selectWorktreesByRepo(state), sortedIds, buildVisibleWorktreeOptionsFromState(state, repoMap) ) diff --git a/src/renderer/src/components/sidebar/worktree-filter-visibility.ts b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts index 298021f246d..4df6a825645 100644 --- a/src/renderer/src/components/sidebar/worktree-filter-visibility.ts +++ b/src/renderer/src/components/sidebar/worktree-filter-visibility.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/execution-host' import { computeVisibleWorktrees } from './visible-worktrees' import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** * Filter-only visibility for one worktree id: runs the sidebar filter pipeline @@ -28,7 +29,7 @@ export function worktreePassesSidebarFilters( const requestedHostId = executionHostId ? normalizeExecutionHostId(executionHostId) : null const defaultHostId = getSettingsFocusedExecutionHostId(state.settings) return computeVisibleWorktrees( - state.worktreesByRepo, + selectWorktreesByRepo(state), [], buildVisibleWorktreeOptionsFromState(state, repoMap) ).some((worktree) => { diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts b/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts index 84c14207278..c45d7cae67f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/dragged-worktree-copies.ts @@ -10,6 +10,7 @@ import type { AppState } from '../../../../store/types' import type { WorktreeMetaBatchUpdate } from '../../../../store/slices/worktree-helpers' import { getAllWorktreesFromState } from '../../../../store/selectors' import { getWorkspaceStatus } from '../../workspace-status' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** The cards a drag picked up, by host: a twin on another host shares each card's id. */ export type DraggedWorktreeCopies = readonly Pick[] @@ -42,7 +43,7 @@ export function dropWriteRows( // Folder workspaces share the manual-order catalog, so a drop ranks them too. for (const row of [ ...getAllWorktreesFromState(state), - ...state.folderWorkspaces.map(folderWorkspaceToWorktree) + ...selectFolderWorkspaces(state).map(folderWorkspaceToWorktree) ]) { rowsById.set(row.id, [...(rowsById.get(row.id) ?? []), row]) } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts index 6f5065d8770..00a8934aa08 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts @@ -1,6 +1,7 @@ import type { AppState } from '@/store/types' import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types' import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' export type WorktreeListReviewCacheState = Pick< AppState, @@ -24,7 +25,7 @@ export function selectWorktreeListReviewCacheInputs( groupBy: WorktreeGroupBy, cardProperties: readonly WorktreeCardProperty[] ): WorktreeListReviewCacheInputs { - const hasFolderWorkspaces = state.folderWorkspaces.length > 0 + const hasFolderWorkspaces = selectFolderWorkspaces(state).length > 0 const newCardStyle = state.settings?.experimentalNewWorktreeCardStyle === true const folderCardsNeedReview = hasFolderWorkspaces && diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts index 04e92771c0e..682bec167db 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts @@ -17,6 +17,7 @@ import { type NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions' import { buildNewExternalWorktreesInboxCandidates } from '../../new-external-worktrees-inbox-candidates' +import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' // The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the // pending/error state their inline actions surface. @@ -123,7 +124,7 @@ export function useSidebarExternalWorktreeCards(args: { }) return } - detected = useAppStore.getState().detectedWorktreesByRepo[projectId] + detected = selectDetectedWorktreesByRepo(useAppStore.getState())[projectId] } if (detected?.authoritative !== true) { setImportedWorktreeCardState(projectId, { diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index ef0b895c95a..d93afb8c8ac 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -40,6 +40,7 @@ import { getVisibleWorktreeTerminalActivityTabs, getWorktreeIdsWithStructuredChat } from '../../visible-worktree-activity-inputs' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' export type SidebarWorktreeFilters = ReturnType @@ -131,7 +132,7 @@ function getRevealTargetHostId(state: AppState, worktree: Worktree, repo: Repo | const defaultHostId = getSettingsFocusedExecutionHostId(state.settings) const workspaceScope = parseWorkspaceKey(worktree.id) if (workspaceScope?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( + const folderWorkspace = selectFolderWorkspaces(state).find( (candidate) => candidate.id === workspaceScope.folderWorkspaceId ) if (folderWorkspace) { diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 5537b8e48b9..eb8cde40349 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -39,6 +39,7 @@ import { import type { ExecutionHostRegistryEntry } from '../../../../../../shared/execution-host-registry' import { buildSidebarHostOptions } from '../../sidebar-host-options' import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type SectionRowsArgs = { agentSendTargetWorktreeId?: string | null @@ -70,7 +71,7 @@ type SectionRowsArgs = { // tier wrapped around them. export function useSidebarSectionRows(args: SectionRowsArgs) { const { repos, worktrees, repoMap, defaultHostId } = args - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts index 9eb32350396..61483bc16a6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts @@ -16,6 +16,7 @@ import { useReusedArrayIdentity } from './use-reused-array-identity' import { useSidebarListSession } from '@/lib/sidebar-list-session' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../../shared/sidebar-inputs-switch' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' // ── Stable sort order ────────────────────────────────────────── // Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. @@ -93,7 +94,7 @@ function useStampSidebarWorktreeSortOrder(args: { sortBy: SortBy }): string[] { const { repoMap, sortBy } = args - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const ranked = useMemo( () => rankWorktreesForSort({ diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index a462e63020f..37239c29242 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -20,6 +20,7 @@ import type { SortBy } from '../../../../../../shared/sidebar-list/smart-sort' import type { SidebarWorktreeFilters } from './use-filters' import { useReusedArrayIdentity } from './use-reused-array-identity' import { useSleepingSweepRetention } from './use-sleeping-sweep-retention' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() @@ -49,7 +50,7 @@ export function useVisibleSidebarWorktrees(args: { visibleWorkspaceHostIds, workspaceHostScope } = filterState - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) // Why: skip the clock entirely when the epoch is the opt-out sentinel, so a // sleeping-workspaces list cannot evict the sample the live lists share. diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx index cc4d4a109ea..b0a76711cd9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/RepoScanUnavailableIndicator.tsx @@ -11,13 +11,14 @@ import { handleRepoHeaderActionPointerDown, stopRepoHeaderKeyboardToggle } from './header-event-guards' +import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' /** * Marks a repo whose worktree scan failed, so its rows are retained but cannot be trusted. * Click re-runs the scan: the failure is otherwise re-tried only by the next incidental refresh. */ export function RepoScanUnavailableIndicator({ repo }: { repo: Repo }): React.JSX.Element | null { - const detected = useAppStore((s) => s.detectedWorktreesByRepo[repo.id]) + const detected = useAppStore((s) => selectDetectedWorktreesByRepo(s)[repo.id]) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const [pending, setPending] = React.useState(false) const failure = resolveRepoScanFailure(repo, detected) diff --git a/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts b/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts index b507241b847..34bb685e2df 100644 --- a/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts +++ b/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts @@ -6,6 +6,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors' export function resolveWorktreeVisibilityHostTarget( state: Pick, @@ -18,7 +19,7 @@ export function resolveWorktreeVisibilityHostTarget( hostId: requestedHostId, settings: state.settings }) - const detectedForRepo = repoId ? state.detectedWorktreesByRepo[repoId] : undefined + const detectedForRepo = repoId ? selectDetectedWorktreesByRepo(state)[repoId] : undefined const detected = detectedForRepo && repo && requestedHostId ? { diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index e4115f5ba13..c094eb3552d 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -25,6 +25,7 @@ import { checklistItemsFromVersion } from './skill-package-checklist-items' import { summarizeSkillInstallRisk } from './skill-package-install-risk' import { retryableSkillIds } from './skill-bundle-retry-selection' import { createBrowserUuid } from '@/lib/browser-uuid' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type BundleVersion = SkillCloudVersion & { manifest: Extract @@ -47,9 +48,9 @@ export function SkillBundleInstallFlow(props: { }): React.JSX.Element { const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) const runtimeStatus = useAppStore((state) => state.runtimeStatusByEnvironmentId) - const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) + const worktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) const repos = useAppStore((state) => state.repos) - const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const sshConnectionStates = useAppStore((state) => state.sshConnectionStates) const sshTargetLabels = useAppStore((state) => state.sshTargetLabels) const allSkillIds = useMemo( diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index 8f150708ee9..55499955f79 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -27,6 +27,7 @@ import { resolveSkillShareForInstall } from './skill-warning-preview-gate' import { useSkillInstallRisk } from './use-skill-install-risk' import { SkillInstallDialogFooter } from './SkillInstallDialogFooter' import { createBrowserUuid } from '@/lib/browser-uuid' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function SkillInstallDialog({ open, @@ -39,9 +40,9 @@ export function SkillInstallDialog({ }): React.JSX.Element { const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) const runtimeStatus = useAppStore((state) => state.runtimeStatusByEnvironmentId) - const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) + const worktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) const repos = useAppStore((state) => state.repos) - const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const sshConnectionStates = useAppStore((state) => state.sshConnectionStates) const sshTargetLabels = useAppStore((state) => state.sshTargetLabels) const [link, setLink] = useState(initialLink) diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index 32bd7237397..a6937c445e4 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -1,5 +1,6 @@ import type { AppState } from '../../store' import { getAllWorktreesFromState } from '../../store/selectors' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} @@ -72,7 +73,7 @@ export function getResourceUsageFolderWorkspaces( state: Pick, open: boolean ): AppState['folderWorkspaces'] { - return open ? state.folderWorkspaces : EMPTY_FOLDER_WORKSPACES + return open ? selectFolderWorkspaces(state) : EMPTY_FOLDER_WORKSPACES } export function getResourceUsageProjectGroups( diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts index 1eb790d8ef2..282e3e6c0af 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts @@ -7,6 +7,7 @@ import type { WorkspaceSpaceSortKey } from './workspace-space-presentation' import type { WorkspaceGitRefreshState } from './workspace-space-manager-state-types' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function useWorkspaceSpaceManagerBindings() { const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) @@ -21,7 +22,7 @@ export function useWorkspaceSpaceManagerBindings() { const repoMap = useAppStore((state) => getRepoMapFromState(state)) const repos = useAppStore((state) => state.repos) const worktreeMap = useAppStore((state) => getWorktreeMapFromState(state)) - const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) + const worktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId) const agentStatusByPaneKey = useAppStore((state) => state.agentStatusByPaneKey) diff --git a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts index dd02eb998ee..74794d81c4a 100644 --- a/src/renderer/src/components/tab-bar/open-tab-search-entries.ts +++ b/src/renderer/src/components/tab-bar/open-tab-search-entries.ts @@ -14,6 +14,7 @@ import { type SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search' import type { AppState } from '@/store/types' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { getRepoExecutionHostId, getWorktreeExecutionHostId, @@ -101,7 +102,7 @@ export function selectOpenTabSearchEntryState( browserPagesByWorkspace: state.browserPagesByWorkspace, browserTabsByWorktree: state.browserTabsByWorktree, executionHostId, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, groupsByWorktree: state.groupsByWorktree, openFiles: state.openFiles, @@ -109,7 +110,7 @@ export function selectOpenTabSearchEntryState( tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktree, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) } } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts index 90cbafd7c7b..620f4dab8a8 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts @@ -6,6 +6,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-typ import type { useAppStore } from '@/store' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' type TabEntryAbsolutePathOwnerState = Pick< ReturnType, @@ -92,9 +97,9 @@ export function createTabEntryAllowAbsolutePathsSelector( if ( previousSlices?.settings === state.settings && previousSlices.repos === state.repos && - previousSlices.worktreesByRepo === state.worktreesByRepo && - previousSlices.detectedWorktreesByRepo === state.detectedWorktreesByRepo && - previousSlices.folderWorkspaces === state.folderWorkspaces && + selectWorktreesByRepo(previousSlices) === selectWorktreesByRepo(state) && + selectDetectedWorktreesByRepo(previousSlices) === selectDetectedWorktreesByRepo(state) && + selectFolderWorkspaces(previousSlices) === selectFolderWorkspaces(state) && previousSlices.projectGroups === state.projectGroups && previousSlices.runtimeEnvironments === state.runtimeEnvironments && previousSlices.runtimeEnvironmentCatalogHydrated === @@ -110,9 +115,9 @@ export function createTabEntryAllowAbsolutePathsSelector( previousSlices = { settings: state.settings, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, runtimeEnvironments: state.runtimeEnvironments, runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 93abdfcd290..90656c58633 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -42,6 +42,7 @@ import { resolveAiVaultHostScopeDefaults } from '../right-sidebar/ai-vault-host- import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' import { claimAiVaultForcedRescan } from '../right-sidebar/ai-vault-session-refresh' import { structuredAgentSessionLaunchFeasible } from '@/lib/agent-session-launch-plan' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind' /** What a tab's history row is found by: a native chat tab by the chat it shows, a terminal tab by @@ -193,8 +194,8 @@ export function resolveTabSessionSwitch( subject: TabSessionHistorySubject ): TabSessionSwitch | null { const worktrees = aiVaultSessionOwnerWorkspaces( - getIndexedAllWorktrees(state.worktreesByRepo), - state.folderWorkspaces + getIndexedAllWorktrees(selectWorktreesByRepo(state)), + selectFolderWorkspaces(state) ) const { resumeState, resumeInChat } = resolveAiVaultHistoryRowResume({ session, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index c031b5d4c4c..ff979fb25d8 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -27,6 +27,7 @@ import { import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { createUnifiedTabLookup } from './tab-bar-item-model' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' const isWindows = navigator.userAgent.includes('Windows') export const isMacOs = navigator.userAgent.includes('Mac') @@ -174,7 +175,7 @@ export function useTabBarRuntimeModel({ const projects = useAppStore((s) => (needsLocalProjectRuntime ? s.projects : EMPTY_PROJECTS)) const repos = useAppStore((s) => (needsLocalProjectRuntime ? s.repos : EMPTY_REPOS)) const worktreesByRepo = useAppStore((s) => - needsLocalProjectRuntime ? s.worktreesByRepo : EMPTY_WORKTREES_BY_REPO + needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO ) const localProjectRuntime = useMemo(() => { if (!needsLocalProjectRuntime) { diff --git a/src/renderer/src/components/terminal-pane/detached-pane-restart-spawn-context.ts b/src/renderer/src/components/terminal-pane/detached-pane-restart-spawn-context.ts index eb941424edb..228a6be5f87 100644 --- a/src/renderer/src/components/terminal-pane/detached-pane-restart-spawn-context.ts +++ b/src/renderer/src/components/terminal-pane/detached-pane-restart-spawn-context.ts @@ -2,11 +2,12 @@ import type { AppState } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { makePaneKey } from '../../../../shared/stable-pane-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' export function getWorkspacePath(state: AppState, worktreeId: string): string | null { const parsed = parseWorkspaceKey(worktreeId) return parsed?.type === 'folder' - ? (state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId) + ? (selectFolderWorkspaces(state).find((workspace) => workspace.id === parsed.folderWorkspaceId) ?.folderPath ?? null) : (getWorktreeMapFromState(state).get(worktreeId)?.path ?? null) } @@ -20,7 +21,7 @@ export function buildPaneIdentityEnv( const parsed = parseWorkspaceKey(worktreeId) const folderWorkspace = parsed?.type === 'folder' - ? state.folderWorkspaces.find((workspace) => workspace.id === parsed.folderWorkspaceId) + ? selectFolderWorkspaces(state).find((workspace) => workspace.id === parsed.folderWorkspaceId) : null return { ORCA_WORKSPACE_ID: worktreeId, diff --git a/src/renderer/src/components/terminal-pane/terminal-notification-state.ts b/src/renderer/src/components/terminal-pane/terminal-notification-state.ts index 94f6d7579c4..1ae76aba65d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-notification-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-notification-state.ts @@ -19,6 +19,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type StoreSnapshot = ReturnType @@ -172,7 +173,7 @@ function findWorktreeRowOnItsOwnHost( state: StoreSnapshot, worktreeId: string ): { worktree: Worktree | undefined; hostId: ExecutionHostId | null } { - const rows = getIndexedWorktreesById(state.worktreesByRepo, worktreeId) + const rows = getIndexedWorktreesById(selectWorktreesByRepo(state), worktreeId) if (rows.length <= 1) { return { worktree: rows[0], hostId: null } } @@ -218,7 +219,10 @@ export function getNotificationWorkspaceLabels( const scope = parseWorkspaceKey(workspaceId) const fallback = terminalTitle?.trim() || 'workspace' if (scope?.type === 'folder') { - const folder = findIndexedFolderWorkspaceOwner(state.folderWorkspaces, scope.folderWorkspaceId) + const folder = findIndexedFolderWorkspaceOwner( + selectFolderWorkspaces(state), + scope.folderWorkspaceId + ) // The group ID is only unique per host, so qualify it with the folder's own host. const group = folder && diff --git a/src/renderer/src/components/use-quick-open-interaction.ts b/src/renderer/src/components/use-quick-open-interaction.ts index 78232dadd65..ff346007076 100644 --- a/src/renderer/src/components/use-quick-open-interaction.ts +++ b/src/renderer/src/components/use-quick-open-interaction.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { getFileExplorerOperationOwnerFromState } from './right-sidebar/file-explorer-operation-owner' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export function useQuickOpenInteraction(worktreeId: string | null): { opening: boolean @@ -25,11 +30,11 @@ export function useQuickOpenInteraction(worktreeId: string | null): { state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId || ((state.settings !== previous.settings || state.repos !== previous.repos || - state.worktreesByRepo !== previous.worktreesByRepo || - state.detectedWorktreesByRepo !== previous.detectedWorktreesByRepo || + selectWorktreesByRepo(state) !== selectWorktreesByRepo(previous) || + selectDetectedWorktreesByRepo(state) !== selectDetectedWorktreesByRepo(previous) || state.restoredRuntimeHostIdByWorkspaceSessionKey !== previous.restoredRuntimeHostIdByWorkspaceSessionKey || - state.folderWorkspaces !== previous.folderWorkspaces || + selectFolderWorkspaces(state) !== selectFolderWorkspaces(previous) || state.projectGroups !== previous.projectGroups) && JSON.stringify(getFileExplorerOperationOwnerFromState(state, worktreeId)) !== JSON.stringify(getFileExplorerOperationOwnerFromState(previous, worktreeId))) diff --git a/src/renderer/src/components/use-task-page-store-bindings.ts b/src/renderer/src/components/use-task-page-store-bindings.ts index 9e66e9610b6..7b6fba87eb4 100644 --- a/src/renderer/src/components/use-task-page-store-bindings.ts +++ b/src/renderer/src/components/use-task-page-store-bindings.ts @@ -8,6 +8,8 @@ import { } from '@/lib/provider-runtime-context' import { useRef } from 'react' import { getScreenSubmitShortcutLabel } from '@/lib/screen-submit-shortcut' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' + export function useTaskPageStoreBindings() { useTranslation() const settings = useAppStore((s) => s.settings) @@ -43,7 +45,7 @@ export function useTaskPageStoreBindings() { const searchLinearIssues = useAppStore((s) => s.searchLinearIssues) const listLinearIssues = useAppStore((s) => s.listLinearIssues) const linearListInvalidationToken = useAppStore((s) => s.linearListInvalidationToken) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const invalidateLinearIssueLists = useAppStore((s) => s.invalidateLinearIssueLists) const getCachedLinearIssues = useAppStore((s) => s.getCachedLinearIssues) const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index d68c5104da2..28d8916cfb6 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -10,6 +10,7 @@ import { useReusedArrayIdentity } from './sidebar/worktree-list/listing/use-reus import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' export function useTerminalWorkspaceFoundation() { const terminalTopologyProjectionRef = useRef()) const terminalWorktreeParkingTimersRef = useRef(new Map()) const worktreesById = useWorktreeMap() - const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const renderedActiveWorktreeId = activeWorktreeId const activeWorktreeDeferralHostId = useAppStore((state) => diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index c6f2ca3ee85..39358c99491 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -11,6 +11,11 @@ import { selectPaletteStatusInputs } from './worktree-jump-palette-status-inputs' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export function useWorktreeJumpPaletteStoreState({ visible, @@ -29,8 +34,8 @@ export function useWorktreeJumpPaletteStoreState({ const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) const revealSidebarRow = useAppStore((state) => state.revealSidebarRow) - const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) - const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const worktreesByRepo = useAppStore((state) => selectWorktreesByRepo(state)) + const folderWorkspaces = useAppStore((state) => selectFolderWorkspaces(state)) const workspaces = useMemo( () => dedupePaletteWorktrees([ @@ -43,7 +48,7 @@ export function useWorktreeJumpPaletteStoreState({ const projectGroups = useAppStore((state) => state.projectGroups) const projects = useAppStore((state) => state.projects) const projectHostSetups = useAppStore((state) => state.projectHostSetups) - const detectedWorktreesByRepo = useAppStore((state) => state.detectedWorktreesByRepo) + const detectedWorktreesByRepo = useAppStore((state) => selectDetectedWorktreesByRepo(state)) const pendingWorktreeCreations = useAppStore((state) => state.pendingWorktreeCreations) const pluginCommands = usePluginCommands() const paletteStatusInputsActive = visible || lingering diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts index e9e7b81c64c..f30c2080dd3 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -36,6 +36,7 @@ import { createWorkspaceCleanupFacetListCache, createWorkspaceCleanupReviewInfoCache } from './workspace-cleanup-facet-row-caches' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type WorkspaceCleanupFacetRows = { rows: WorkspaceCleanupFacets[] @@ -98,7 +99,7 @@ export function useWorkspaceCleanupFacetRows({ }): WorkspaceCleanupFacetRows { const sources = useAppStore( useShallow((s) => ({ - worktreesByRepo: s.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(s), hostedReviewCache: s.hostedReviewCache, repos: s.repos, settings: s.settings, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index 0f83e44fb7c..a8a46c59c2a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -8,6 +8,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { isGitHubPRSuppressed } from '../../../../shared/worktree/github-pr-suppression' import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type WorkspaceCleanupSortKey = 'activity' | 'name' | 'repo' | 'review' | 'git' export type WorkspaceCleanupSortDirection = 'asc' | 'desc' @@ -109,7 +110,7 @@ export function buildWorkspaceCleanupReviewLookup( reposById.set(repo.id, matches) } const worktreesByRepoAndId = new Map() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + for (const [repoId, worktrees] of Object.entries(selectWorktreesByRepo(state))) { for (const worktree of worktrees) { const key = `${repoId}\0${worktree.id}` const matches = worktreesByRepoAndId.get(key) ?? [] diff --git a/src/renderer/src/components/workspace-space/space-window-state.ts b/src/renderer/src/components/workspace-space/space-window-state.ts index 1f4af0dd59c..020603b5973 100644 --- a/src/renderer/src/components/workspace-space/space-window-state.ts +++ b/src/renderer/src/components/workspace-space/space-window-state.ts @@ -1,4 +1,9 @@ import type { AppState } from '@/store/types' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export function pickSpaceWindowState(state: AppState) { return { @@ -8,12 +13,12 @@ export function pickSpaceWindowState(state: AppState) { workspaceSpaceScanning: state.workspaceSpaceScanning, workspaceSpaceMeasurements: state.workspaceSpaceMeasurements, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), runtimeEnvironments: state.runtimeEnvironments, runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, diff --git a/src/renderer/src/hooks/composer-state/composer-target-store.ts b/src/renderer/src/hooks/composer-state/composer-target-store.ts index 5219afee063..4f16dac7626 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -14,6 +14,7 @@ import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../.. import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { ComposerDecisions } from './composer-decisions' import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type ComposerStateInput = { initialRepoId?: string @@ -104,7 +105,7 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo) diff --git a/src/renderer/src/hooks/editor-external-watch-target-rows.ts b/src/renderer/src/hooks/editor-external-watch-target-rows.ts index 796e86817fa..82a51a89c82 100644 --- a/src/renderer/src/hooks/editor-external-watch-target-rows.ts +++ b/src/renderer/src/hooks/editor-external-watch-target-rows.ts @@ -10,6 +10,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type WatchTargetRowState = Pick< AppState, @@ -43,7 +44,7 @@ export function resolveEditorExternalWatchTargetRows( hostId: ExecutionHostId | null ): EditorExternalWatchTargetRows | null { const worktree = pickHostRow( - getIndexedWorktreesById(state.worktreesByRepo, worktreeId), + getIndexedWorktreesById(selectWorktreesByRepo(state), worktreeId), hostId, (row) => row.hostId ?? @@ -55,7 +56,7 @@ export function resolveEditorExternalWatchTargetRows( const folderWorkspace = workspaceScope?.type === 'folder' ? pickHostRow( - state.folderWorkspaces.filter( + selectFolderWorkspaces(state).filter( (workspace) => workspace.id === workspaceScope.folderWorkspaceId ), hostId, diff --git a/src/renderer/src/hooks/editor-external-watch-targets.ts b/src/renderer/src/hooks/editor-external-watch-targets.ts index 5a522b7d73a..46c18de44a0 100644 --- a/src/renderer/src/hooks/editor-external-watch-targets.ts +++ b/src/renderer/src/hooks/editor-external-watch-targets.ts @@ -17,6 +17,7 @@ import { resolveEditorExternalWatchTargetRows } from './editor-external-watch-target-rows' import { selectWatchEligibleWorkspaceIds } from './editor-external-watch-scope' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type EditorExternalWatchTarget = { worktreeId: string @@ -150,7 +151,7 @@ export function selectEditorExternalWatchTargets( const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId ?? null if ( cachedOpenFiles === state.openFiles && - cachedWorktreesByRepo === state.worktreesByRepo && + cachedWorktreesByRepo === selectWorktreesByRepo(state) && cachedRepos === state.repos && cachedActiveWorktreeId === state.activeWorktreeId && cachedActiveWorkspaceExecutionHostId === activeWorkspaceExecutionHostId && @@ -161,7 +162,7 @@ export function selectEditorExternalWatchTargets( cachedRightSidebarExplorerView === state.rightSidebarExplorerView && cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree && cachedSshConnectionStates === state.sshConnectionStates && - cachedFolderWorkspaces === state.folderWorkspaces && + cachedFolderWorkspaces === selectFolderWorkspaces(state) && cachedProjectGroups === state.projectGroups && cachedLastVisitedAtByWorktreeId === state.lastVisitedAtByWorktreeId ) { @@ -190,7 +191,7 @@ export function selectEditorExternalWatchTargets( } const activeWorktreeId = state.activeWorktreeId const activeWorktree = activeWorktreeId - ? findWorktreeById(state.worktreesByRepo, activeWorktreeId) + ? findWorktreeById(selectWorktreesByRepo(state), activeWorktreeId) : undefined const activeWorktreeHost = parseExecutionHostId(activeWorktree?.hostId) const activeRepo = activeWorktree @@ -238,7 +239,7 @@ export function selectEditorExternalWatchTargets( const targetsKey = sortedEntries.map(([key]) => key).join('|') const unwatchedTargetsKey = [...unwatchedTargetKeys].sort().join('|') cachedOpenFiles = state.openFiles - cachedWorktreesByRepo = state.worktreesByRepo + cachedWorktreesByRepo = selectWorktreesByRepo(state) cachedRepos = state.repos cachedActiveWorktreeId = state.activeWorktreeId cachedActiveWorkspaceExecutionHostId = activeWorkspaceExecutionHostId @@ -249,7 +250,7 @@ export function selectEditorExternalWatchTargets( cachedRightSidebarExplorerView = state.rightSidebarExplorerView cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree cachedSshConnectionStates = state.sshConnectionStates - cachedFolderWorkspaces = state.folderWorkspaces + cachedFolderWorkspaces = selectFolderWorkspaces(state) cachedProjectGroups = state.projectGroups cachedLastVisitedAtByWorktreeId = state.lastVisitedAtByWorktreeId diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.ts b/src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.ts index 4b8dcc36a9e..04c216bcca3 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-pending-retry-gate.ts @@ -1,4 +1,5 @@ import type { AppState } from '../../store/types' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type AgentStatusPendingRetryState = Pick< AppState, @@ -21,7 +22,7 @@ export function shouldRetryPendingAgentStatusesAfterStoreUpdate( current.tabsByWorktree !== previous.tabsByWorktree || current.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree || current.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId || - current.worktreesByRepo !== previous.worktreesByRepo || + selectWorktreesByRepo(current) !== selectWorktreesByRepo(previous) || current.repos !== previous.repos || current.recentlyClosedAgentStatusTabIds !== previous.recentlyClosedAgentStatusTabIds || current.recentlyRetiredAgentStatusPaneKeys !== previous.recentlyRetiredAgentStatusPaneKeys diff --git a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts index 17b70444ebf..79e96964613 100644 --- a/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/background-worktree-removal-bridge.ts @@ -6,6 +6,10 @@ import { UNFINISHED_WORKTREE_REMOVAL_ERROR, settleHostWorktreeRemovals } from '../../store/slices/worktrees/teardown/host-worktree-removal-state' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' type HostMarkedRow = Pick type AppStoreApi = Pick @@ -52,7 +56,7 @@ function showHostFailure(store: AppStoreApi, row: Worktree & { removalError: str export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): void { settleHostWorktreeRemovals() const listed = new Map() - for (const rows of Object.values(store.getState().worktreesByRepo)) { + for (const rows of Object.values(selectWorktreesByRepo(store.getState()))) { for (const row of rows) { listed.set(deleteStateKey(row), row) } @@ -110,18 +114,18 @@ export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): } export function registerBackgroundWorktreeRemovalBridge(unsubs: (() => void)[]): void { - let previousRows = useAppStore.getState().worktreesByRepo - let previousDetected = useAppStore.getState().detectedWorktreesByRepo + let previousRows = selectWorktreesByRepo(useAppStore.getState()) + let previousDetected = selectDetectedWorktreesByRepo(useAppStore.getState()) unsubs.push( useAppStore.subscribe((state) => { if ( - state.worktreesByRepo === previousRows && - state.detectedWorktreesByRepo === previousDetected + selectWorktreesByRepo(state) === previousRows && + selectDetectedWorktreesByRepo(state) === previousDetected ) { return } - previousRows = state.worktreesByRepo - previousDetected = state.detectedWorktreesByRepo + previousRows = selectWorktreesByRepo(state) + previousDetected = selectDetectedWorktreesByRepo(state) reconcileHostWorktreeRemovals() }) ) diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index a80a34c7243..3107c1e577c 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -2,6 +2,7 @@ import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' import { useAppStore } from '../../store' import { adoptFromEndpoint } from '../../store/adopt-from-endpoint' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function registerProjectCatalogIpcBridge( unsubs: (() => void)[], @@ -58,7 +59,7 @@ export function registerProjectCatalogIpcBridge( } const state = useAppStore.getState() applyWorktreeHeadIdentities(data, { - getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId], + getWorktreesForRepo: (repoId) => selectWorktreesByRepo(state)[repoId], updateWorktreeGitIdentity: state.updateWorktreeGitIdentity }) }) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index 4cec2c7ed40..615cd942624 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -19,6 +19,10 @@ import { useAppStore } from '../../store' import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows' import { rekeyWorktreeVisitHost } from '@/lib/worktree-visit-recency' import { preserveConvertedSshBrowserPages } from '../../store/repos/converted-ssh-browser-pages' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' type ManagedServerStatus = SshConnectionState['managedServer'] @@ -217,8 +221,8 @@ function rehomeActiveWorkspaceWhenKnown( state.startupWorktreeRefreshCompleted && !previous.startupWorktreeRefreshCompleted if ( !startupScanFinished && - state.worktreesByRepo === previous.worktreesByRepo && - state.detectedWorktreesByRepo === previous.detectedWorktreesByRepo && + selectWorktreesByRepo(state) === selectWorktreesByRepo(previous) && + selectDetectedWorktreesByRepo(state) === selectDetectedWorktreesByRepo(previous) && state.activeWorktreeId === previous.activeWorktreeId && state.activeWorkspaceExecutionHostId === previous.activeWorkspaceExecutionHostId ) { @@ -292,8 +296,8 @@ function serverCopyOfWorkspace( const path = normalizeRuntimePathForComparison(source.worktreePath) // Why detected too: a checkout the server's visibility settings hide still exists there. const row = [ - ...(state.worktreesByRepo[serverRepoId] ?? []), - ...(state.detectedWorktreesByRepo[serverRepoId]?.worktrees ?? []) + ...(selectWorktreesByRepo(state)[serverRepoId] ?? []), + ...(selectDetectedWorktreesByRepo(state)[serverRepoId]?.worktrees ?? []) ].find( (worktree) => worktree.hostId === runtimeHostId && normalizeRuntimePathForComparison(worktree.path) === path diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts index 06c342b04d2..32b012e7c8f 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -27,6 +27,10 @@ import { createWorktreeChangeRefreshQueue, type WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 @@ -37,13 +41,13 @@ function runtimeSortOrderFence(environmentId: string): string { const recentlyRenamedWorktreeIdExpiry = new Map() function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { - const detected = state.detectedWorktreesByRepo[repoId] + const detected = selectDetectedWorktreesByRepo(state)[repoId] return detected?.authoritative === true ? new Set(detected.worktrees.map((worktree) => worktree.id)) : null } function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { - return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) + return new Set((selectWorktreesByRepo(state)[repoId] ?? []).map((worktree) => worktree.id)) } type WorktreeEventActivationOptions = { diff --git a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts index 1e3d491b631..e42bcb42864 100644 --- a/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/wsl-host-ipc-bridge.ts @@ -4,6 +4,7 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { workspaceOwnerHostId } from '../../lib/workspace-row-owner-host' import type { AppState } from '../../store/types' import { useAppStore } from '../../store' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** * The WSL hosts an open project or terminal needs: the active workspace's host and the host of @@ -77,7 +78,7 @@ export function registerWslHostIpcBridge(unsubs: (() => void)[]): void { state.tabsByWorktree, state.runtimeEnvironments, state.repos, - state.folderWorkspaces, + selectFolderWorkspaces(state), state.projectGroups ] if (next.every((value, index) => value === inputs[index])) { diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 0efe927c3bf..230a43991fd 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -17,6 +17,7 @@ import { } from '@/lib/project-skill-runtime' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '@/store' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type ActiveProjectSkillRuntime = { projectRuntime?: ProjectExecutionRuntimeResolution @@ -72,7 +73,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { projects: state.projects, repos: state.repos, settings: state.settings, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) })) ) const currentPlatform = getCurrentPlatform() diff --git a/src/renderer/src/lib/active-worktree-removal-successor.ts b/src/renderer/src/lib/active-worktree-removal-successor.ts index ef67f57a839..7fdd642f708 100644 --- a/src/renderer/src/lib/active-worktree-removal-successor.ts +++ b/src/renderer/src/lib/active-worktree-removal-successor.ts @@ -21,6 +21,7 @@ import { } from '../../../shared/execution-host' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type SuccessorState = Pick< AppState, @@ -47,11 +48,11 @@ function findListedRow( hostId: ExecutionHostId | null ) { const repoId = getRepoIdFromWorktreeId(worktreeId) - const rows = state.worktreesByRepo[repoId] ?? [] + const rows = selectWorktreesByRepo(state)[repoId] ?? [] if (!hostId) { return rows.find((row) => row.id === worktreeId) } - return findIndexedWorktreeOwnerForHost(state.worktreesByRepo, worktreeId, hostId) + return findIndexedWorktreeOwnerForHost(selectWorktreesByRepo(state), worktreeId, hostId) } // Why: a per-workspace-env's runtime-owned SSH target is torn down with its workspace, so a sibling @@ -84,7 +85,7 @@ export function pickActiveWorktreeSuccessor( removed: RemovedActiveRow ): Worktree | null { const repoById = getRepoMapFromState(state) - const siblings = (state.worktreesByRepo[removed.repoId] ?? []).filter( + const siblings = (selectWorktreesByRepo(state)[removed.repoId] ?? []).filter( (worktree) => !getDeleteStateForWorktreeHost(worktree, state.deleteStateByWorktreeId)?.isDeleting && !isHostedOnRuntimeOwnedSshTarget(worktree, repoById) @@ -168,7 +169,7 @@ export function installActiveWorktreeRemovalSuccessor(store = useAppStore): () = if ( next.activeWorktreeId === prev.activeWorktreeId && next.activeWorkspaceExecutionHostId === prev.activeWorkspaceExecutionHostId && - next.worktreesByRepo === prev.worktreesByRepo && + selectWorktreesByRepo(next) === selectWorktreesByRepo(prev) && next.activeView === prev.activeView && next.activePendingCreationId === prev.activePendingCreationId ) { diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index 316d0f7aeb4..711e739690f 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -25,6 +25,7 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilitiesOrUnknown } from '@/runtime/local-runtime-capabilities' import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' import { pairedHostClientCapabilities } from '@/runtime/paired-host-client-capabilities' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { readHostStructuredAgents } from '@/runtime/host-structured-agents' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' @@ -166,10 +167,10 @@ export function buildAgentLaunchRouteInput( workspaceId: workspace.worktreeId, requestedCwd: tuiCustomization?.cwd, workspacePath: workspace.worktreeId - ? findWorktreeById(store.worktreesByRepo ?? {}, workspace.worktreeId)?.path + ? findWorktreeById(selectWorktreesByRepo(store) ?? {}, workspace.worktreeId)?.path : undefined, resolveFolderWorkspacePath: (folderWorkspaceId) => - store.folderWorkspaces?.find((entry) => entry.id === folderWorkspaceId)?.folderPath + selectFolderWorkspaces(store)?.find((entry) => entry.id === folderWorkspaceId)?.folderPath }), ...hostStructuredAgentsInput(store, executionHostId) } diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index 4c9959ea2d1..74cdd84af67 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -17,6 +17,7 @@ import { useAppStore } from '../store' import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -88,7 +89,7 @@ export function publishAgentLaunchTab( lookupWorktrees: () => [ ...(store.allWorktrees?.() ?? []), - ...(store.folderWorkspaces ?? []).map(folderWorkspaceToWorktree) + ...(selectFolderWorkspaces(store) ?? []).map(folderWorkspaceToWorktree) ].filter((worktree) => worktree.id === worktreeId) }) const focuses = request.viewer === 'focus-window' || request.viewer === 'focus-in-workspace' diff --git a/src/renderer/src/lib/agent-status-pane-ownership.ts b/src/renderer/src/lib/agent-status-pane-ownership.ts index 430aac83af6..b19e7234545 100644 --- a/src/renderer/src/lib/agent-status-pane-ownership.ts +++ b/src/renderer/src/lib/agent-status-pane-ownership.ts @@ -2,6 +2,7 @@ import type { AppState } from '../store/types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { collectLeafIdsInOrder } from '../components/terminal-pane/terminal-layout-leaf-ids' import { getIndexedRepoMap, getIndexedWorktreeMap } from '../store/worktree-repo-index' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, * and the owning repo's connectionId. Used for agent-type inference and to drop @@ -63,7 +64,7 @@ export function resolvePaneKey( let repoConnectionId: string | null = null let repoConnectionResolved = false if (owningWorktreeId !== undefined) { - const worktree = getIndexedWorktreeMap(store.worktreesByRepo).get(owningWorktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(store)).get(owningWorktreeId) if (worktree) { const repo = getIndexedRepoMap(store.repos).get(worktree.repoId) repoConnectionResolved = repo !== undefined @@ -121,7 +122,7 @@ export function resolveWorktreeConnection( repoConnectionId: string | null repoConnectionResolved: boolean } { - const worktree = getIndexedWorktreeMap(store.worktreesByRepo).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(store)).get(worktreeId) if (!worktree) { return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } } diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts index c51626a338e..657eaa9ebdc 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -12,6 +12,7 @@ import { } from '../../../shared/tui-agent-startup-shell' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type AiVaultResumeShellState = Pick< AppState, @@ -65,14 +66,15 @@ export function getAiVaultResumeWorkspacePath( const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { return ( - state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) - ?.folderPath ?? null + selectFolderWorkspaces(state).find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + )?.folderPath ?? null ) } const targetWorktreeId = workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId return ( - Object.values(state.worktreesByRepo ?? {}) + Object.values(selectWorktreesByRepo(state) ?? {}) .flat() .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null ) @@ -95,10 +97,10 @@ export function getAiVaultResumeWorkspaceWslDistro( activeWorktreeId: state.activeWorktreeId ?? null, projects: state.projects ?? [], settings: state.settings ?? null, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups ?? [], repos: state.repos, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) } if (workspaceKey?.type === 'folder') { if ( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 6a52f0bba00..04eb3f747ef 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -15,6 +15,7 @@ import { canResumeAiVaultSessionOnSshHost } from '../../../shared/ai-vault-ssh-r import type { AppState } from '@/store/types' import { getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' @@ -135,7 +136,7 @@ export function getAiVaultResumeWorkspaceExecutionHostId( } const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId - const worktree = getIndexedWorktreeMap(state.worktreesByRepo ?? {}).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state) ?? {}).get(worktreeId) const worktreeHostId = normalizeExecutionHostId(worktree?.hostId) if (worktreeHostId) { return worktreeHostId @@ -159,7 +160,7 @@ export function getAiVaultResumeWorkspaceTargetStatus( } const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId - const worktree = getIndexedWorktreeMap(state.worktreesByRepo ?? {}).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state) ?? {}).get(worktreeId) const worktreeHost = getAiVaultResumeExecutionHostTargetStatus(worktree?.hostId) if (worktreeHost !== 'unknown') { return worktreeHost @@ -172,7 +173,7 @@ function getAiVaultResumeFolderTargetStatus( state: Pick, folderWorkspaceId: string ): AiVaultResumeTargetStatus { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + const workspace = selectFolderWorkspaces(state).find((entry) => entry.id === folderWorkspaceId) if (!workspace) { return 'unknown' } @@ -196,7 +197,7 @@ function getAiVaultResumeFolderExecutionHostId( state: Pick, folderWorkspaceId: string ): ExecutionHostId | null { - const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + const workspace = selectFolderWorkspaces(state).find((entry) => entry.id === folderWorkspaceId) if (!workspace) { return null } diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts index 75ff2b13a20..eadbecbacdc 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -4,6 +4,11 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' function providerSessionEqual( left: AgentProviderSessionMetadata | undefined, @@ -181,9 +186,9 @@ export function aiVaultTitleSyncInputsChanged(current: AppState, previous: AppSt const ownerInputsChanged = current.repos !== previous.repos || - current.worktreesByRepo !== previous.worktreesByRepo || - current.detectedWorktreesByRepo !== previous.detectedWorktreesByRepo || - current.folderWorkspaces !== previous.folderWorkspaces || + selectWorktreesByRepo(current) !== selectWorktreesByRepo(previous) || + selectDetectedWorktreesByRepo(current) !== selectDetectedWorktreesByRepo(previous) || + selectFolderWorkspaces(current) !== selectFolderWorkspaces(previous) || current.projectGroups !== previous.projectGroups || current.settings !== previous.settings || current.activeWorktreeId !== previous.activeWorktreeId || diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index d78ba165fd5..48b1c1195cf 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -24,6 +24,7 @@ import { getCachedWindowsTerminalCapabilities, hasCachedWindowsTerminalCapabilities } from './windows-terminal-capabilities' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Everything the pane lane needs: the workspace path plus the project runtime inputs. */ type CodexPaneLaneState = Pick< @@ -297,12 +298,13 @@ function getWorkspacePath( const parsed = parseWorkspaceKey(worktreeId) if (parsed?.type === 'folder') { return ( - (state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId) - ?.folderPath ?? null + (selectFolderWorkspaces(state) ?? []).find( + (workspace) => workspace.id === parsed.folderWorkspaceId + )?.folderPath ?? null ) } return ( - Object.values(state.worktreesByRepo ?? {}) + Object.values(selectWorktreesByRepo(state) ?? {}) .flat() .find((entry) => entry.id === worktreeId)?.path ?? null ) diff --git a/src/renderer/src/lib/connection-owner-resolution.ts b/src/renderer/src/lib/connection-owner-resolution.ts index 0451adac9a4..fe5d78da0b9 100644 --- a/src/renderer/src/lib/connection-owner-resolution.ts +++ b/src/renderer/src/lib/connection-owner-resolution.ts @@ -17,6 +17,7 @@ import { getFolderWorkspaceCandidateRepos, getFolderWorkspaceConnectionId } from './folder-workspace-connection' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type ConnectionOwnerState = Pick< AppState, @@ -35,18 +36,18 @@ export function createConnectionIdForFileSelector( return undefined } if ( - previousSlices?.folderWorkspaces === state.folderWorkspaces && + previousSlices?.folderWorkspaces === selectFolderWorkspaces(state) && previousSlices.projectGroups === state.projectGroups && previousSlices.repos === state.repos && - previousSlices.worktreesByRepo === state.worktreesByRepo + selectWorktreesByRepo(previousSlices) === selectWorktreesByRepo(state) ) { return previousResult } previousSlices = { - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, repos: state.repos, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) } previousResult = getConnectionIdForFileFromState(state, worktreeId, filePath) return previousResult @@ -66,7 +67,7 @@ export function getConnectionIdFromState( } // Why: owner resolution runs from retained Zustand selectors, so unrelated // store writes must not flatten every worktree or scan every repository. - const worktreeResolution = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) + const worktreeResolution = resolveIndexedWorktreeOwner(selectWorktreesByRepo(state), worktreeId) if (worktreeResolution.kind === 'ambiguous') { // Why (#17799): rows that disagree about the owner cannot name a connection. // `undefined` is this module's documented "cannot determine the host" answer; diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 7427ccd211c..6f6c7cafbac 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -16,6 +16,11 @@ import { type WorktreeOperationRoute } from './worktree-operation-route' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export type EditorFileOperationProvenance = { generation: WorktreeOperationGenerationSnapshot @@ -150,10 +155,10 @@ function resolveCurrentEditorRoute( function isWorktreePublished(state: EditorOwnerState, worktreeId: string): boolean { return ( - Object.values(state.worktreesByRepo ?? {}).some((worktrees) => + Object.values(selectWorktreesByRepo(state) ?? {}).some((worktrees) => worktrees.some((worktree) => worktree.id === worktreeId) ) || - Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => + Object.values(selectDetectedWorktreesByRepo(state) ?? {}).some((result) => result.worktrees.some((worktree) => worktree.id === worktreeId) ) ) @@ -193,7 +198,7 @@ export function getEditorFileOperationContext( const resolvedWorktreePath = (worktreePath?.trim() ? worktreePath : null) ?? (workspaceScope?.type === 'folder' - ? (state.folderWorkspaces.find( + ? (selectFolderWorkspaces(state).find( (workspace) => workspace.id === workspaceScope.folderWorkspaceId )?.folderPath ?? null) : null) diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index 685a2849e01..b728c8bfcd9 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -14,6 +14,7 @@ import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { recordOpenedSimulatorTabInLayoutOwner } from './simulator-tab-layout-owner' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type EnsureSimulatorTabOptions = { targetGroupId?: string @@ -32,7 +33,7 @@ type ExistingSimulatorTab = { function getSimulatorWorktrees(state: ReturnType, worktreeId: string) { return [ ...(state.allWorktrees?.() ?? []), - ...(state.folderWorkspaces ?? []).map(folderWorkspaceToWorktree) + ...(selectFolderWorkspaces(state) ?? []).map(folderWorkspaceToWorktree) ].filter((worktree) => worktree.id === worktreeId) } diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index 14965e2cc50..a77d45e6289 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -25,6 +25,7 @@ import { findPage } from '@/store/slices/browser-page-records' import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion' import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type PreviewableLanguage = 'html' @@ -129,10 +130,10 @@ export function useWorkspaceFileBrowserActionPredicate( runtimeEnvironmentId: worktreeId ? runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, worktreeId) : null, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, repos: state.repos, - worktreesByRepo: state.worktreesByRepo + worktreesByRepo: selectWorktreesByRepo(state) })) ) return useCallback( diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts index 1dfdd7c1c3b..1da9a84b045 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -1,4 +1,9 @@ import { useAppStore } from '@/store' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' type AppStoreSnapshot = ReturnType @@ -45,26 +50,26 @@ function ensurePendingHookCommandSubscription(): void { // Capture references so unrelated PTY/status updates do not rescan every // runtime worktree waiting for its first mirrored terminal tab. let previousTabsByWorktree = initial.tabsByWorktree - let previousWorktreesByRepo = initial.worktreesByRepo - let previousDetectedWorktreesByRepo = initial.detectedWorktreesByRepo - let previousFolderWorkspaces = initial.folderWorkspaces + let previousWorktreesByRepo = selectWorktreesByRepo(initial) + let previousDetectedWorktreesByRepo = selectDetectedWorktreesByRepo(initial) + let previousFolderWorkspaces = selectFolderWorkspaces(initial) let previousWorktreeLookup = initial.getKnownWorktreeById unsubscribePendingHookCommandDeliveries = useAppStore.subscribe((state) => { // Why: only these slices (or the test-adapter lookup itself) can change // whether a pending worktree exists or has received its first tab. if ( state.tabsByWorktree === previousTabsByWorktree && - state.worktreesByRepo === previousWorktreesByRepo && - state.detectedWorktreesByRepo === previousDetectedWorktreesByRepo && - state.folderWorkspaces === previousFolderWorkspaces && + selectWorktreesByRepo(state) === previousWorktreesByRepo && + selectDetectedWorktreesByRepo(state) === previousDetectedWorktreesByRepo && + selectFolderWorkspaces(state) === previousFolderWorkspaces && state.getKnownWorktreeById === previousWorktreeLookup ) { return } previousTabsByWorktree = state.tabsByWorktree - previousWorktreesByRepo = state.worktreesByRepo - previousDetectedWorktreesByRepo = state.detectedWorktreesByRepo - previousFolderWorkspaces = state.folderWorkspaces + previousWorktreesByRepo = selectWorktreesByRepo(state) + previousDetectedWorktreesByRepo = selectDetectedWorktreesByRepo(state) + previousFolderWorkspaces = selectFolderWorkspaces(state) previousWorktreeLookup = state.getKnownWorktreeById flushPendingHookCommandDeliveries() }) diff --git a/src/renderer/src/lib/linear-issue-workspace-open.ts b/src/renderer/src/lib/linear-issue-workspace-open.ts index 63d2944c180..b927169184d 100644 --- a/src/renderer/src/lib/linear-issue-workspace-open.ts +++ b/src/renderer/src/lib/linear-issue-workspace-open.ts @@ -12,6 +12,7 @@ import { findLinearIssueWorkspaceAttachment, type LinearIssueAttachmentRef } from '@/lib/linear-issue-workspace-attachment' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** * Opens the workspace already attached to a Linear issue, or starts a new one. @@ -24,7 +25,7 @@ export function openLinearIssueWorkspaceOrStart( ): 'opened' | 'started' | 'failed' { const state = useAppStore.getState() const attached = findLinearIssueWorkspaceAttachment( - [...state.allWorktrees(), ...state.folderWorkspaces.map(folderWorkspaceToWorktree)], + [...state.allWorktrees(), ...selectFolderWorkspaces(state).map(folderWorkspaceToWorktree)], issue ) if (!attached) { diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 1eda3cb9f67..5d006191211 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -7,6 +7,7 @@ import { } from '../../../shared/folder-workspace-execution-host' import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type RuntimeWorkspaceState = Pick & Partial> @@ -24,8 +25,10 @@ export function getLocalProjectRuntimeWorkspace( const scope = parseWorkspaceKey(targetWorktreeId) if (scope?.type !== 'folder') { return ( - getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ?? - null + getIndexedWorktreeById( + selectWorktreesByRepo(state) ?? EMPTY_WORKTREES_BY_REPO, + targetWorktreeId + ) ?? null ) } const folderState = { diff --git a/src/renderer/src/lib/right-sidebar-visibility.ts b/src/renderer/src/lib/right-sidebar-visibility.ts index 4eb9375efb4..7b478056ad8 100644 --- a/src/renderer/src/lib/right-sidebar-visibility.ts +++ b/src/renderer/src/lib/right-sidebar-visibility.ts @@ -1,6 +1,7 @@ import type { AppState } from '@/store/types' import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { isFolderRepo } from '../../../shared/repo-kind' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type ActiveView = AppState['activeView'] @@ -39,7 +40,7 @@ export function rightSidebarShowsPullRequestData( } const activeWorktree = state.activeWorktreeId - ? getIndexedWorktreeMap(state.worktreesByRepo).get(state.activeWorktreeId) + ? getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(state.activeWorktreeId) : undefined const activeRepo = activeWorktree ? getIndexedRepoMap(state.repos).get(activeWorktree.repoId) diff --git a/src/renderer/src/lib/runtime-workspace-file-route.ts b/src/renderer/src/lib/runtime-workspace-file-route.ts index 28545e5677d..30e4e713fd7 100644 --- a/src/renderer/src/lib/runtime-workspace-file-route.ts +++ b/src/renderer/src/lib/runtime-workspace-file-route.ts @@ -10,6 +10,7 @@ import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export type RuntimeWorkspaceFileRoute = { worktreeId: string @@ -41,12 +42,12 @@ export function findWorkspaceFileRoute( executionHostId: ExecutionHostId, absolutePath: string ): RuntimeWorkspaceFileRoute | null { - const roots = getIndexedAllWorktrees(state.worktreesByRepo).flatMap((worktree) => + const roots = getIndexedAllWorktrees(selectWorktreesByRepo(state)).flatMap((worktree) => workspaceMatchesExecutionHost(state, worktree.id, executionHostId) ? [{ workspaceId: worktree.id, rootPath: worktree.path, executionHostId }] : [] ) - for (const workspace of state.folderWorkspaces) { + for (const workspace of selectFolderWorkspaces(state)) { const workspaceId = folderWorkspaceKey(workspace.id) if (workspaceMatchesExecutionHost(state, workspaceId, executionHostId)) { roots.push({ workspaceId, rootPath: workspace.folderPath, executionHostId }) diff --git a/src/renderer/src/lib/sidebar-shadow/desktop-facts-shadow.ts b/src/renderer/src/lib/sidebar-shadow/desktop-facts-shadow.ts index 3d884842ecb..dcda251d455 100644 --- a/src/renderer/src/lib/sidebar-shadow/desktop-facts-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/desktop-facts-shadow.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/execution-host' import type { DesktopSidebarFacts } from '../../../../shared/sidebar-list/desktop-sidebar-facts' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type WindowFactsState = Pick< AppState, @@ -28,7 +29,7 @@ export function readWindowDesktopSidebarFacts(state: WindowFactsState): DesktopS ...getAllWorktreesFromState(state) .filter((worktree) => worktree.isPinned) .map((worktree) => worktree.id), - ...state.folderWorkspaces + ...selectFolderWorkspaces(state) .filter((folder) => folder.isPinned) .map((folder) => folderWorkspaceKey(folder.id)) ] diff --git a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts index 98a161a3a63..342b1a14577 100644 --- a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts @@ -12,6 +12,7 @@ import { describePairedServerHosts } from '../../../../shared/paired-server-host import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import type { Worktree } from '../../../../shared/worktree/types' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** The paired-server rows the window fetched itself, keyed by host-qualified id, for these hosts. */ export function readWindowPairedServerRows( @@ -48,7 +49,7 @@ export function readWindowPairedServerRows( for (const worktree of getAllWorktreesFromState(state)) { add(worktree, 'git') } - for (const folder of state.folderWorkspaces ?? []) { + for (const folder of selectFolderWorkspaces(state) ?? []) { add(folderWorkspaceToWorktree(folder), 'folder') } return rows diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts index e1bd2767f1f..b30c1a2f94d 100644 --- a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts @@ -13,6 +13,7 @@ import { isIdleWorkspaceActivity, type WorkspaceActivityFacts } from '../../../../shared/sidebar-list/workspace-activity-facts' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** The window's own activity facts, from the same store slices the sidebar filter reads. */ export function readWindowWorkspaceActivityFacts( @@ -81,7 +82,7 @@ export function toLocallyOwnedRows( const repo = repoById.get(worktree.repoId) own(worktree.id, worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : null)) } - for (const folder of state.folderWorkspaces ?? []) { + for (const folder of selectFolderWorkspaces(state) ?? []) { own(folderWorkspaceKey(folder.id), getRepoExecutionHostId(folder)) } const scoped = new Map() diff --git a/src/renderer/src/lib/structured-agent-session-launch-surface.ts b/src/renderer/src/lib/structured-agent-session-launch-surface.ts index d830e58197d..b6c8d641352 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-surface.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-surface.ts @@ -30,6 +30,7 @@ import { peekWebSessionFocusIntent, resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type LaunchSurface = { sessionId: string @@ -215,7 +216,7 @@ export function restoreStructuredLaunchSurfaces(): void { const catalog = { workspaces: [ ...state.allWorktrees(), - ...state.folderWorkspaces.flatMap((workspace) => { + ...selectFolderWorkspaces(state).flatMap((workspace) => { const group = findIndexedProjectGroupOwner(state.projectGroups, workspace.projectGroupId) const host = workspace.executionHostId || workspace.connectionId ? workspace : group const owned = host && { ...workspace, executionHostId: getCatalogOwnerHostId(host) } diff --git a/src/renderer/src/lib/unified-tab-host-ownership.ts b/src/renderer/src/lib/unified-tab-host-ownership.ts index 3507a892c26..dcd68036ee4 100644 --- a/src/renderer/src/lib/unified-tab-host-ownership.ts +++ b/src/renderer/src/lib/unified-tab-host-ownership.ts @@ -13,13 +13,16 @@ import type { AppState } from '@/store/types' import { dedupePaletteWorktrees } from './palette-repo-resolution' import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function getPaletteOwnershipWorktreeIds( state: Pick ): Pick[] { return [ - ...dedupePaletteWorktrees(Object.values(state.worktreesByRepo).flat()), - ...(state.folderWorkspaces ?? []).map((workspace) => ({ id: folderWorkspaceKey(workspace.id) })) + ...dedupePaletteWorktrees(Object.values(selectWorktreesByRepo(state)).flat()), + ...(selectFolderWorkspaces(state) ?? []).map((workspace) => ({ + id: folderWorkspaceKey(workspace.id) + })) ] } diff --git a/src/renderer/src/lib/unread-badge-count-selector.ts b/src/renderer/src/lib/unread-badge-count-selector.ts index 1e271165420..14f9aa3eb97 100644 --- a/src/renderer/src/lib/unread-badge-count-selector.ts +++ b/src/renderer/src/lib/unread-badge-count-selector.ts @@ -8,6 +8,7 @@ import { sameBucketRecords } from './bucket-record-equality' import { getUnreadBadgeCount } from './unread-badge-count' import { hasLiveTabMarker } from '@/store/slices/workspace-attention/workspace-live-markers' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' type UnreadBadgeCountState = Pick< AppState, @@ -54,7 +55,7 @@ function sameFolderAttention( ) { return true } - for (const folder of next.folderWorkspaces) { + for (const folder of selectFolderWorkspaces(next)) { if (!folder.isUnread) { continue } @@ -68,7 +69,7 @@ function sameFolderAttention( function sameCountInputs(previous: UnreadBadgeCountState, next: UnreadBadgeCountState): boolean { return ( - previous.folderWorkspaces === next.folderWorkspaces && + selectFolderWorkspaces(previous) === selectFolderWorkspaces(next) && previous.projectGroups === next.projectGroups && previous.repos === next.repos && previous.workspaceHostScope === next.workspaceHostScope && @@ -79,7 +80,11 @@ function sameCountInputs(previous: UnreadBadgeCountState, next: UnreadBadgeCount (!next.hideWorkspacesFromOtherDevices || (previous.runtimeEnvironments === next.runtimeEnvironments && previous.runtimeStatusByEnvironmentId === next.runtimeStatusByEnvironmentId)) && - sameBucketRecords(previous.worktreesByRepo, next.worktreesByRepo, sameBadgeWorktree) && + sameBucketRecords( + selectWorktreesByRepo(previous), + selectWorktreesByRepo(next), + sameBadgeWorktree + ) && sameFolderAttention(previous, next) ) } @@ -100,8 +105,8 @@ export function createUnreadBadgeCountSelector(): (state: UnreadBadgeCountState) return (state) => { if (!previousState || !sameCountInputs(previousState, state)) { unreadCount = getUnreadBadgeCount({ - worktreesByRepo: state.worktreesByRepo, - folderWorkspaces: state.folderWorkspaces, + worktreesByRepo: selectWorktreesByRepo(state), + folderWorkspaces: selectFolderWorkspaces(state), tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, unreadTerminalTabs: state.unreadTerminalTabs, diff --git a/src/renderer/src/lib/workspace-directory.ts b/src/renderer/src/lib/workspace-directory.ts index d6bcbf44981..d3a8d9740ec 100644 --- a/src/renderer/src/lib/workspace-directory.ts +++ b/src/renderer/src/lib/workspace-directory.ts @@ -1,6 +1,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { AppState } from '@/store/types' import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' // Why detected rows are optional: skill discovery does not subscribe to them, so a detected-only // workspace has no directory there. @@ -22,9 +23,9 @@ export function resolveWorkspaceDirectory( executionHostId?: ExecutionHostId | null ): string | null { const catalog = { - worktreesByRepo: state.worktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), detectedWorktreesByRepo: state.detectedWorktreesByRepo ?? NO_DETECTED_WORKTREES, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), floatingWorkspacePath: state.floatingWorkspacePath } return findKnownWorktreeById(catalog, worktreeId, executionHostId ?? undefined)?.path || null diff --git a/src/renderer/src/lib/workspace-doc-address-input.ts b/src/renderer/src/lib/workspace-doc-address-input.ts index 2fa7c5750ff..8a589f40682 100644 --- a/src/renderer/src/lib/workspace-doc-address-input.ts +++ b/src/renderer/src/lib/workspace-doc-address-input.ts @@ -8,6 +8,7 @@ import { htmlFilePathFromUrl } from '../../../shared/html-file-url' import { authorityKey } from '../../../shared/host-authority' import { resolveOwner } from './resolve-owner' import { translate } from '@/i18n/i18n' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' function workspaceHostKey(state: AppState, workspaceId: string): string | null { const match = resolveOwner(state, { workspaceId }) @@ -38,7 +39,7 @@ function ownedWorktreeRoots( roots.push({ worktreeId: worktree.id, root: worktree.path }) } } - for (const folder of state.folderWorkspaces) { + for (const folder of selectFolderWorkspaces(state)) { const worktreeId = folderWorkspaceKey(folder.id) if ( worktreeId !== currentWorktreeId && diff --git a/src/renderer/src/lib/workspace-row-owner-host.ts b/src/renderer/src/lib/workspace-row-owner-host.ts index d4d30a45d25..141ec20e2b5 100644 --- a/src/renderer/src/lib/workspace-row-owner-host.ts +++ b/src/renderer/src/lib/workspace-row-owner-host.ts @@ -3,6 +3,7 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { getFolderWorkspaceHostId } from '../../../shared/folder-workspace-execution-host' import type { AppState } from '../store/types' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** * The host that owns a workspace's own row; null while that row is unknown. Unlike @@ -15,7 +16,7 @@ export function workspaceOwnerHostId( ): ExecutionHostId | null { const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { - const workspace = state.folderWorkspaces.find(({ id }) => id === scope.folderWorkspaceId) + const workspace = selectFolderWorkspaces(state).find(({ id }) => id === scope.folderWorkspaceId) return workspace ? getFolderWorkspaceHostId(workspace, state.projectGroups) : null } const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 54dd82185a6..f611e66ed5b 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -39,6 +39,7 @@ import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar- import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { revealFloatingWorkspacePanel } from './floating-workspace-panel-reveal' import { markVisited, readPreviousActive } from './worktree-activation-visit' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -76,7 +77,7 @@ export function activateAndRevealFolderWorkspace( folderWorkspaceId, opts?.executionHostId ) - const folderWorkspace = state.folderWorkspaces.find( + const folderWorkspace = selectFolderWorkspaces(state).find( (workspace) => workspace === folderWorkspaceOwner ) if (!folderWorkspace) { diff --git a/src/renderer/src/lib/worktree-creation-ready-toast.tsx b/src/renderer/src/lib/worktree-creation-ready-toast.tsx index ffa7c53c66e..dc1e8ea3bf8 100644 --- a/src/renderer/src/lib/worktree-creation-ready-toast.tsx +++ b/src/renderer/src/lib/worktree-creation-ready-toast.tsx @@ -12,13 +12,17 @@ import { isFolderRepo } from '../../../shared/repo-kind' import type { ExecutionHostId } from '../../../shared/execution-host' import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { Worktree } from '../../../shared/worktree/types' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** Announces a create that finished after the user left its creation surface, instead of pulling them to it. */ export function showWorktreeCreationReadyToast( worktree: Pick, creationHostId?: ExecutionHostId ): void { - const owner = resolveIndexedWorktreeOwner(useAppStore.getState().worktreesByRepo, worktree.id) + const owner = resolveIndexedWorktreeOwner( + selectWorktreesByRepo(useAppStore.getState()), + worktree.id + ) const ownerHostId = creationHostId ?? (owner.kind === 'resolved' ? owner.owner.hostId : undefined) // Paired create responses name the server's host; use the renderer's ownership projection. const executionHostId = diff --git a/src/renderer/src/lib/worktree-jump-navigation.ts b/src/renderer/src/lib/worktree-jump-navigation.ts index cb3685c94b9..dc02621d533 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -7,11 +7,12 @@ import { worktreePassesSidebarFilters } from '@/components/sidebar/worktree-filt import { sidebarHasActiveFilters } from '@/components/sidebar/sidebar-filter-actions' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: ExecutionHostId): boolean { const state = useAppStore.getState() // Some lightweight callers/tests provide only the activation slice of state. - if (!state.worktreesByRepo || !sidebarHasActiveFilters(state)) { + if (!selectWorktreesByRepo(state) || !sidebarHasActiveFilters(state)) { return false } diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index 7a1763f06f3..bcf0336c260 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -22,7 +22,7 @@ type ProjectGroupOwnerRecord = Pick, - ReadonlyMap + ReadonlyMap >() const repoOwnerIndexCache = new WeakMap< readonly RepoOwnerRecord[], @@ -91,19 +91,17 @@ export function findIndexedWorktreeOwner( return resolution.kind === 'resolved' ? resolution.owner : null } -export function findIndexedWorktreeOwnerForHost( - worktreesByRepo: Record | undefined, +/** Returns the caller's own row type: the index records where the owner is listed. */ +export function findIndexedWorktreeOwnerForHost( + worktreesByRepo: Record | undefined, worktreeId: string, executionHostId: ExecutionHostId -): WorktreeOwnerRecord | null { - if (!worktreesByRepo) { - return null - } +): T | null { + const hostKey = `${worktreeId}\0${executionHostId}` resolveIndexedWorktreeOwner(worktreesByRepo, worktreeId) - const resolution = worktreeOwnerIndexCache - .get(worktreesByRepo) - ?.get(`${worktreeId}\0${executionHostId}`) - return resolution?.kind === 'resolved' ? resolution.owner : null + const entry = worktreesByRepo && worktreeOwnerIndexCache.get(worktreesByRepo)?.get(hostKey) + const row = entry?.kind === 'resolved' ? worktreesByRepo?.[entry.bucket]?.[entry.position] : null + return row ?? null } export type IndexedRepoOwnerResolution = @@ -152,6 +150,11 @@ export type IndexedWorktreeOwnerResolution = | { kind: 'missing' } | { kind: 'ambiguous' } +// Where the owner is listed, so a typed caller reads its own row back without a cast. +type IndexedWorktreeOwnerEntry = + | { kind: 'resolved'; owner: WorktreeOwnerRecord; bucket: string; position: number } + | { kind: 'ambiguous' } + function worktreeOwnerIdentity(owner: WorktreeOwnerRecord): string { return JSON.stringify([ owner.repoId, @@ -161,13 +164,14 @@ function worktreeOwnerIdentity(owner: WorktreeOwnerRecord): string { } function addWorktreeOwnerIndexEntry( - index: Map, + index: Map, key: string, - owner: WorktreeOwnerRecord + owner: WorktreeOwnerRecord, + at: { bucket: string; position: number } ): void { const current = index.get(key) if (!current) { - index.set(key, { kind: 'resolved', owner }) + index.set(key, { kind: 'resolved', owner, ...at }) } else if ( current.kind === 'resolved' && worktreeOwnerIdentity(current.owner) !== worktreeOwnerIdentity(owner) @@ -198,15 +202,15 @@ export function resolveIndexedWorktreeOwner( } let index = worktreeOwnerIndexCache.get(worktreesByRepo) if (!index) { - const next = new Map() - for (const worktrees of Object.values(worktreesByRepo)) { - for (const worktree of worktrees) { + const next = new Map() + for (const [bucket, worktrees] of Object.entries(worktreesByRepo)) { + worktrees.forEach((worktree, position) => { const id = worktree.id - addWorktreeOwnerIndexEntry(next, id, worktree) + addWorktreeOwnerIndexEntry(next, id, worktree, { bucket, position }) for (const hostId of worktreeOwnerHostIds(worktree)) { - addWorktreeOwnerIndexEntry(next, `${id}\0${hostId}`, worktree) + addWorktreeOwnerIndexEntry(next, `${id}\0${hostId}`, worktree, { bucket, position }) } - } + }) } index = next worktreeOwnerIndexCache.set(worktreesByRepo, index) diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts index f7e40da59ae..7084a23330e 100644 --- a/src/renderer/src/runtime/server-layout-frames.ts +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -16,6 +16,10 @@ import { runtimeHostConnectionStateForEntry } from './runtime-host-connection-state' import { createServerLayoutFrameTranslator } from './server-layout-frame-ids' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' import { serverHeldPanes, showServerLayoutHeldPanes } from './server-held-panes' import { createServerViewReport, onServerControlConnected } from './server-layout-view-report' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' @@ -265,8 +269,8 @@ export function installServerLayoutFrames(store: FramesStore): () => void { function routingInputs(state: AppState): unknown[] { return [ state.repos, - state.worktreesByRepo, - state.detectedWorktreesByRepo, + selectWorktreesByRepo(state), + selectDetectedWorktreesByRepo(state), state.projectGroups, state.restoredRuntimeHostIdByWorkspaceSessionKey, state.runtimeEnvironments, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index d692a5b83d3..9b076c4801c 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -23,6 +23,7 @@ import { getRuntimeLeafIdsForTerminal, resolveMobileTabWideAgentHintLeafId } from './mobile-session-surfaces' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) { @@ -86,7 +87,7 @@ export function buildMobileSessionAgentStatusByWorktree( // single identity is correct, so publish without one and let main fall back to // its generation fence rather than blank the mobile session. function resolveWorktreeInstanceId(state: AppState, worktreeId: string): string | undefined { - const rows = getIndexedWorktreesById(state.worktreesByRepo ?? {}, worktreeId) + const rows = getIndexedWorktreesById(selectWorktreesByRepo(state) ?? {}, worktreeId) return rows.length === 1 ? rows[0]?.instanceId : undefined } diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts index 7dc106a6cd4..03da3957d22 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -33,6 +33,7 @@ import { buildMobileMarkdownTab, buildMobileFileTab } from './mobile-session-edi import { buildMobileBrowserTab } from './mobile-session-browser-tabs' import type { MobileSessionPublicationInputs } from './types' import { canReuseMobileSessionSnapshot } from './mobile-session-capture' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' export function buildMobileSessionTabSnapshots( state: AppState, @@ -55,7 +56,7 @@ export function buildMobileSessionTabSnapshots( terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) } const liveFolderWorkspaceIds = new Set( - (state.folderWorkspaces ?? []).map((workspace) => workspace.id) + (selectFolderWorkspaces(state) ?? []).map((workspace) => workspace.id) ) const worktreeIds = new Set([ ...Object.keys(state.tabsByWorktree), diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts index d3678eae1f0..e929381e3c2 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts @@ -18,6 +18,8 @@ import { } from './sync-projections' import { resolveMobileTerminalTheme } from './mobile-terminal-theme' import type { RuntimeMobileSessionSyncKey } from './types' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' + function getTerminalThemeSystemPrefersDark( state: Pick, systemPrefersDark: boolean | null | undefined @@ -59,7 +61,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild( state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && - state.folderWorkspaces === previousState.folderWorkspaces && + selectFolderWorkspaces(state) === selectFolderWorkspaces(previousState) && state.agentStatusEpoch === previousState.agentStatusEpoch && state.agentStatusByPaneKey === previousState.agentStatusByPaneKey ) @@ -89,7 +91,7 @@ export function getRuntimeMobileSessionSyncKey( terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, - folderWorkspaces: state.folderWorkspaces, + folderWorkspaces: selectFolderWorkspaces(state), groupsByWorktree: state.groupsByWorktree, activeGroupIdByWorktree: state.activeGroupIdByWorktree, layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, diff --git a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts index 8800bbeed83..aca2a760036 100644 --- a/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts +++ b/src/renderer/src/runtime/use-runtime-session-mirror-environment-key.ts @@ -3,6 +3,10 @@ import { useShallow } from 'zustand/react/shallow' import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export type RuntimeSessionMirrorTargetInputs = Pick< AppState, @@ -23,8 +27,8 @@ export function selectRuntimeSessionMirrorTargetInputs( return { activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId ?? null, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), projectGroups: state.projectGroups, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, runtimeEnvironments: state.runtimeEnvironments, @@ -52,8 +56,8 @@ export function buildRuntimeSessionMirrorEnvironmentKeys( const targets = getReachableRuntimeSessionMirrorTargets({ settings: { activeRuntimeEnvironmentId: inputs.activeRuntimeEnvironmentId }, repos: inputs.repos, - worktreesByRepo: inputs.worktreesByRepo, - detectedWorktreesByRepo: inputs.detectedWorktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(inputs), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(inputs), projectGroups: inputs.projectGroups, restoredRuntimeHostIdByWorkspaceSessionKey: inputs.restoredRuntimeHostIdByWorkspaceSessionKey, runtimeEnvironments: inputs.runtimeEnvironments, diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status.ts b/src/renderer/src/store/folder-workspaces/folder-path-status.ts index 543b6775723..ab56eb018a4 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status.ts @@ -17,6 +17,7 @@ import { } from '../../runtime/runtime-client-target' import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' import { getFolderWorkspaceHostId } from '../../../../shared/folder-workspace-execution-host' +import { selectFolderWorkspaces } from '../workspace-record-selectors' type FolderPathStatusSnapshotState = Pick< AppState, @@ -59,7 +60,7 @@ function getFolderPathStatusScope( } : null } - const folders = state.folderWorkspaces.filter( + const folders = selectFolderWorkspaces(state).filter( (workspace) => workspace.id === request.folderWorkspaceId && catalogOwnsHost(ownerHostId, getFolderWorkspaceHostId(workspace, state.projectGroups)) diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts index 3043f755300..b1b4f751251 100644 --- a/src/renderer/src/store/github/refresh-sweep-actions.ts +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -7,6 +7,7 @@ import { issueCacheKey, prCacheKey } from './cache-identity' import { CACHE_TTL, evictStaleEntries } from './cache-policy' import { pruneExpiredPRRefreshStates } from './pr-refresh-state' import { settingsForGitHubRepoOwner } from './work-item-routing' +import { selectWorktreesByRepo } from '../workspace-record-selectors' export const createRefreshSweepActions = ( set: Parameters>[0], @@ -47,7 +48,7 @@ export const createRefreshSweepActions = ( const now = Date.now() const repoLookup = getGitHubRepoLookupIndex(state.repos) - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(selectWorktreesByRepo(state))) { for (const wt of worktrees) { const repo = repoLookup.findById(wt.repoId) if (!repo) { @@ -77,7 +78,7 @@ export const createRefreshSweepActions = ( refreshGitHubForWorktree: (worktreeId) => { const state = get() let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(selectWorktreesByRepo(state))) { worktree = worktrees.find((w) => w.id === worktreeId) if (worktree) { break diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts index 12ffa3501ab..75e67995960 100644 --- a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -6,6 +6,7 @@ import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility import { issueCacheKey } from './cache-identity' import { CACHE_TTL } from './cache-policy' import { settingsForGitHubRepoOwner } from './work-item-routing' +import { selectWorktreesByRepo } from '../workspace-record-selectors' export const createStaleWorktreeRefreshActions = ( get: Parameters>[1] @@ -14,7 +15,7 @@ export const createStaleWorktreeRefreshActions = ( refreshGitHubForWorktreeIfStale: (worktreeId) => { const state = get() let worktree: Worktree | undefined - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(selectWorktreesByRepo(state))) { worktree = worktrees.find((w) => w.id === worktreeId) if (worktree) { break diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts index 9b55f9c46c5..d750c7a74cd 100644 --- a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts @@ -21,6 +21,7 @@ import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index' import { getPRRefreshRuntimeRepoTarget } from './repository-routing' import type { VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler' import { shouldCoordinateVisibleGitHubReview } from './visible-hosted-review-refresh-ownership' +import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '../workspace-record-selectors' export function* getVisibleHostedReviewWorkspaces( state: AppState, @@ -33,7 +34,7 @@ export function* getVisibleHostedReviewWorkspaces( : null) for (const id of state.visibleReviewWorktreeIds) { const owners = new Set(getWorktreeLookupIndex(state).byId.get(id)?.owners) - for (const detected of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, id)) { + for (const detected of findIndexedDetectedWorktrees(selectDetectedWorktreesByRepo(state), id)) { const workspace = findKnownWorktreeById(state, id, detected.hostId ?? 'local') if (workspace) { owners.add(workspace) @@ -216,8 +217,8 @@ export function visibleHostedReviewRefreshInputsChanged( state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId || state.visibleWorkspaceHostIds !== previous.visibleWorkspaceHostIds || state.workspaceHostScope !== previous.workspaceHostScope || - state.worktreesByRepo !== previous.worktreesByRepo || - state.detectedWorktreesByRepo !== previous.detectedWorktreesByRepo || + selectWorktreesByRepo(state) !== selectWorktreesByRepo(previous) || + selectDetectedWorktreesByRepo(state) !== selectDetectedWorktreesByRepo(previous) || state.repos !== previous.repos || state.settings !== previous.settings || state.sshConnectionStates !== previous.sshConnectionStates || diff --git a/src/renderer/src/store/github/worktree-refresh.ts b/src/renderer/src/store/github/worktree-refresh.ts index 8588803741e..c86e7c06e4b 100644 --- a/src/renderer/src/store/github/worktree-refresh.ts +++ b/src/renderer/src/store/github/worktree-refresh.ts @@ -14,6 +14,7 @@ import { prCacheKey } from './cache-identity' import { settingsForGitHubRepoOwner } from './work-item-routing' import { githubHostedReviewFallbackPRNumber } from './pr-result-routing' import type { GitHubPRFallbackSource } from './cache-model' +import { selectWorktreesByRepo } from '../workspace-record-selectors' export function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null @@ -35,7 +36,7 @@ const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] export function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { + for (const worktrees of Object.values(selectWorktreesByRepo(state) ?? EMPTY_WORKTREES_BY_REPO)) { for (const worktree of worktrees) { const worktreeId = worktree.id const existing = byId.get(worktreeId) @@ -67,7 +68,7 @@ const worktreeLookupIndexes = new WeakMap< >() export function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { - const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const worktreesByRepo = selectWorktreesByRepo(state) ?? EMPTY_WORKTREES_BY_REPO const repos = state.repos ?? EMPTY_WORKTREE_REPOS const cached = worktreeLookupIndexes.get(worktreesByRepo) if (cached && cached.repos === repos) { diff --git a/src/renderer/src/store/repos/recipe-vm-project-removal.ts b/src/renderer/src/store/repos/recipe-vm-project-removal.ts index 429d7ecf078..bde6ba89c75 100644 --- a/src/renderer/src/store/repos/recipe-vm-project-removal.ts +++ b/src/renderer/src/store/repos/recipe-vm-project-removal.ts @@ -7,6 +7,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { AppState } from '../types' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' import { getRecipeVmSshTargetForHost } from '@/lib/recipe-vm-managed-host' +import { selectWorktreesByRepo } from '../workspace-record-selectors' /** * Destroys the recipe VM behind a project before the project is removed, so it never leaks. @@ -20,7 +21,7 @@ export function destroyRecipeVmBeforeProjectRemoval( workspaceIds: string[] ): Promise | false { const knownServerTarget = getRecipeVmSshTargetForHost(state.runtimeEnvironments, ownerHostId) - const hasProvisionedRoot = (state.worktreesByRepo[ownerRepo.id] ?? []).some( + const hasProvisionedRoot = (selectWorktreesByRepo(state)[ownerRepo.id] ?? []).some( (worktree) => (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === ownerHostId && worktree.ephemeralVmCheckoutMode === 'provisioned-root' diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index d0ec3c3476f..1213b524298 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -21,6 +21,7 @@ import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' import { warnIfProjectKnownInAnotherProfile } from '../projects/project-profile-presence' import { warnIfProjectCrossesWslFilesystemBoundary } from '../projects/project-wsl-filesystem-boundary-advisory' +import { selectWorktreesByRepo } from '../workspace-record-selectors' export function createRepoAddActions( set: Parameters>[0], @@ -181,7 +182,7 @@ export function createRepoAddActions( ? toRuntimeExecutionHostId(options.runtimeEnvironmentId) : LOCAL_EXECUTION_HOST_ID await get().fetchWorktrees(repo.id, executionHostId ? { executionHostId } : undefined) - const folderWorktree = get().worktreesByRepo[repo.id]?.find( + const folderWorktree = selectWorktreesByRepo(get())[repo.id]?.find( (worktree) => executionHostId === undefined || worktree.hostId === executionHostId ) if (folderWorktree) { diff --git a/src/renderer/src/store/repos/repo-removal-host-twin-attribution.ts b/src/renderer/src/store/repos/repo-removal-host-twin-attribution.ts index ba8d00280d1..36b213336ad 100644 --- a/src/renderer/src/store/repos/repo-removal-host-twin-attribution.ts +++ b/src/renderer/src/store/repos/repo-removal-host-twin-attribution.ts @@ -13,6 +13,7 @@ import { getOpenFileExecutionHostId, hasOpenFileExecutionHostEvidence } from '@/lib/unified-tab-host-ownership' +import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '../workspace-record-selectors' /** * State under a worktree id that a surviving host also publishes, attributed to the removed host. @@ -96,8 +97,8 @@ export function removedHostCanonicalizer( ): (evidence: HostEvidence) => HostEvidence { const repoId = getRepoIdFromWorktreeId(worktreeId) const removedRow = [ - ...(state.worktreesByRepo[repoId] ?? []), - ...(state.detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ...(selectWorktreesByRepo(state)[repoId] ?? []), + ...(selectDetectedWorktreesByRepo(state)[repoId]?.worktrees ?? []) ].find((row) => row.id === worktreeId && row.hostId === removedHostId) return (evidence) => evidence && diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 910da8d02b7..13fcac56d8d 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -18,6 +18,7 @@ import { getIndexedWorktreesById as getCachedWorktreesById } from './worktree-repo-index' import { findKnownWorktreeById } from './slices/worktrees/listing/detected-worktree-meta' +import { selectWorktreesByRepo } from './workspace-record-selectors' export { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' export { @@ -120,13 +121,13 @@ export function resetFloatingWorkspaceUnreadSelectorCacheForTest(): void { } export function getAllWorktreesFromState(state: Pick): Worktree[] { - return getCachedAllWorktrees(state.worktreesByRepo) + return getCachedAllWorktrees(selectWorktreesByRepo(state)) } export function getWorktreeMapFromState( state: Pick ): Map { - return getCachedWorktreeMap(state.worktreesByRepo) + return getCachedWorktreeMap(selectWorktreesByRepo(state)) } /** @@ -139,12 +140,12 @@ export function getWorktreeOnHostFromState( worktreeId: string, hostId: ExecutionHostId | undefined ): Worktree | undefined { - const rows = getCachedWorktreesById(state.worktreesByRepo, worktreeId) + const rows = getCachedWorktreesById(selectWorktreesByRepo(state), worktreeId) return hostId ? rows.find((row) => row.hostId === hostId) : rows[0] } export function getHasAnyWorktreesFromState(state: Pick): boolean { - return getCachedHasAnyWorktrees(state.worktreesByRepo) + return getCachedHasAnyWorktrees(selectWorktreesByRepo(state)) } export function getRepoMapFromState(state: Pick): Map { @@ -226,9 +227,13 @@ export const useProjectHostSetupProjection = () => // ─── Worktrees ────────────────────────────────────────────────────── export const useActiveWorktreeId = () => useAppStore((s) => s.activeWorktreeId) export const useWorktreesForRepo = (repoId: string | null) => - useAppStore((s) => (repoId ? (s.worktreesByRepo[repoId] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES)) -export const useAllWorktrees = () => useAppStore((s) => getCachedAllWorktrees(s.worktreesByRepo)) -export const useWorktreeMap = () => useAppStore((s) => getCachedWorktreeMap(s.worktreesByRepo)) + useAppStore((s) => + repoId ? (selectWorktreesByRepo(s)[repoId] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES + ) +export const useAllWorktrees = () => + useAppStore((s) => getCachedAllWorktrees(selectWorktreesByRepo(s))) +export const useWorktreeMap = () => + useAppStore((s) => getCachedWorktreeMap(selectWorktreesByRepo(s))) type WorktreeLookupHostState = Pick // Why: worktree ids repeat across hosts, so the active id must resolve on the host the user selected. diff --git a/src/renderer/src/store/settled-sort-epoch.ts b/src/renderer/src/store/settled-sort-epoch.ts index 409d078a539..c2aada4d35f 100644 --- a/src/renderer/src/store/settled-sort-epoch.ts +++ b/src/renderer/src/store/settled-sort-epoch.ts @@ -15,6 +15,7 @@ import { } from '../../../shared/sidebar-list/sort-settle' import type { AppState } from './types' import { getIndexedAllWorktrees } from './worktree-repo-index' +import { selectWorktreesByRepo } from './workspace-record-selectors' function countLiveWorktrees(worktreesByRepo: AppState['worktreesByRepo']): number { let count = 0 @@ -52,12 +53,12 @@ export function installSettledSortEpoch( api.subscribe((state, previous) => { const epochBumped = state.sortEpoch !== previous.sortEpoch // Why also on row writes: a bump-less add during a pending window must still settle now. - const rowsChanged = state.worktreesByRepo !== previous.worktreesByRepo + const rowsChanged = selectWorktreesByRepo(state) !== selectWorktreesByRepo(previous) dispatch({ kind: 'write', change: epochBumped || rowsChanged - ? { epochBumped, liveCount: countLiveWorktrees(state.worktreesByRepo) } + ? { epochBumped, liveCount: countLiveWorktrees(selectWorktreesByRepo(state)) } : null, settled: state.settledSortEpoch === state.sortEpoch, sortBy: state.sortBy, diff --git a/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts index e4e94c16049..16b2945b209 100644 --- a/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts +++ b/src/renderer/src/store/slices/agent-status-connection-worktree-scope.ts @@ -3,6 +3,7 @@ import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../../../../shared/execution-host' +import { selectWorktreesByRepo } from '../workspace-record-selectors' // Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never // matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host @@ -24,7 +25,7 @@ export function collectWorktreeIdsForConnection( const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) const onConnection = new Set() const onOtherHost = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + for (const [repoId, worktrees] of Object.entries(selectWorktreesByRepo(state))) { const repo = repoById.get(repoId) for (const worktree of worktrees) { const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index e3b32623c18..5dd58f5f382 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -9,6 +9,7 @@ import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-cli import { findWorkspace } from '../browser-page-records' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import { recordLayoutApply } from '@/runtime/layout-apply-send' +import { selectFolderWorkspaces } from '../../workspace-record-selectors' export function createBrowserHydrationActions( set: BrowserSliceSet, @@ -23,7 +24,7 @@ export function createBrowserHydrationActions( Object.keys(persistedTabsByWorktree) ) validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of currentState.folderWorkspaces) { + for (const workspace of selectFolderWorkspaces(currentState)) { validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) @@ -47,7 +48,7 @@ export function createBrowserHydrationActions( Object.keys(persistedTabsByWorktree) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { + for (const workspace of selectFolderWorkspaces(s)) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) diff --git a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts index 74373337a6e..4776ae9a721 100644 --- a/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts @@ -16,6 +16,7 @@ import { findWorktreeById, getRepoIdFromWorktreeId } from '../../worktree-helper import type { OpenFile } from '../types/open-file' import { openWorkspaceEditorItem } from '../tabs/workspace-editor-item' import { reconcileCheckDetailsReadingCache } from '@/components/editor/check-details-reading-cache' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' export function createCheckRunDetailsActions( set: EditorSet, @@ -191,7 +192,7 @@ export function createCheckRunDetailsActions( requestId }) } - const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(state), file.worktreeId) const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) const repo = state.repos.find((candidate) => candidate.id === repoId) if (!repo?.path) { diff --git a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts index 75c94fb5e96..6a2b2609914 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts @@ -6,6 +6,7 @@ import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import { buildValidWorktreeIdsForSessionHydration } from '../../../../../../shared/worktree/degraded-repo-worktree-validity' import { migrateHydratedEditorTabsAndGroups } from '../file-ids/hydrated-editor-file-ids' import { buildHydratedEditorSessionState } from './hydrated-editor-session-state' +import { selectFolderWorkspaces } from '../../../workspace-record-selectors' export function createHydrateEditorSession( set: EditorSet, @@ -19,7 +20,7 @@ export function createHydrateEditorSession( Object.keys(session.openFilesByWorktree ?? {}) ) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { + for (const workspace of selectFolderWorkspaces(s)) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) diff --git a/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts b/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts index abf08ba2cb7..562d2fc92b7 100644 --- a/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts +++ b/src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts @@ -9,6 +9,7 @@ import { deleteRuntimeRelativePath } from '@/runtime/runtime-file-mutation-client' import { statRuntimePath } from '@/runtime/runtime-file-metadata-client' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' export function deleteUntouchedUntitledFile(getState: () => AppState, file: OpenFile): void { const hasRetainedView = () => @@ -17,7 +18,7 @@ export function deleteUntouchedUntitledFile(getState: () => AppState, file: Open return } const state = getState() - const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(state), file.worktreeId) const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() let context: ReturnType try { diff --git a/src/renderer/src/store/slices/ssh-target-cleanup.ts b/src/renderer/src/store/slices/ssh-target-cleanup.ts index d42d41a6c24..15331f927db 100644 --- a/src/renderer/src/store/slices/ssh-target-cleanup.ts +++ b/src/renderer/src/store/slices/ssh-target-cleanup.ts @@ -4,6 +4,7 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { sanitizeSshTargetGeneration } from '../../../../shared/ssh-target-generation' import { resolveDirectSshTargetWorktreeIds } from '../../lib/direct-ssh-target-scope' import { unbindTerminalTabPtys } from './tabs/worktree-layout-lifecycle' +import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '../workspace-record-selectors' export function sshTargetLabelsEqual( labels: Map, @@ -47,12 +48,12 @@ function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set< const targetWorktreeIds = resolveDirectSshTargetWorktreeIds({ targetId, repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, + worktreesByRepo: selectWorktreesByRepo(state), + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(state), restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey }) const tabIds = new Set() - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(selectWorktreesByRepo(state))) { for (const worktree of worktrees) { if (!targetWorktreeIds.has(worktree.id)) { continue diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index 294675f708c..ab6d4ffd1d0 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -21,6 +21,7 @@ import { resolveUnifiedTabCreatePlacement } from './tabs-create-placement' import { emptySplitGroup, insertSplitGroup } from './tabs-group-actions' import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' import { createdTabPreviewFlag } from './tab-preview-flag' +import { selectFolderWorkspaces } from '../../workspace-record-selectors' export function createTabsCreateActions( set: TabsSliceSet, @@ -55,7 +56,7 @@ export function createTabsCreateActions( lookupWorktrees: () => [ ...(state.allWorktrees?.() ?? []), - ...(state.folderWorkspaces ?? []).map(folderWorkspaceToWorktree) + ...(selectFolderWorkspaces(state) ?? []).map(folderWorkspaceToWorktree) ].filter((worktree) => worktree.id === worktreeId) }) const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 6fb187f53aa..6dd1debae6b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -11,6 +11,7 @@ import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' import type { AppState } from '../../types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { selectFolderWorkspaces } from '../../workspace-record-selectors' function replaceWorkspaceRecordKeys( current: Record, @@ -87,7 +88,7 @@ export function createTabsSessionActions( const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of state.folderWorkspaces) { + for (const workspace of selectFolderWorkspaces(state)) { validWorktreeIds.add(folderWorkspaceKey(workspace.id)) } addAdditionalValidWorkspaceKeys(validWorktreeIds, options) diff --git a/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts b/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts index 2b24b8aa387..0895cc26aa8 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts @@ -21,6 +21,7 @@ import { } from '@/lib/worktree-runtime-owner-index' import { translate } from '@/i18n/i18n' import { getWorkspaceCleanupAgentStartedFailure } from './workspace-cleanup-preflight-failures' +import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '../workspace-record-selectors' export function assertWorkspaceCleanupAgentStopApproved( state: AppState, @@ -110,8 +111,12 @@ export function getWorkspaceCleanupAgentEvidence( evidence.set(identity, 'unknown') } else { const owner = - findIndexedWorktreeOwnerForHost(state.worktreesByRepo, worktreeId, candidateHost) ?? - findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId).find( + findIndexedWorktreeOwnerForHost( + selectWorktreesByRepo(state), + worktreeId, + candidateHost + ) ?? + findIndexedDetectedWorktrees(selectDetectedWorktreesByRepo(state), worktreeId).find( (worktree) => isExecutionHostAliasForWorktree(candidateHost, worktree) ) if (isExecutionHostAliasForWorktree(hostId, owner ?? { hostId: candidateHost })) { diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.ts index a3ad0e4c865..90a0fca36c9 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.ts @@ -10,6 +10,7 @@ import { enrichWorkspaceCleanupCandidatesWithCache, type WorkspaceCleanupEnrichmentCacheEntry } from './workspace-cleanup-candidate-enrichment' +import { selectDetectedWorktreesByRepo, selectWorktreesByRepo } from '../workspace-record-selectors' let latestWorkspaceCleanupScanToken = 0 let finalizedWorkspaceCleanupScanToken = 0 @@ -210,8 +211,8 @@ function buildWorkspaceCleanupLocalStateToken(state: AppState): readonly unknown state.retainedAgentsByPaneKey, state.agentStatusByPaneKey, state.unifiedTabsByWorktree, - state.worktreesByRepo, - state.detectedWorktreesByRepo, + selectWorktreesByRepo(state), + selectDetectedWorktreesByRepo(state), state.runtimeAgentOrchestrationByPaneKey, state.paneForegroundAgentByPaneKey, state.lastVisitedAtByWorktreeId, diff --git a/src/renderer/src/store/slices/worktree-nav-history.ts b/src/renderer/src/store/slices/worktree-nav-history.ts index 67a4597cab9..04e2529bec3 100644 --- a/src/renderer/src/store/slices/worktree-nav-history.ts +++ b/src/renderer/src/store/slices/worktree-nav-history.ts @@ -10,6 +10,7 @@ import { type TaskSourceContext } from '../../../../shared/task-source-context' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '../workspace-record-selectors' // Why: bound per-session history growth; 50 keeps goBack/goForward's linear scans cheap yet is never hit in normal use. const MAX_HISTORY = 50 @@ -139,11 +140,11 @@ function isLiveEntry(entry: WorktreeNavHistoryEntry, state: AppState): boolean { } const workspaceScope = parseWorkspaceKey(entry) if (workspaceScope?.type === 'folder') { - return state.folderWorkspaces.some( + return selectFolderWorkspaces(state).some( (workspace) => workspace.id === workspaceScope.folderWorkspaceId ) } - return findWorktreeById(state.worktreesByRepo, entry) !== undefined + return findWorktreeById(selectWorktreesByRepo(state), entry) !== undefined } function appendHistoryEntry( diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts index 8fd85db1cd8..e70d3e59b28 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts @@ -14,6 +14,7 @@ import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '../../../workspace-record-selectors' export type WorktreeCreateParentPick = { /** Workspace the create attaches to. Undefined once a stale pick is dropped. */ @@ -33,7 +34,7 @@ export function resolveWorktreeCreateParent( executionHostId: ExecutionHostId ): WorktreeCreateParentPick { const picked = requestedParentWorktreeId - ? getIndexedWorktreesById(state.worktreesByRepo, requestedParentWorktreeId).find( + ? getIndexedWorktreesById(selectWorktreesByRepo(state), requestedParentWorktreeId).find( (candidate) => { const repo = state.repos.find((owner) => repoMatchesHostIdentity(owner, candidate.repoId, candidate.hostId ?? executionHostId) @@ -74,7 +75,7 @@ function parentLabel(state: AppState, parent: WorktreeCreateParentPick): string if (scope?.type !== 'folder') { return null } - const workspace = state.folderWorkspaces?.find( + const workspace = selectFolderWorkspaces(state)?.find( (candidate) => candidate.id === scope.folderWorkspaceId ) return workspace?.name.trim() || null diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts index d894d570001..74171ea4272 100644 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts @@ -15,12 +15,15 @@ import { floatingWorkspaceToWorktree, isFloatingWorkspaceId } from '../../../../../../shared/floating-workspace-worktree' -import { - findIndexedDetectedWorktrees, - findIndexedWorktreeOwnerForHost -} from '@/lib/worktree-runtime-owner-index' +import { findIndexedDetectedWorktrees } from '@/lib/worktree-runtime-owner-index' import { findWorktreeById, withoutErasedRequiredWorktreeFields } from '../../worktree-helpers' import { worktreeMatchesHost } from './worktree-host-ownership' +import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity' +import { + selectFolderWorkspaces, + selectWorkspaceByHostTarget, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' const folderWorkspaceWorktreeCache = new WeakMap() // Why a one-slot cache: the floating workspace is a singleton; retained selectors only need the @@ -106,7 +109,7 @@ export function findKnownWorktreeById( } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( + const folderWorkspace = selectFolderWorkspaces(state).find( (workspace) => workspace.id === workspaceScope.folderWorkspaceId && (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) @@ -114,12 +117,11 @@ export function findKnownWorktreeById( return folderWorkspace ? getFolderWorkspaceWorktree(folderWorkspace) : undefined } const visible = executionHostId - ? (findIndexedWorktreeOwnerForHost( - state.worktreesByRepo, - worktreeId, - executionHostId - ) as Worktree | null) - : findWorktreeById(state.worktreesByRepo, worktreeId) + ? selectWorkspaceByHostTarget( + state, + hostWorktreeTarget({ id: worktreeId, hostId: executionHostId }) + ) + : findWorktreeById(selectWorktreesByRepo(state), worktreeId) if (visible) { return visible } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts index a1a8de27f51..b9d3d1d1168 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts @@ -20,6 +20,11 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { mapReposForWorktreeRefresh } from './worktree-refresh-pool' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' export function createFetchAllWorktrees( set: WorktreeSliceSet, @@ -41,7 +46,7 @@ export function createFetchAllWorktrees( await mapReposForWorktreeRefresh(repos, async (r) => { try { const requestStartedState = get() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] + const requestStartedWorktrees = selectWorktreesByRepo(requestStartedState)[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) const parsedHost = parseExecutionHostId(hostId) @@ -100,7 +105,7 @@ export function createFetchAllWorktrees( > => { try { const requestStartedState = get() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] + const requestStartedWorktrees = selectWorktreesByRepo(requestStartedState)[r.id] const hostId = getRepoExecutionHostId(r) const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) const parsedHost = parseExecutionHostId(hostId) @@ -187,7 +192,7 @@ export function createFetchAllWorktrees( // Why: floating is persisted renderer state, not a repo worktree an authoritative scan returns. validIds.add(FLOATING_TERMINAL_WORKTREE_ID) // Why: folder workspaces persist tabs under `folder:` keys that authoritative repo scans never return. - for (const workspace of get().folderWorkspaces ?? []) { + for (const workspace of selectFolderWorkspaces(get()) ?? []) { validIds.add(folderWorkspaceKey(workspace.id)) } for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { @@ -195,7 +200,7 @@ export function createFetchAllWorktrees( validIds.add(key) } } - for (const result of Object.values(get().detectedWorktreesByRepo)) { + for (const result of Object.values(selectDetectedWorktreesByRepo(get()))) { if (!result.authoritative) { continue } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts index 5ccd922d9b8..1ed7c54490d 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts @@ -25,6 +25,7 @@ import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { refreshRemoteWorktreeLineageBestEffort } from '../metadata/worktree-lineage-refresh' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' export function createFetchWorktrees( set: WorktreeSliceSet, @@ -52,7 +53,7 @@ export function createFetchWorktrees( options && 'directSshAuthority' in options ? options.directSshAuthority : undefined try { const ownerState = get() - const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] + const requestStartedWorktrees = selectWorktreesByRepo(ownerState)[repoId] const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) const ownerWasMissingAtStart = repoOwners.length === 0 const hasLocalOwner = repoOwners.some( diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index 37f9434e656..748efb01ae9 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -15,6 +15,10 @@ import { import { toVisibleWorktree } from './worktree-catalog-visibility' import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host' import type { WorktreeHostMatchOptions, RepoHostSummary } from './worktree-slice-types' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' export function withRepoHostOwnership< T extends { @@ -247,7 +251,7 @@ export function getKnownWorktreeIdsForPurge( repoId: string, hostId: ExecutionHostId ): string[] { - const detected = state.detectedWorktreesByRepo[repoId] + const detected = selectDetectedWorktreesByRepo(state)[repoId] const knownIds = new Set() const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) if (detected?.authoritative === true) { @@ -257,7 +261,7 @@ export function getKnownWorktreeIdsForPurge( } } } else { - for (const worktree of state.worktreesByRepo[repoId] ?? []) { + for (const worktree of selectWorktreesByRepo(state)[repoId] ?? []) { if (worktreeMatchesHost(worktree, hostId, matchOptions)) { knownIds.add(worktree.id) } diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts index 294d1f0308e..2196036b0d8 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts @@ -9,6 +9,7 @@ import { getRepoIdFromWorktreeId } from '../../worktree-helpers' import { isRuntimeSelectorNotFoundError } from './runtime-worktree-rpc-errors' import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' import type { WorktreeSliceGet } from './worktree-slice-types' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' /** Replaces only the copy on `hostId`; a same-id twin on another host is a different worktree. */ export function replaceWorktreeInRepoLists( @@ -17,13 +18,13 @@ export function replaceWorktreeInRepoLists( hostId: ExecutionHostId ): Record { const repoId = getRepoIdFromWorktreeId(updatedWorktree.id) - const current = state.worktreesByRepo[repoId] + const current = selectWorktreesByRepo(state)[repoId] if (!current) { - return state.worktreesByRepo + return selectWorktreesByRepo(state) } const options = worktreeHostMatchOptions(state, repoId, hostId) return { - ...state.worktreesByRepo, + ...selectWorktreesByRepo(state), [repoId]: current.map((worktree) => worktree.id === updatedWorktree.id && worktreeMatchesHost(worktree, hostId, options) ? updatedWorktree diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts index f0c396bcecd..0bfb5b3cba4 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts @@ -11,17 +11,21 @@ import { import { findWorktreeById, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { findRepoForHost } from '../../repo-host-identity' import { reuseEqualRecordMap } from '../../repo-identity-reconcile' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' export function getWorktreeHostId( state: Pick, worktreeId: string ): ExecutionHostId | null { - const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) + const worktree = findWorktreeById(selectWorktreesByRepo(state), worktreeId) if (worktree?.hostId) { return worktree.hostId } const repoId = getRepoIdFromWorktreeId(worktreeId) - const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + const detected = selectDetectedWorktreesByRepo(state)[repoId]?.worktrees.find( (entry) => entry.id === worktreeId ) if (detected?.hostId) { diff --git a/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts b/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts index 48969db5e1c..eed05cf73bc 100644 --- a/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts @@ -1,6 +1,7 @@ import { FolderWorkspaceActivityPersistence } from '../../folder-workspace-activity-persistence' import { FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS } from '../listing/worktree-slice-constants' import type { WorktreeSliceGet } from '../listing/worktree-slice-types' +import { selectFolderWorkspaces } from '../../../workspace-record-selectors' const folderWorkspaceActivityPersistenceByStore = new WeakMap< WorktreeSliceGet, @@ -15,7 +16,7 @@ export function getFolderWorkspaceActivityPersistence( return existing } const created = new FolderWorkspaceActivityPersistence((folderWorkspaceId, activityAt) => { - if (get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { + if (selectFolderWorkspaces(get()).some((workspace) => workspace.id === folderWorkspaceId)) { void get().updateFolderWorkspace(folderWorkspaceId, { lastActivityAt: activityAt }) } }, FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts b/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts index be8e23f28c9..6c5268fbb22 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts @@ -13,6 +13,7 @@ import type { Worktree } from '../../../../../../shared/worktree/types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { getIndexedWorktreesById } from '../../../worktree-repo-index' import { worktreeOwnerHostIds } from '@/lib/worktree-runtime-owner-index' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' type WorktreeWithEmbeddedLineage = Worktree & { lineage?: WorktreeLineage | null } function getProjectedLineage(get: WorktreeSliceGet, worktree: Worktree): WorktreeLineage | null { @@ -79,7 +80,8 @@ export function createSetWorktreesPinnedAndReveal( const worktreeId = target.id // Why: an id names every host's copy; without a host only a lone row is safe to write. const current = - target.hostId || getIndexedWorktreesById(get().worktreesByRepo, worktreeId).length <= 1 + target.hostId || + getIndexedWorktreesById(selectWorktreesByRepo(get()), worktreeId).length <= 1 ? get().getKnownWorktreeById(worktreeId, target.hostId) : undefined if (!current || current.isPinned === isPinned) { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 58822da2e01..0c879014bcf 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -15,6 +15,7 @@ import type { TerminalRecoveryRemountResult } from '../../../terminals/terminal-tab-recovery-ledger' import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up' +import { selectWorktreesByRepo } from '../../../workspace-record-selectors' export function createSetRenamingWorktreeId( set: WorktreeSliceSet, @@ -129,7 +130,7 @@ export function createAllWorktrees( _set: WorktreeSliceSet, get: WorktreeSliceGet ): WorktreeSlice['allWorktrees'] { - return () => Object.values(get().worktreesByRepo).flat() + return () => Object.values(selectWorktreesByRepo(get())).flat() } export function createGetKnownWorktreeById( diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index ad2330ebd9f..7a8e2d3f148 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -9,6 +9,10 @@ import { getWorktreeVisitKey, getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' export function createMarkWorktreeVisited( set: WorktreeSliceSet, @@ -57,15 +61,15 @@ export function createPruneLastVisitedTimestamps( validWorktreeIds.add(worktree.id) validVisitKeys.add(getWorktreeVisitKey(worktree.id, worktree.hostId)) } - for (const [repoId, list] of Object.entries(s.worktreesByRepo)) { + for (const [repoId, list] of Object.entries(selectWorktreesByRepo(s))) { // An empty list is the not-yet-hydrated shape, not an authoritative "no worktrees". - if (s.detectedWorktreesByRepo[repoId] || list.length === 0) { + if (selectDetectedWorktreesByRepo(s)[repoId] || list.length === 0) { continue } validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id))) list.forEach(addValidWorktree) } - for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) { + for (const [repoId, result] of Object.entries(selectDetectedWorktreesByRepo(s))) { if (result.authoritative) { validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id))) result.worktrees.forEach(addValidWorktree) diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index 4e271dd0b21..b0a228561ae 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -27,6 +27,11 @@ import { retirePurgedTabLaunches } from './purged-structured-launch-retirement' import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' type PublisherRow = { id: string @@ -48,7 +53,7 @@ function resolvePurgedPublishers( const attributedHostIds = new Map([...purgedHosts].map((hostId) => [hostId, hostId])) if (scope?.type === 'folder') { const groups = s.projectGroups ?? [] - const hosts = (s.folderWorkspaces ?? []) + const hosts = (selectFolderWorkspaces(s) ?? []) .filter((folder) => folder.id === scope.folderWorkspaceId) .flatMap((folder) => { const groupHosts = groups @@ -67,8 +72,8 @@ function resolvePurgedPublishers( const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId const repoId = getRepoIdFromWorktreeId(worktreeId) const rows: PublisherRow[] = [ - ...(s.worktreesByRepo[repoId] ?? []), - ...(s.detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ...(selectWorktreesByRepo(s)[repoId] ?? []), + ...(selectDetectedWorktreesByRepo(s)[repoId]?.worktrees ?? []) ].filter((row) => row.id === worktreeId) const survivors = new Set() for (const row of rows) { diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts index 62567af4a53..14572cf91d2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-worktree-removal-state.ts @@ -10,6 +10,10 @@ import { import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id' import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeSliceGet } from '../listing/worktree-slice-types' +import { + selectDetectedWorktreesByRepo, + selectWorktreesByRepo +} from '../../../workspace-record-selectors' export const UNFINISHED_WORKTREE_REMOVAL_ERROR = 'The delete did not finish. Try again.' @@ -91,8 +95,8 @@ export function settleLostWorktreeRemovalReply( return waitForHostWorktreeRemoval({ ...args, readRows: () => [ - ...(get().worktreesByRepo[repoId] ?? []), - ...(get().detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ...(selectWorktreesByRepo(get())[repoId] ?? []), + ...(selectDetectedWorktreesByRepo(get())[repoId]?.worktrees ?? []) ], refresh: () => args.hostId diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.ts index ac7ae2a15a7..ab879dcee30 100644 --- a/src/renderer/src/store/terminals/terminal-workspace-routing.ts +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.ts @@ -7,6 +7,7 @@ import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shar import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { getIndexedRepoMap, getIndexedWorktreeMap } from '../worktree-repo-index' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '../workspace-record-selectors' export function isWindowsRendererRuntime(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') @@ -56,12 +57,12 @@ export function worktreeUsesWslPath( ): boolean { const parsed = parseWorkspaceKey(worktreeId) if (parsed?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( + const folderWorkspace = selectFolderWorkspaces(state).find( (workspace) => workspace.id === parsed.folderWorkspaceId ) return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false } - const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(worktreeId) return worktree ? isWslUncPath(worktree.path) : false } @@ -78,7 +79,7 @@ export function worktreeUsesRemoteConnection( if (directRepo) { return Boolean(directRepo.connectionId) } - const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(worktreeId) const repo = worktree ? repoMap.get(worktree.repoId) : null return Boolean(repo?.connectionId) } @@ -96,7 +97,7 @@ export function getRemoteConnectionIdForWorktree( if (directRepo) { return directRepo.connectionId?.trim() || null } - const worktree = getIndexedWorktreeMap(state.worktreesByRepo).get(worktreeId) + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(worktreeId) const repo = worktree ? repoMap.get(worktree.repoId) : null return repo?.connectionId?.trim() || null } diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index d2e8c1ce100..15da2fa193d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -2,6 +2,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { selectWorktreesByRepo } from '../workspace-record-selectors' export function createWorkspaceTerminalReconnectActions( set: TerminalStoreSet, @@ -35,7 +36,7 @@ export function createWorkspaceTerminalReconnectActions( let reconnectedPtyIdsByTabId: Record | null = null // Why indexed: the loop neither sets state nor awaits, so one index over the // whole store snapshot serves every iteration. - const worktreeById = buildWorktreeByIdIndex(get().worktreesByRepo) + const worktreeById = buildWorktreeByIdIndex(selectWorktreesByRepo(get())) const repoById = buildByIdIndex(get().repos) for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/store/workspace-record-selectors.test.ts b/src/renderer/src/store/workspace-record-selectors.test.ts new file mode 100644 index 00000000000..e6d967a759c --- /dev/null +++ b/src/renderer/src/store/workspace-record-selectors.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from './types' +import { makeFolderWorkspace, makeWorktree } from './slices/worktrees-slice-test-fixtures' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorkspaceByHostTarget, + selectWorktreesByRepo +} from './workspace-record-selectors' + +const SHARED_ID = 'repo-1::/work/orca' +const localRow = makeWorktree({ id: SHARED_ID, repoId: 'repo-1', hostId: 'local' }) +const sshRow = makeWorktree({ id: SHARED_ID, repoId: 'repo-1', hostId: 'ssh:build-box' }) +const otherRepoRow = makeWorktree({ id: 'repo-2::/work/api', repoId: 'repo-2', hostId: 'local' }) +const folder = makeFolderWorkspace({ id: 'folder-1', executionHostId: 'local' }) + +function makeState( + overrides: Partial> = {} +): Pick { + return { + worktreesByRepo: { 'repo-1': [localRow, sshRow], 'repo-2': [otherRepoRow] }, + folderWorkspaces: [folder], + detectedWorktreesByRepo: {}, + ...overrides + } +} + +describe('compatibility views', () => { + it('return the store fields by reference', () => { + const state = makeState() + expect(selectWorktreesByRepo(state)).toBe(state.worktreesByRepo) + expect(selectFolderWorkspaces(state)).toBe(state.folderWorkspaces) + expect(selectDetectedWorktreesByRepo(state)).toBe(state.detectedWorktreesByRepo) + }) +}) + +describe('selectWorkspaceByHostTarget', () => { + it('resolves the target host only', () => { + const state = makeState() + expect( + selectWorkspaceByHostTarget(state, hostWorktreeTarget({ id: SHARED_ID, hostId: 'local' })) + ).toBe(localRow) + expect( + selectWorkspaceByHostTarget( + state, + hostWorktreeTarget({ id: SHARED_ID, hostId: 'ssh:build-box' }) + ) + ).toBe(sshRow) + }) + + it("answers a paired runtime's logical host alias", () => { + const hubRow = makeWorktree({ + id: 'repo-3::/srv/app', + repoId: 'repo-3', + hostId: 'ssh:hub', + runtimeOwnerEnvironmentId: 'env-1' + }) + const state = makeState({ worktreesByRepo: { 'repo-3': [hubRow] } }) + expect( + selectWorkspaceByHostTarget( + state, + hostWorktreeTarget({ id: hubRow.id, hostId: 'runtime:env-1' }) + ) + ).toBe(hubRow) + }) + + it('matches a folder workspace on its host only', () => { + const state = makeState() + const key = folderWorkspaceKey('folder-1') + expect( + selectWorkspaceByHostTarget(state, hostWorktreeTarget({ id: key, hostId: 'local' }))?.path + ).toBe(folder.folderPath) + expect( + selectWorkspaceByHostTarget(state, hostWorktreeTarget({ id: key, hostId: 'ssh:build-box' })) + ).toBe(undefined) + }) +}) + +describe('selectWorkspaceByHostTarget bucket keys', () => { + it('finds a row filed under a bucket that is not its repo id', () => { + const row = makeWorktree({ id: 'wt-9', repoId: 'repo-9', hostId: 'ssh:remote' }) + const state = makeState({ worktreesByRepo: { remote: [row] } }) + expect( + selectWorkspaceByHostTarget(state, hostWorktreeTarget({ id: 'wt-9', hostId: 'ssh:remote' })) + ).toBe(row) + }) +}) diff --git a/src/renderer/src/store/workspace-record-selectors.ts b/src/renderer/src/store/workspace-record-selectors.ts new file mode 100644 index 00000000000..fc3da233328 --- /dev/null +++ b/src/renderer/src/store/workspace-record-selectors.ts @@ -0,0 +1,73 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Worktree } from '../../../shared/worktree/types' +import type { HostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' +import type { AppState } from './types' +import { + folderWorkspaceMatchesHost, + getFolderWorkspaceWorktree +} from './slices/worktrees/listing/detected-worktree-meta' + +// The one read path for workspace records. Backed by today's store fields; readers outside the +// writers go through here (config/scripts/check-workspace-record-read-ratchet.mjs). + +export type WorkspaceRecordState = Pick + +// ─── Compatibility views ───────────────────────────────────────────── + +/** Git worktrees per repo id, in listing order. */ +export function selectWorktreesByRepo( + state: Pick +): AppState['worktreesByRepo'] { + return state.worktreesByRepo +} + +export function selectFolderWorkspaces( + state: Pick +): AppState['folderWorkspaces'] { + return state.folderWorkspaces +} + +/** Checkouts git reports that Orca does not track, per repo id. */ +export function selectDetectedWorktreesByRepo( + state: Pick +): AppState['detectedWorktreesByRepo'] { + return state.detectedWorktreesByRepo +} + +// ─── Keyed records ─────────────────────────────────────────────────── + +function findFolderWorkspaceForTarget( + folderWorkspaces: readonly FolderWorkspace[], + folderWorkspaceId: string, + hostId: ExecutionHostId +): FolderWorkspace | undefined { + return folderWorkspaces.find( + (workspace) => + workspace.id === folderWorkspaceId && folderWorkspaceMatchesHost(workspace, hostId) + ) +} + +/** + * The row that owns `target` on its host, including a paired runtime's logical host alias. + * An ambiguous owner on that host answers undefined. + */ +export function selectWorkspaceByHostTarget( + state: WorkspaceRecordState, + target: HostWorktreeTarget +): Worktree | undefined { + const scope = parseWorkspaceKey(target.id) + if (scope?.type === 'folder') { + const folder = findFolderWorkspaceForTarget( + state.folderWorkspaces, + scope.folderWorkspaceId, + target.hostId + ) + return folder ? getFolderWorkspaceWorktree(folder) : undefined + } + return ( + findIndexedWorktreeOwnerForHost(state.worktreesByRepo, target.id, target.hostId) ?? undefined + ) +} diff --git a/src/renderer/src/store/worktree-diff-comments-selector.ts b/src/renderer/src/store/worktree-diff-comments-selector.ts index 7c26298e2d9..af5e57f532f 100644 --- a/src/renderer/src/store/worktree-diff-comments-selector.ts +++ b/src/renderer/src/store/worktree-diff-comments-selector.ts @@ -8,6 +8,7 @@ import { namedHostForOwnerRef } from '@/lib/resolve-owner' import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { selectWorktreesByRepo } from './workspace-record-selectors' const EMPTY_DIFF_COMMENTS = Object.freeze([]) as unknown as DiffComment[] @@ -81,7 +82,7 @@ export function findDiffCommentRows( worktreeId: string, hint: { twin?: DiffCommentTwin; hostId?: ExecutionHostId; commentIds?: ReadonlySet } = {} ): DiffCommentRows | null { - const rows = (state.worktreesByRepo[getRepoIdFromWorktreeId(worktreeId)] ?? []).filter( + const rows = (selectWorktreesByRepo(state)[getRepoIdFromWorktreeId(worktreeId)] ?? []).filter( (row) => row.id === worktreeId ) const { twin: captured, commentIds } = hint @@ -127,7 +128,7 @@ export function findDiffCommentRows( const named = namedHostForOwnerRef(state, { workspaceId: worktreeId }) const holdsNamedNotes = rows.some((row) => row.diffComments?.some((c) => commentIds?.has(c.id))) const owner = named - ? findIndexedWorktreeOwnerForHost(state.worktreesByRepo, worktreeId, named) + ? findIndexedWorktreeOwnerForHost(selectWorktreesByRepo(state), worktreeId, named) : null if (named && twin.hostId && !holdsNamedNotes && !rows.some((row) => row === owner)) { return null @@ -142,7 +143,7 @@ export function findDiffCommentRows( } const named = namedHostForOwnerRef(state, { workspaceId: worktreeId }) const owner = named - ? findIndexedWorktreeOwnerForHost(state.worktreesByRepo, worktreeId, named) + ? findIndexedWorktreeOwnerForHost(selectWorktreesByRepo(state), worktreeId, named) : null return pickTwin( rows.find((row) => row === owner), @@ -170,7 +171,7 @@ export function readQueuedDiffComments( worktreeId: string, chain: { twin?: DiffCommentTwin; hostId?: ExecutionHostId; latest: DiffComment[] | undefined } ): { list: DiffComment[] | undefined } | null { - const rows = state.worktreesByRepo[getRepoIdFromWorktreeId(worktreeId)] ?? [] + const rows = selectWorktreesByRepo(state)[getRepoIdFromWorktreeId(worktreeId)] ?? [] if ( chain.latest && rows.some((row) => row.id === worktreeId && row.diffComments === chain.latest) @@ -218,7 +219,7 @@ export function selectWorktreeDiffComments( : null return open ? diffCommentRowNotes(open) - : getIndexedWorktreeById(state.worktreesByRepo, worktreeId)?.diffComments + : getIndexedWorktreeById(selectWorktreesByRepo(state), worktreeId)?.diffComments } export function selectWorktreeDiffCommentsOrEmpty(