refactor(renderer): read workspace records through typed selectors (#31462)

* refactor(renderer): read workspace records through typed selectors

* refactor(renderer): keep only the selectors with readers; type the host owner lookup
This commit is contained in:
Jinwoo Hong
2026-10-21 06:29:18 -04:00
committed by GitHub
parent abce679f88
commit 2eae220b2e
174 changed files with 1069 additions and 371 deletions
@@ -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 =
/(?<!\.\.)(?<!\bapi\??)\.(?:worktreesByRepo|folderWorkspaces|detectedWorktreesByRepo)\b/g
/** Files allowed to read the fields directly, with the reason. Keep each reason true. */
export const SELECTOR_FILES = new Map([
[
'src/renderer/src/store/workspace-record-selectors.ts',
'the selector module is the one read path by definition'
]
])
export function isScannedFile(rel) {
return (
/\.tsx?$/.test(rel) &&
!rel.endsWith('.d.ts') &&
!/\.(?:test|spec)\.tsx?$|fixture|test-harness|test-support/.test(rel) &&
!SELECTOR_FILES.has(rel)
)
}
/** Drops comments and the contents of quoted strings, so prose and i18n keys never count. */
export function stripCommentsAndQuotedStrings(text) {
let out = ''
for (let i = 0; i < text.length; i++) {
const char = text[i]
if (char === "'" || char === '"') {
let j = i + 1
while (j < text.length && text[j] !== char && text[j] !== '\n') {
j += text[j] === '\\' ? 2 : 1
}
out += `${char}${char}`
i = j
} else if (char === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
i = end === -1 ? text.length : end - 1
} else if (char === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
i = end === -1 ? text.length : end + 1
out += ' '
} else {
out += char
}
}
return out
}
export function countWorkspaceRecordReads(text) {
return [...stripCommentsAndQuotedStrings(text).matchAll(RAW_READ)].length
}
export function collectWorkspaceRecordReads(root = process.cwd()) {
const counts = new Map()
for (const [rel, text] of readTrackedSources(root, SCAN_ROOTS, isScannedFile)) {
const count = countWorkspaceRecordReads(text)
if (count > 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 `<count> <file> # <why>`.',
'# 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]))
}
@@ -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)
}
})
})
+84
View File
@@ -0,0 +1,84 @@
# Renderer reads of `worktreesByRepo` / `folderWorkspaces` / `detectedWorktreesByRepo`
# outside src/renderer/src/store/workspace-record-selectors.ts, as `<count> <file> # <why>`.
# 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
+2 -1
View File
@@ -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",
@@ -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,
@@ -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,
@@ -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]
@@ -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]
@@ -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<typeof hasActivityThreadWorkspace>[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
]
@@ -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),
@@ -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
@@ -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)
@@ -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()
@@ -20,6 +20,7 @@ import {
startWorktreeAgentRowsCachePass,
type WorktreeAgentRowsCache
} from './worktree-agent-rows-cache'
import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
const EMPTY_COUNTS: Record<DashboardBucket, number> = {
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
@@ -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
}
@@ -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]))
])
@@ -6,6 +6,7 @@ import {
buildDashboardBucketCounts,
createDashboardBucketCountsCache
} from './build-dashboard-bucket-counts'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
export type AgentBucketCounts = Record<DashboardBucket, number>
@@ -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
@@ -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 ||
@@ -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<ReturnType<typeof createWorktreeAgentRowsCache>>(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)
@@ -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()
@@ -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<typeof getEditorPanelRenderModel>
@@ -170,7 +171,7 @@ export function EditorPanelShell({
? getUntitledFileRoot(
renameDialogFile,
findWorktreeById(
useAppStore.getState().worktreesByRepo,
selectWorktreesByRepo(useAppStore.getState()),
renameDialogFile.worktreeId
)?.path
)
@@ -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<string | null>(null)
const parsed = useMemo(() => {
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
@@ -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(
@@ -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<StoreApi<AppState>, '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
@@ -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
}
@@ -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,
@@ -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<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
@@ -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({
@@ -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 }
@@ -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,
@@ -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)
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -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<string>
@@ -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,
@@ -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 =
@@ -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<AiVaultSession, 'filePath' | 'executionHostId'>
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
)
}
@@ -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: {
@@ -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 ?? [],
@@ -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
),
[]
)
@@ -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)
@@ -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)
@@ -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
@@ -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
}
@@ -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
@@ -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' })
}
@@ -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'
)
@@ -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<typeof hasActivityThreadWorkspace>[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
]
@@ -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)
@@ -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<number>
@@ -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(
@@ -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<NewExternalWorktreesInboxActionState | null>(null)
const [busyPath, setBusyPath] = useState<string | null>(null)
@@ -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<string> {
)
}
const visibleWorktrees = computeVisibleWorktrees(
state.worktreesByRepo,
selectWorktreesByRepo(state),
[],
buildVisibleWorktreeOptionsFromState(state, getRepoMapFromState(state))
)
@@ -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<void> {
let defaultCheckout = getProjectDefaultCheckout(
getProjectWorktreesForHost(
useAppStore.getState().worktreesByRepo[repoId] ?? [],
selectWorktreesByRepo(useAppStore.getState())[repoId] ?? [],
executionHostId
)
)
@@ -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<string> = Object.freeze(new Set<string>())
const EMPTY_IMPORTED_BY_REPO: ReadonlyMap<string, ImportedWorktreesCardCandidate> = 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
@@ -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<void> => {
const worktrees = (useAppStore.getState().worktreesByRepo[repoId] ?? []).filter(
const worktrees = (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).filter(
(worktree) =>
executionHostId === undefined ||
worktree.hostId === executionHostId ||
@@ -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<string> {
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)
@@ -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)
@@ -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',
@@ -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)
)
@@ -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) => {
@@ -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<Worktree, 'id' | 'hostId'>[]
@@ -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])
}
@@ -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 &&
@@ -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, {
@@ -40,6 +40,7 @@ import {
getVisibleWorktreeTerminalActivityTabs,
getWorktreeIdsWithStructuredChat
} from '../../visible-worktree-activity-inputs'
import { selectFolderWorkspaces } from '@/store/workspace-record-selectors'
export type SidebarWorktreeFilters = ReturnType<typeof useSidebarWorktreeFilters>
@@ -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) {
@@ -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)
@@ -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({
@@ -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<string> = 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.
@@ -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)
@@ -6,6 +6,7 @@ import {
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { selectDetectedWorktreesByRepo } from '@/store/workspace-record-selectors'
export function resolveWorktreeVisibilityHostTarget(
state: Pick<AppState, 'repos' | 'settings' | 'detectedWorktreesByRepo'>,
@@ -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
? {
@@ -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<SkillCloudVersion['manifest'], { skills: unknown }>
@@ -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(
@@ -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)
@@ -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<AppState, 'folderWorkspaces'>,
open: boolean
): AppState['folderWorkspaces'] {
return open ? state.folderWorkspaces : EMPTY_FOLDER_WORKSPACES
return open ? selectFolderWorkspaces(state) : EMPTY_FOLDER_WORKSPACES
}
export function getResourceUsageProjectGroups(
@@ -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)
@@ -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)
}
}
@@ -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<typeof useAppStore.getState>,
@@ -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,
@@ -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,
@@ -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) {
@@ -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,
@@ -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<typeof useAppStore.getState>
@@ -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 &&
@@ -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)))
@@ -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)
@@ -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<WorktreeTabBucketProjection<
@@ -25,7 +26,7 @@ export function useTerminalWorkspaceFoundation() {
const terminalWorktreeParkCooldownUntilRef = useRef(new Map<string, number>())
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
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) =>
@@ -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
@@ -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,
@@ -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<string, Worktree[]>()
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) ?? []
@@ -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,
@@ -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)
@@ -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,
@@ -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
@@ -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
@@ -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<Worktree, 'id' | 'hostId'>
type AppStoreApi = Pick<typeof useAppStore, 'getState' | 'setState'>
@@ -52,7 +56,7 @@ function showHostFailure(store: AppStoreApi, row: Worktree & { removalError: str
export function reconcileHostWorktreeRemovals(store: AppStoreApi = useAppStore): void {
settleHostWorktreeRemovals()
const listed = new Map<string, Worktree>()
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()
})
)
@@ -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
})
})
@@ -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
@@ -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<string, number>()
function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set<string> | 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<string> {
return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id))
return new Set((selectWorktreesByRepo(state)[repoId] ?? []).map((worktree) => worktree.id))
}
type WorktreeEventActivationOptions = {
@@ -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])) {
@@ -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()

Some files were not shown because too many files have changed in this diff Show More