diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index caece413c1a..515f084708c 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -58,6 +58,8 @@ function useStartupActions() { fetchWorktreeLineage: s.fetchWorktreeLineage, fetchOrcaProfiles: s.fetchOrcaProfiles, fetchSettings: s.fetchSettings, + awaitOwnerWorktreeVisibilityDefaultsHydration: + s.awaitOwnerWorktreeVisibilityDefaultsHydration, fetchKeybindings: s.fetchKeybindings, initGitHubCache: s.initGitHubCache, hydrateWorkspaceSession: s.hydrateWorkspaceSession, @@ -109,8 +111,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta try { // Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings. void actions.fetchOrcaProfiles() - // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state. - await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) + // Why: publish local settings before persisted UI/catalog work; a saved remote owner's defaults can spend the full connect timeout. + await timeRendererStartupStep('fetch-settings', () => + actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true }) + ) // Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data. publishTerminalViewAttributesAtAppStart( useAppStore.getState().settings, @@ -283,6 +287,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta void (async () => { try { try { + // Why: remote rows must not render under a fallback visibility while their owner default is still loading. + await timeRendererStartupStep('owner-visibility-defaults', () => + actions.awaitOwnerWorktreeVisibilityDefaultsHydration() + ) await timeRendererStartupStep('remote-catalog-refresh', async () => { await actions.fetchReposForAllHosts() await actions.fetchProjectGroupsForAllHosts() diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 9f882234a51..99ef97bb211 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -30,7 +30,9 @@ describe('renderer startup runtime routing', () => { const relativeIndex = startupBlock.indexOf(needle) return relativeIndex === -1 ? -1 : startupBlockStart + relativeIndex } - const settingsIndex = indexInStartupBlock('actions.fetchSettings()') + const settingsIndex = indexInStartupBlock( + 'actions.fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true })' + ) const uiGetIndex = indexInStartupBlock("timeRendererStartupStep('ui-get'") const hydrateUiIndex = indexInStartupBlock("timeRendererStartupSyncStep('hydrate-persisted-ui'") const localReposIndex = indexInStartupBlock( @@ -96,6 +98,14 @@ describe('renderer startup runtime routing', () => { expect(fullWorktreesIndex).toBeGreaterThan( source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'") ) + const ownerDefaultsIndex = source.indexOf( + 'actions.awaitOwnerWorktreeVisibilityDefaultsHydration()' + ) + const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'") + expect(ownerDefaultsIndex).toBeGreaterThan( + source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'") + ) + expect(ownerDefaultsIndex).toBeLessThan(remoteCatalogIndex) // Why: the deferred full scan must be followed by a re-prune so deleted-worktree visit // timestamps for non-session repos are dropped once every repo is authoritative. expect( diff --git a/src/renderer/src/store/slices/settings-owner-hydration-publication.ts b/src/renderer/src/store/slices/settings-owner-hydration-publication.ts new file mode 100644 index 00000000000..b349dcac899 --- /dev/null +++ b/src/renderer/src/store/slices/settings-owner-hydration-publication.ts @@ -0,0 +1,65 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { hydrateOwnerWorktreeVisibilityDefaults } from './worktree-visibility-owner-settings' + +export type SettingsStateSetter = Parameters>[0] +type SettingsStateGetter = Parameters>[1] +const completedOwnerVisibilityDefaultsHydration = Promise.resolve() +const ownerVisibilityDefaultsHydrationByStore = new WeakMap>() + +export type FetchSettingsOptions = { + deferOwnerWorktreeVisibilityDefaults?: boolean +} + +export function startOwnerWorktreeVisibilityDefaultsHydration(args: { + settings: GlobalSettings + deferPublication: boolean + shouldPublish: () => boolean + set: SettingsStateSetter + get: SettingsStateGetter +}): Promise { + if (args.deferPublication) { + args.set((state) => ({ + settings: args.settings, + worktreeVisibilityDefaultsByHost: args.settings.worktreeVisibilityDefaults + ? { + ...state.worktreeVisibilityDefaultsByHost, + [LOCAL_EXECUTION_HOST_ID]: args.settings.worktreeVisibilityDefaults + } + : state.worktreeVisibilityDefaultsByHost + })) + } + const hydration = hydrateOwnerWorktreeVisibilityDefaults( + args.settings, + args.get().worktreeVisibilityDefaultsByHost + ) + .then((hydrated) => { + if (!args.shouldPublish()) { + return + } + args.set((state) => ({ + settings: hydrated.settings, + worktreeVisibilityDefaultsByHost: { + ...state.worktreeVisibilityDefaultsByHost, + ...hydrated.defaultsByHost + }, + worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: + hydrated.supportedRuntimeEnvironmentId, + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: + hydrated.sourceDefaultsSupportedRuntimeEnvironmentId + })) + }) + .catch((err) => console.error('Failed to fetch settings:', err)) + ownerVisibilityDefaultsHydrationByStore.set(args.get, hydration) + return hydration +} + +export function awaitOwnerWorktreeVisibilityDefaultsHydration( + get: SettingsStateGetter +): Promise { + return ( + ownerVisibilityDefaultsHydrationByStore.get(get) ?? completedOwnerVisibilityDefaultsHydration + ) +} diff --git a/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts b/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts index ba6a212f45b..9dc792d6af4 100644 --- a/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts +++ b/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts @@ -35,7 +35,8 @@ it('preserves host defaults added while owner hydration is in flight', async () get: vi.fn().mockResolvedValue({ activeRuntimeEnvironmentId: 'env-1' }) }, runtimeEnvironments: { - call: vi.fn().mockReturnValue(ownerRead) + call: vi.fn().mockReturnValue(ownerRead), + list: vi.fn().mockResolvedValue([]) } } }) @@ -58,3 +59,50 @@ it('preserves host defaults added while owner hydration is in flight', async () 'runtime:env-2': { external: 'show' } }) }) + +it('publishes startup settings before remote owner hydration and local catalog work', async () => { + markRuntimeEnvironmentCompatible('env-1') + let resolveOwnerRead!: (value: unknown) => void + const ownerRead = new Promise((resolve) => (resolveOwnerRead = resolve)) + vi.stubGlobal('window', { + api: { + settings: { + get: vi.fn().mockResolvedValue({ + activeRuntimeEnvironmentId: 'env-1', + worktreeVisibilityDefaults: { external: 'show' } + }), + set: vi.fn().mockResolvedValue({ + activeRuntimeEnvironmentId: 'env-1', + pluginSystemEnabled: true, + worktreeVisibilityDefaults: { external: 'show' } + }) + }, + runtimeEnvironments: { + call: vi.fn().mockReturnValue(ownerRead), + list: vi.fn().mockResolvedValue([]) + } + } + }) + const store = createTestStore() + let localCatalogStarted = false + const startup = (async () => { + await store.getState().fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true }) + localCatalogStarted = true + })() + await vi.waitFor(() => expect(window.api.runtimeEnvironments.call).toHaveBeenCalled()) + + expect(store.getState().settings?.activeRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().worktreeVisibilityDefaultsByHost.local).toEqual({ external: 'show' }) + expect(localCatalogStarted).toBe(true) + + await store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true }) + resolveOwnerRead({ + ok: true, + result: { settings: { worktreeVisibilityDefaults: { external: 'hide' } } }, + _meta: { runtimeId: 'runtime-1' } + }) + await startup + await store.getState().awaitOwnerWorktreeVisibilityDefaultsHydration() + expect(store.getState().settings?.pluginSystemEnabled).toBe(true) + expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeUndefined() +}) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index e3e8031c4b4..5dbb99dc431 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -32,6 +32,7 @@ import { hydrateOwnerWorktreeVisibilityDefaults, type WorktreeVisibilityDefaultsByHost } from './worktree-visibility-owner-settings' +import * as ownerHydration from './settings-owner-hydration-publication' import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' @@ -40,7 +41,8 @@ export type SettingsSlice = SettingsSearchState & { worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: string | null - fetchSettings: () => Promise + fetchSettings: (options?: ownerHydration.FetchSettingsOptions) => Promise + awaitOwnerWorktreeVisibilityDefaultsHydration: () => Promise updateSettings: (updates: Partial) => Promise updateSettingsOrThrow: (updates: Partial) => Promise setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise @@ -50,7 +52,6 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial & { terminalScrollbackBytes?: unknown } -type SettingsStateSetter = Parameters>[0] let ownerSettingsHydrationGeneration = 0 function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null { @@ -134,7 +135,7 @@ function normalizeSettingsUpdates( } async function persistSettingsUpdates( - set: SettingsStateSetter, + set: ownerHydration.SettingsStateSetter, updates: Partial, currentSettings: GlobalSettings | null, supportedRuntimeEnvironmentId: string | null, @@ -185,27 +186,24 @@ export const createSettingsSlice: StateCreator worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null, ...createSettingsSearchState((state) => set(state)), - fetchSettings: async () => { + fetchSettings: async (options) => { const generation = ++ownerSettingsHydrationGeneration try { - const hydrated = await hydrateOwnerWorktreeVisibilityDefaults( - (await window.api.settings.get()) as GlobalSettings, - get().worktreeVisibilityDefaultsByHost - ) + const localSettings = (await window.api.settings.get()) as GlobalSettings if (generation !== ownerSettingsHydrationGeneration) { return } - set((state) => ({ - settings: hydrated.settings, - worktreeVisibilityDefaultsByHost: { - ...state.worktreeVisibilityDefaultsByHost, - ...hydrated.defaultsByHost - }, - worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: - hydrated.supportedRuntimeEnvironmentId, - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: - hydrated.sourceDefaultsSupportedRuntimeEnvironmentId - })) + const ownerVisibilityDefaultsHydration = + ownerHydration.startOwnerWorktreeVisibilityDefaultsHydration({ + settings: localSettings, + deferPublication: options?.deferOwnerWorktreeVisibilityDefaults === true, + shouldPublish: () => generation === ownerSettingsHydrationGeneration, + set, + get + }) + if (!options?.deferOwnerWorktreeVisibilityDefaults) { + await ownerVisibilityDefaultsHydration + } } catch (err) { console.error('Failed to fetch settings:', err) } @@ -223,6 +221,9 @@ export const createSettingsSlice: StateCreator } }, + awaitOwnerWorktreeVisibilityDefaultsHydration: () => + ownerHydration.awaitOwnerWorktreeVisibilityDefaultsHydration(get), + updateSettings: async (updates) => { const generation = ++ownerSettingsHydrationGeneration const visibilityOwnerHostId = getSettingsFocusedExecutionHostId(get().settings)