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 }