From 200b3e53ae5e577c5b997447611badba08efa469 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:08:19 -0700 Subject: [PATCH] perf(renderer): keep the repos array identity across no-op refetches (#13744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(renderer): keep the repos array identity across no-op refetches reconcileFetchedRepos deliberately returns the previous array when a refetch changes nothing, so identity-keyed memos can skip work. Two later steps in the same chain copied unconditionally and threw that identity away: - reconcileReadoptedSshRepoRows spread the input on its no-prune path, which is the common case. - applyManualRepoOrder allocated a fresh array even when the saved order moved nothing. Both now return the input when they change nothing, so state.repos stays referentially stable through fetchRepos, fetchRuntimeEnvironmentRepos, fetchReposForAllHosts, and hydratePersistedUI. Return type stays Repo[] with the same cast reconcileFetchedRepos already uses; all four call sites only read the result. Co-authored-by: Orca * refactor(renderer): make the store repos array readonly at the type level Preserving the repos array identity means handing callers the same array that is live store state, which previously relied on `as Repo[]` casts to launder readonly inputs back into a mutable field. A cast is a footgun: the next person to add a .push or .sort corrupts store state with no type error. Widen RepoSlice['repos'] to readonly Repo[] and propagate honestly. Consumers that only read take readonly Repo[]; genuine local accumulators are annotated Repo[] and built from copies. Removes all four pre-existing `as Repo[]` casts in the reconcile chain (repo-identity-reconcile, superseded-ssh-repo-rows, manual-repo-order x2) — this lands with fewer casts than main has today. Type-only change; no runtime behavior differs. Co-authored-by: Orca * fix(renderer): compare nested repo fields so reconciliation actually fires Preserving the repos array identity was inert. reconcileFetchedRepos compares repo fields with !==, but every repo the renderer receives carries nested records that are new objects on every fetch: - main's hydrateRepo unconditionally rebuilds hookSettings for every repo, even a pristine one (persistence.ts:5109) - IPC structured-clone (and the JSON hop for SSH/runtime hosts) reclones gitRemoteIdentity, upstream, repoIcon, and the path arrays So every repo compared unequal, `identical` went false on every refresh, and the array was rebuilt regardless of the copies removed in the parent commit. Compare nested plain records structurally instead. They are small sanitized values; anything non-plain falls back to reference equality. The end-to-end test previously passed for a fixture-only reason: its repo had five scalar fields and the mock returned the same object both calls, the one shape production never produces. It now uses a production-shaped repo and fails without this change. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/components/Landing.tsx | 2 +- .../components/NewWorkspaceComposerCard.tsx | 4 +- .../automations/AutomationEditorDialog.tsx | 2 +- .../AutomationEditorDialogFooter.tsx | 2 +- .../automations/AutomationProjectCombobox.tsx | 2 +- .../dashboard-worktree-launch-options.test.ts | 18 ++++++- .../components/dashboard/useRetainedAgents.ts | 4 +- .../components/landing-preflight-dismissal.ts | 6 +-- .../new-workspace/SmartWorkspaceNameField.tsx | 2 +- .../src/components/repo/RepoCombobox.tsx | 2 +- .../useGitStatusPolling.rerender.test.ts | 2 +- .../settings/QuickCommandsScopeFilter.tsx | 4 +- .../resource-usage-open-slices.test.ts | 11 ++++- .../TerminalQuickCommandAdvancedSection.tsx | 2 +- .../TerminalQuickCommandDialog.tsx | 2 +- .../TerminalQuickCommandScopeField.tsx | 4 +- .../src/components/ui/repo-multi-combobox.tsx | 7 ++- .../src/hooks/useAgentDetectionTarget.test.ts | 10 ++-- .../src/lib/ensure-hooks-confirmed.test.ts | 6 +++ .../src/lib/folder-workspace-connection.ts | 2 +- src/renderer/src/lib/http-link-routing.ts | 2 +- .../lib/local-preflight-context-cache.test.ts | 8 +++- .../src/lib/local-preflight-context.test.ts | 26 +++++----- src/renderer/src/lib/repo-search.ts | 2 +- src/renderer/src/lib/repo-slug-index.ts | 2 +- ...workspace-port-localhost-label-selector.ts | 4 +- .../src/store/slices/detected-agents.test.ts | 4 ++ src/renderer/src/store/slices/github.test.ts | 38 ++++++++------- .../src/store/slices/preflight.test.ts | 3 +- .../slices/repo-identity-reconcile.test.ts | 44 +++++++++++++++++ .../store/slices/repo-identity-reconcile.ts | 48 +++++++++++++++++-- src/renderer/src/store/slices/repos.test.ts | 42 +++++++++++++++- src/renderer/src/store/slices/repos.ts | 10 ++-- .../slices/superseded-ssh-repo-rows.test.ts | 6 +++ .../store/slices/superseded-ssh-repo-rows.ts | 6 ++- src/renderer/src/store/slices/worktrees.ts | 3 +- src/shared/manual-repo-order.test.ts | 20 ++++++++ src/shared/manual-repo-order.ts | 9 ++-- 38 files changed, 290 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 2d4b392b71b..bcf5c66ec1f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -149,7 +149,7 @@ function PreflightBanner({ repos }: { issues: PreflightIssue[] - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element | null { // Why: keying the seed on the current GitHub project set means adding a new // GitHub project (which changes the key) re-evaluates dismissals, so a lapsed diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index bc1691f293a..b9683150d17 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -84,7 +84,7 @@ type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void - eligibleRepos: RepoOption[] + eligibleRepos: readonly RepoOption[] repoId: string projectOptions?: NewWorkspaceProjectOption[] selectedProjectId?: string | null @@ -98,7 +98,7 @@ type NewWorkspaceComposerCardProps = { selectedEphemeralVmRecipeId?: string | null onEphemeralVmRecipeChange?: (recipeId: string | null) => void ephemeralVmRecipeError?: string | null - repoBackedSearchRepos?: RepoOption[] + repoBackedSearchRepos?: readonly RepoOption[] repoBackedSourcesDisabled?: boolean allowSmartNameAddProject?: boolean smartNameRepoSwitchTarget?: 'project' | 'task-source' diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index f595290d2a2..90eb5a9df47 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -61,7 +61,7 @@ type AutomationEditorDialogProps = { isSaving: boolean canSave: boolean createTarget: AutomationCreateTarget - repos: Repo[] + repos: readonly Repo[] projectHostSetups: ProjectHostSetup[] automationYamlHooksByRepoKey: Record getAutomationHooksCacheKey: (repoId: string) => string diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 62a7be7aab7..ff5a67cc41b 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -31,7 +31,7 @@ type AutomationEditorDialogFooterProps = { isHermesCreate: boolean isSaving: boolean canSave: boolean - repos: Repo[] + repos: readonly Repo[] projectHostSetups: ProjectHostSetup[] automationYamlHooksByRepoKey: Record getAutomationHooksCacheKey: (repoId: string) => string diff --git a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx index b63bc5821f8..65d0176bf29 100644 --- a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx +++ b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx @@ -19,7 +19,7 @@ import { } from './automation-project-groups' type AutomationProjectComboboxProps = { - repos: Repo[] + repos: readonly Repo[] value: string onValueChange: (repoId: string) => void placeholder?: string diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts index e7c0c04d85c..42b63e3e993 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -94,8 +94,22 @@ describe('buildDashboardWorktreeLaunchOptions', () => { const options = buildDashboardWorktreeLaunchOptions( state({ repos: [ - { id: 'repo-ssh', connectionId: 'ssh-1' }, - { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'repo-ssh', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }, + { + id: 'repo-runtime', + path: '/repo-runtime', + displayName: 'repo-runtime', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:hub-1' + } ] as LaunchState['repos'], worktreesByRepo: { 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 61cd0f30ce4..1b88c32bb57 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -22,7 +22,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' type RetainedAgentSnapshot = Map type RetainedAgentsSyncInputs = { - repos: Repo[] + repos: readonly Repo[] worktreesByRepo: Record folderWorkspaces: FolderWorkspace[] tabsByWorktree: Record @@ -38,7 +38,7 @@ function paneKeyTabId(paneKey: string): string | null { } function buildLiveTabIndex(args: { - repos: Repo[] + repos: readonly Repo[] worktreesByRepo: Record folderWorkspaces: FolderWorkspace[] tabsByWorktree: Record diff --git a/src/renderer/src/components/landing-preflight-dismissal.ts b/src/renderer/src/components/landing-preflight-dismissal.ts index 76ef2a47bf5..1fae17b0a3d 100644 --- a/src/renderer/src/components/landing-preflight-dismissal.ts +++ b/src/renderer/src/components/landing-preflight-dismissal.ts @@ -23,7 +23,7 @@ function storageKey(issueId: string): string { /** GitHub-backed project identity keys for the current repo set, de-duped so * the same GitHub project added twice doesn't read as two distinct projects. */ -export function githubProjectKeys(repos: Repo[]): string[] { +export function githubProjectKeys(repos: readonly Repo[]): string[] { const keys = repos .filter((repo) => isGitHubBackedRepo(repo)) .map((repo) => getProjectIdentityKey(repo)) @@ -45,7 +45,7 @@ function readRecord(issueId: string): DismissalRecord | null { /** True when the issue was dismissed and no new GitHub project has appeared * since. A GitHub key present now but absent from the snapshot re-surfaces it. */ -export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boolean { +export function isPreflightIssueDismissed(issueId: string, repos: readonly Repo[]): boolean { const record = readRecord(issueId) if (!record) { return false @@ -55,7 +55,7 @@ export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boole return !hasNewGithubProject } -export function dismissPreflightIssue(issueId: string, repos: Repo[]): void { +export function dismissPreflightIssue(issueId: string, repos: readonly Repo[]): void { try { const record: DismissalRecord = { githubKeys: githubProjectKeys(repos) } localStorage.setItem(storageKey(issueId), JSON.stringify(record)) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 539f40deb7c..6ddc4e3ae93 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -114,7 +114,7 @@ type RepoOption = ReturnType['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] type SmartWorkspaceNameFieldProps = { - repos: RepoOption[] + repos: readonly RepoOption[] repoId: string onRepoChange: (repoId: string) => void value: string diff --git a/src/renderer/src/components/repo/RepoCombobox.tsx b/src/renderer/src/components/repo/RepoCombobox.tsx index f9fef8ad902..868c7be2d22 100644 --- a/src/renderer/src/components/repo/RepoCombobox.tsx +++ b/src/renderer/src/components/repo/RepoCombobox.tsx @@ -19,7 +19,7 @@ import RepoBadgeLabel from './RepoBadgeLabel' import { translate } from '@/i18n/i18n' type RepoComboboxProps = { - repos: Repo[] + repos: readonly Repo[] value: string onValueChange: (repoId: string) => void onValueSelected?: (repoId: string) => void diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 2f9c3ddabdb..348bf8a1f36 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -217,7 +217,7 @@ describe('useGitStatusPolling rerender stability', () => { useAppStore.setState({ repos: [{ ...repo, connectionId: 'ssh-1' }], sshConnectionStates: new Map([ - ['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }] + ['ssh-1', { targetId: 'ssh-1', status: 'disconnected', error: null, reconnectAttempt: 0 }] ]) } as Partial) await renderHook() diff --git a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx index e845b9dd0b4..749d554b783 100644 --- a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx +++ b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx @@ -21,7 +21,7 @@ function ScopeTriggerLabel({ }: { showAll: boolean effectiveSelection: ReadonlySet - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element { if (showAll) { return ( @@ -57,7 +57,7 @@ export function QuickCommandsScopeFilter({ handleSelectAll, toggleScope }: { - repos: Repo[] + repos: readonly Repo[] effectiveSelection: ReadonlySet showAll: boolean scopePopoverOpen: boolean diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 58a51eed488..85663bc2090 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -117,7 +117,16 @@ describe('resource usage open slices', () => { }) it('gates repo and worktree slices only while closed', () => { - const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos'] + const repos = [ + { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + } + ] as AppState['repos'] const row = worktree() const worktreesByRepo = { 'repo-1': [row] diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx index 646c81dd100..d49be9c9616 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx @@ -11,7 +11,7 @@ import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField type TerminalQuickCommandAdvancedSectionProps = { draft: TerminalQuickCommand - repos: Pick[] + repos: readonly Pick[] advancedOpen: boolean selectedScope: ReturnType selectedRepoId: string diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 59bce894d19..493eb755bd6 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -39,7 +39,7 @@ type TerminalQuickCommandDialogProps = { open: boolean mode: TerminalQuickCommandDialogMode command: TerminalQuickCommand - repos?: Pick[] + repos?: readonly Pick[] onOpenChange: (open: boolean) => void onSave: (command: TerminalQuickCommand) => void } diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx index fe09ee6acb4..58cd0ea8fd2 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx @@ -18,7 +18,7 @@ import { QUICK_COMMAND_TOGGLE_ITEM_CLASS } from './terminal-quick-command-toggle import { translate } from '@/i18n/i18n' type TerminalQuickCommandScopeFieldProps = { - repos: Pick[] + repos: readonly Pick[] selectedScope: TerminalQuickCommandScope selectedRepoId: string selectedRepoMissing: boolean @@ -32,7 +32,7 @@ function getRepoLabel(repo: Pick): string { } export function getQuickCommandProjectScopeRepoId( - repos: Pick[], + repos: readonly Pick[], lastRepoScopeId: string | null ): string | null { return lastRepoScopeId ?? repos[0]?.id ?? null diff --git a/src/renderer/src/components/ui/repo-multi-combobox.tsx b/src/renderer/src/components/ui/repo-multi-combobox.tsx index 6334f74179e..f3dfef437d0 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.tsx +++ b/src/renderer/src/components/ui/repo-multi-combobox.tsx @@ -16,7 +16,7 @@ import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { translate } from '@/i18n/i18n' type RepoMultiComboboxProps = { - repos: Repo[] + repos: readonly Repo[] /** Currently selected repo ids. The component enforces `selected.size >= 1` * by disabling the last-selected checkbox. */ selected: ReadonlySet @@ -32,7 +32,10 @@ type RepoMultiComboboxProps = { triggerClassName?: string } -function renderTriggerLabel(repos: Repo[], selected: ReadonlySet): React.JSX.Element { +function renderTriggerLabel( + repos: readonly Repo[], + selected: ReadonlySet +): React.JSX.Element { if (repos.length === 0) { return ( diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 8d0cc61f716..1d1c4fa1417 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -1,16 +1,20 @@ import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget' describe('getAgentDetectionTargetKeyForWorktree', () => { it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => { let projectGroupReads = 0 - const repos = Array.from({ length: 100 }, (_, index) => { - const repo = { + const repos: readonly Repo[] = Array.from({ length: 100 }, (_, index) => { + const repo: Repo = { id: `repo-${index}`, connectionId: `ssh-${index}`, executionHostId: `ssh:ssh-${index}`, - path: `/workspace/repo-${index}` + path: `/workspace/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: 'blue', + addedAt: 1 } Object.defineProperty(repo, 'projectGroupId', { enumerable: true, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 00b8621a35b..bbfbe637cb7 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -291,7 +291,10 @@ describe('ensureHooksConfirmed', () => { repos: [ { id: 'repo-1', + path: '/repo-1', displayName: 'Repo One', + badgeColor: 'blue', + addedAt: 1, hookSettings: { mode: 'auto', commandSourcePolicy: 'local-only', @@ -318,7 +321,10 @@ describe('ensureHooksConfirmed', () => { repos: [ { id: 'repo-1', + path: '/repo-1', displayName: 'Repo One', + badgeColor: 'blue', + addedAt: 1, hookSettings: { mode: 'auto', scripts: { setup: 'echo local', archive: '' } diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 4afb696d700..ee15c61e761 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -6,7 +6,7 @@ import { parseExecutionHostId } from '../../../shared/execution-host' export type FolderWorkspaceConnectionState = { folderWorkspaces: FolderWorkspace[] projectGroups: ProjectGroup[] - repos: Repo[] + repos: readonly Repo[] } function getFolderScopeCandidateRepos(args: { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 60f53457fc7..0f010f05348 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -35,7 +35,7 @@ type StoreAccessor = () => { > | null setActiveWorktree: (worktreeId: string) => void createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown - repos?: LocalhostLinkRepo[] + repos?: readonly LocalhostLinkRepo[] projects?: LocalhostLinkProject[] worktreesByRepo?: Record allWorktrees?: () => LocalhostLinkWorktree[] diff --git a/src/renderer/src/lib/local-preflight-context-cache.test.ts b/src/renderer/src/lib/local-preflight-context-cache.test.ts index 602aa0f302e..4a9cec27812 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -16,11 +16,15 @@ afterEach(() => { resetLocalPreflightContextCachesForTests() }) +function makeRepos(id: string, path: string): AppState['repos'] { + return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }] +} + function makeWslState(distro: string): AppState { return { activeRepoId: 'repo-1', activeWorktreeId: null, - repos: [{ id: 'repo-1', path: `\\\\wsl.localhost\\${distro}\\home\\alice\\repo` }], + repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), worktreesByRepo: {} } as AppState } @@ -29,7 +33,7 @@ function makeWindowsProjectState(projectId: string): AppState { return { activeRepoId: projectId, activeWorktreeId: null, - repos: [{ id: projectId, path: `C:\\Users\\alice\\${projectId}` }], + repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), settings: {}, worktreesByRepo: {} } as AppState diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index b4f5417f8ce..287376bb548 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -11,26 +11,30 @@ import { } from './local-preflight-context' function makeState(args: { - repoPath?: string | null + repoPath?: string worktreePath?: string | null repo?: Partial worktree?: Partial }): AppState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` + const repos: AppState['repos'] = + args.repoPath === undefined + ? [] + : [ + { + id: repoId, + path: args.repoPath, + displayName: repoId, + badgeColor: 'blue', + addedAt: 1, + ...args.repo + } + ] return { activeRepoId: repoId, activeWorktreeId: args.worktreePath === undefined ? null : worktreeId, - repos: - args.repoPath === undefined - ? [] - : [ - { - id: repoId, - path: args.repoPath, - ...args.repo - } - ], + repos, worktreesByRepo: args.worktreePath === undefined ? {} diff --git a/src/renderer/src/lib/repo-search.ts b/src/renderer/src/lib/repo-search.ts index 7c4faf192ea..5a223af6392 100644 --- a/src/renderer/src/lib/repo-search.ts +++ b/src/renderer/src/lib/repo-search.ts @@ -38,7 +38,7 @@ function matchScore(repo: Repo, query: string): number | null { return null } -export function searchRepos(repos: Repo[], rawQuery: string): Repo[] { +export function searchRepos(repos: readonly Repo[], rawQuery: string): readonly Repo[] { if (isRepoSearchQueryTooLarge(rawQuery)) { return [] } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index d48dbb869f3..442a29d6d62 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -126,7 +126,7 @@ async function resolveRepoSlug( } async function buildIndex( - repos: Repo[], + repos: readonly Repo[], settings: Pick | null | undefined ): Promise<{ index: SlugIndex; upstreamIndex: SlugIndex; retryDelayMs: number | null }> { // Why: evict cached entries for repos that no longer exist in state so diff --git a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts index ea21dcfed7f..8e996cf6690 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -9,8 +9,8 @@ import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-l // this is the single source for both reactive and imperative call sites. type LocalhostLabelLookupState = { settings?: Pick | null - repos?: Repo[] - projects?: Project[] + repos?: readonly Repo[] + projects?: readonly Project[] getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1755f355072..c4776039e13 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -327,6 +327,10 @@ describe('createDetectedAgentsSlice WSL context', () => { projects: [ { id: 'repo-1', + displayName: 'repo-1', + badgeColor: 'blue', + createdAt: 1, + updatedAt: 1, sourceRepoIds: ['repo-1'], localWindowsRuntimePreference: { kind: 'windows-host' } } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index f77b8577a94..5e5b0d61ccb 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6534,17 +6534,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() + const repos: AppState['repos'] = [ + { + id: 'runtime-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [ - { - id: 'runtime-repo-id', - path: '/server/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - ] + repos } as Partial) await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', { @@ -6634,17 +6635,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'source-runtime' } }) const store = createTestStore() + const repos: AppState['repos'] = [ + { + id: 'local-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, - repos: [ - { - id: 'local-repo-id', - path: '/server/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - ] + repos } as Partial) const sourceContext = { diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index bd93507ff41..18ebaf839cd 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -265,11 +265,12 @@ describe('createPreflightSlice', () => { new Error('Project runtime requires repair before preflight: wsl-distro-required') ) const store = createTestStore() + const repos: AppState['repos'] = [makeRepo({ id: 'repo-1', path: 'C:\\repo' })] store.setState({ settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: null } }, - repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + repos, worktreesByRepo: {}, activeRepoId: 'repo-1', activeWorktreeId: null diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index f8c334ff4a7..dda221e18b0 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -14,6 +14,50 @@ describe('reconcileFetchedRepos', () => { expect(reconcileFetchedRepos(previous, next)).toBe(previous) }) + it('reconciles repos whose nested records were rebuilt by hydration and IPC', () => { + // Why: main's hydrateRepo reconstructs hookSettings on every list and structured-clone + // rebuilds the rest, so a reference compare would report every real repo as changed. + const nested = (): Partial => ({ + hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, + gitRemoteIdentity: { + canonicalKey: 'github.com/o/n', + remoteName: 'origin', + remoteUrl: 'git@github.com:o/n.git' + }, + importedExternalWorktreePaths: ['/a', '/b'] + }) + const previous = [makeRepo('a', nested())] + const next = structuredClone([makeRepo('a', nested())]) as Repo[] + + expect(reconcileFetchedRepos(previous, next)).toBe(previous) + }) + + it('treats a changed nested field as a real change', () => { + const previous = [makeRepo('a', { importedExternalWorktreePaths: ['/a'] })] + const next = [makeRepo('a', { importedExternalWorktreePaths: ['/b'] })] + const result = reconcileFetchedRepos(previous, next) + + expect(result).not.toBe(previous) + expect(result[0].importedExternalWorktreePaths).toEqual(['/b']) + }) + + it('treats a nested field gaining a key as a real change', () => { + const previous = [ + makeRepo('a', { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } }) + ] + const next = [ + makeRepo('a', { + hookSettings: { + mode: 'auto', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + }) + ] + + expect(reconcileFetchedRepos(previous, next)).not.toBe(previous) + }) + it('reuses unchanged repo objects while reflecting a reorder', () => { const previous = [makeRepo('a'), makeRepo('b')] const next = [makeRepo('b'), makeRepo('a')] diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 119c4ed3e2a..9212313f0c7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -8,6 +8,45 @@ import { getRepoHostIdentity } from './repo-host-identity' // virtualizer to rebuild + re-measure a tick after the drop — the visible jump. // Reusing equal objects (and the whole array when nothing moved) makes the echo // a no-op render. +// Why: `Repo` carries nested records (hookSettings, upstream, gitRemoteIdentity, repoIcon, path +// arrays). IPC structured-clone rebuilds those every fetch, and main's hydrateRepo always +// reconstructs hookSettings — so a reference compare reports every repo as changed and no repo +// ever reconciles. Compare nested plain values structurally; they are small sanitized records. +function areValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((item, index) => areValuesEqual(item, b[index])) + ) + } + // Why: only plain records are safe to walk — anything exotic falls back to reference equality. + if ( + Object.getPrototypeOf(a) !== Object.prototype || + Object.getPrototypeOf(b) !== Object.prototype + ) { + return false + } + const aRecord = a as Record + const bRecord = b as Record + const keys = Object.keys(aRecord) + if (keys.length !== Object.keys(bRecord).length) { + return false + } + return keys.every( + (key) => + Object.prototype.hasOwnProperty.call(bRecord, key) && + areValuesEqual(aRecord[key], bRecord[key]) + ) +} + function areReposEqual(a: Repo, b: Repo): boolean { if (a === b) { return true @@ -20,14 +59,17 @@ function areReposEqual(a: Repo, b: Repo): boolean { if (!Object.prototype.hasOwnProperty.call(b, key)) { return false } - if (a[key] !== b[key]) { + if (!areValuesEqual(a[key], b[key])) { return false } } return true } -export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { +export function reconcileFetchedRepos( + previous: readonly Repo[], + next: readonly Repo[] +): readonly Repo[] { const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { @@ -41,5 +83,5 @@ export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): identical = false return repo }) - return identical ? (previous as Repo[]) : reconciled + return identical ? previous : reconciled } diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 764a5650657..efd5a998375 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Project, ProjectHostSetup } from '../../../../shared/types' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' import { toast } from 'sonner' import { installReposRuntimeRoutingHarness, @@ -52,6 +52,46 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('keeps the repos array identity across a refetch that changes nothing', async () => { + // Why: main rebuilds nested records (hookSettings et al) per list and IPC clones them, so a + // production-shaped repo — not a scalar-only fixture — is what proves reconciliation works. + const hydrated = (): Repo => ({ + ...localRepo, + kind: 'git', + gitUsername: 'octocat', + hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, + gitRemoteIdentity: { + canonicalKey: 'github.com/octocat/local', + remoteName: 'origin', + remoteUrl: 'git@github.com:octocat/local.git' + }, + importedExternalWorktreePaths: ['/local/wt'] + }) + reposList.mockImplementation(async () => [hydrated()]) + const store = createTestStore() + await store.getState().fetchRepos() + const reposRef = store.getState().repos + + await store.getState().fetchRepos() + + // Why: identity-keyed renderer memos (repo lookup index, selectors) rebuild on a new array. + expect(store.getState().repos).toBe(reposRef) + expect(store.getState().repos[0]).toBe(reposRef[0]) + }) + + it('replaces the repos array identity when a refetch adds a repo', async () => { + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + await store.getState().fetchRepos() + const reposRef = store.getState().repos + reposList.mockResolvedValue([localRepo, { ...localRepo, id: 'second', path: '/second' }]) + + await store.getState().fetchRepos() + + expect(store.getState().repos).not.toBe(reposRef) + expect(store.getState().repos).toHaveLength(2) + }) + it('fetches repos from the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 23bd1e48652..3adbd0ff766 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -881,9 +881,9 @@ function mergeByIdentity( function mergeFetchedReposForHost( previous: readonly Repo[], - fetched: Repo[], + fetched: readonly Repo[], hostId: string -): Repo[] { +): readonly Repo[] { const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched) const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { @@ -1050,7 +1050,7 @@ function mergeFetchedFolderWorkspacesForHost({ } type FetchedRepoCatalog = { - repos: Repo[] + repos: readonly Repo[] projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } @@ -1111,7 +1111,7 @@ function mergeFetchedRepoCatalog( catalog: FetchedRepoCatalog, currentRepos: readonly Repo[] ): { - repos: Repo[] + repos: readonly Repo[] projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } { @@ -1620,7 +1620,7 @@ function getFolderWorkspacePathStatusRequestSnapshotForRead( } export type RepoSlice = { - repos: Repo[] + repos: readonly Repo[] projects: Project[] projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts index ec18aea767d..7b50312d7a5 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts @@ -31,6 +31,12 @@ describe('reconcileReadoptedSshRepoRows', () => { expect(result.pendingReadoptions).toEqual([]) }) + it('returns the input array when nothing is pruned', () => { + const repos = [repo({ id: 'shared', path: '/local' })] + + expect(reconcileReadoptedSshRepoRows(repos, []).repos).toBe(repos) + }) + it('keeps evidence pending when repos:changed has not delivered the new row yet', () => { const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' }) diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts index da87da70291..4b20cfa6edf 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/types' import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' export type SshRepoReconciliation = { - repos: Repo[] + repos: readonly Repo[] pendingReadoptions: SshRepoReadoption[] } @@ -53,8 +53,10 @@ export function reconcileReadoptedSshRepoRows( } } + // Why: pruning nothing must hand back the input array, or the copy alone would defeat the + // referential stability reconcileFetchedRepos just established upstream. if (prunedOwners.size === 0) { - return { repos: [...repos], pendingReadoptions } + return { repos, pendingReadoptions } } return { repos: repos.filter( diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index cd87892eb16..706fa02de2f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -14,6 +14,7 @@ import type { WorktreeLineage, WorkspaceLineage, ProjectHostSetup, + Repo, WorktreeMeta } from '../../../../shared/types' import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types' @@ -6066,7 +6067,7 @@ export const createWorktreeSlice: StateCreator const repoIdsWithRemovedOwners = new Set() const survivingRepoIds = new Set() const repoIdsWithSurvivingOwners = new Set() - const survivingRepos: AppState['repos'] = [] + const survivingRepos: Repo[] = [] for (const repo of s.repos) { if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) { repoIdsWithRemovedOwners.add(repo.id) diff --git a/src/shared/manual-repo-order.test.ts b/src/shared/manual-repo-order.test.ts index 298ba4a10a1..3044a8d5399 100644 --- a/src/shared/manual-repo-order.test.ts +++ b/src/shared/manual-repo-order.test.ts @@ -27,6 +27,26 @@ describe('manual repo order', () => { expect(applyManualRepoOrder([localBravo, localAlpha], [])).toEqual([localBravo, localAlpha]) }) + it('returns the input array when no overlay exists', () => { + const repos = [localBravo, localAlpha] + + expect(applyManualRepoOrder(repos, [])).toBe(repos) + }) + + it('returns the input array when the saved order moves nothing', () => { + const repos = [localAlpha, remoteCharlie, localBravo, remoteDelta] + + expect(applyManualRepoOrder(repos, getManualRepoOrder(repos))).toBe(repos) + }) + + it('returns a new array when the saved order actually reorders', () => { + const repos = [localBravo, localAlpha] + const reordered = applyManualRepoOrder(repos, getManualRepoOrder([localAlpha, localBravo])) + + expect(reordered).not.toBe(repos) + expect(reordered).toEqual([localAlpha, localBravo]) + }) + it('restores a host-qualified cross-host interleaving', () => { const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta]) diff --git a/src/shared/manual-repo-order.ts b/src/shared/manual-repo-order.ts index 1ba8c886cab..9b5bfee39a1 100644 --- a/src/shared/manual-repo-order.ts +++ b/src/shared/manual-repo-order.ts @@ -41,13 +41,15 @@ export function getManualRepoOrder(repos: readonly Repo[]): ManualRepoOrderEntry export function applyManualRepoOrder( repos: readonly Repo[], order: readonly ManualRepoOrderEntry[] | null | undefined -): Repo[] { +): readonly Repo[] { const normalized = normalizeManualRepoOrder(order) + // Why: results flow straight back into `repos`, so a reorder that moves nothing must return + // the input — a fresh array would invalidate every identity-keyed repo memo downstream. if (normalized.length === 0) { - return [...repos] + return repos } const rankByKey = new Map(normalized.map((entry, index) => [getEntryKey(entry), index])) - return repos + const ordered = repos .map((repo, index) => ({ repo, index, @@ -66,4 +68,5 @@ export function applyManualRepoOrder( return a.rank - b.rank || a.index - b.index }) .map(({ repo }) => repo) + return ordered.every((repo, index) => repo === repos[index]) ? repos : ordered }