diff --git a/src/renderer/src/components/settings/Settings.load-performance.test.ts b/src/renderer/src/components/settings/Settings.load-performance.test.ts new file mode 100644 index 000000000..60928b422 --- /dev/null +++ b/src/renderer/src/components/settings/Settings.load-performance.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + deriveNeededRepoIds, + deriveNeededSectionIds, + getRuntimeTargetIdentity +} from './settings-load-performance' + +describe('Settings load-performance helpers', () => { + it('keeps only eager and active sections mounted for empty search on first paint', () => { + const needed = deriveNeededSectionIds({ + navSectionIds: ['general', 'agents', 'appearance', 'terminal', 'stats', 'ssh', 'repo-a'], + mountedSectionIds: new Set(['general']), + activeSectionId: 'general', + pendingSectionId: null, + query: '', + visibleSectionIds: new Set([ + 'general', + 'agents', + 'appearance', + 'terminal', + 'stats', + 'ssh', + 'repo-a' + ]) + }) + + expect(Array.from(needed).sort()).toEqual(['general']) + }) + + it('adds matched sections immediately when search is non-empty', () => { + const needed = deriveNeededSectionIds({ + navSectionIds: ['general', 'agents', 'appearance', 'terminal', 'stats', 'repo-a'], + mountedSectionIds: new Set(['general']), + activeSectionId: 'general', + pendingSectionId: null, + query: 'stats', + visibleSectionIds: new Set(['stats']) + }) + + expect(needed.has('stats')).toBe(true) + }) + + it('keeps a pending deep-link target mounted before jump work continues', () => { + const needed = deriveNeededSectionIds({ + navSectionIds: ['general', 'agents', 'appearance', 'terminal', 'repo-a'], + mountedSectionIds: new Set(['general']), + activeSectionId: 'general', + pendingSectionId: 'repo-a', + query: '', + visibleSectionIds: new Set(['general', 'agents', 'appearance', 'terminal', 'repo-a']) + }) + + expect(needed.has('repo-a')).toBe(true) + }) + + it('scopes repo hook checks to needed repo sections only', () => { + const neededRepoIds = deriveNeededRepoIds( + [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + new Set(['general', 'repo-b']) + ) + + expect(neededRepoIds).toEqual(['b']) + }) + + it('normalizes runtime target identity for cache invalidation keys', () => { + expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: null })).toBe('local') + expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: ' env-1 ' })).toBe('env-1') + }) +}) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index f588ac650..f488bcfed 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -76,6 +76,12 @@ import { SettingsSidebar } from './SettingsSidebar' import { SettingsSection } from './SettingsSection' import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' +import { + deriveNeededRepoIds, + deriveNeededSectionIds, + getInitialMountedSectionIds, + getRuntimeTargetIdentity +} from './settings-load-performance' type SettingsNavTarget = | 'general' @@ -230,20 +236,13 @@ function Settings(): React.JSX.Element { const ghostty = useGhosttyImport(updateSettings, settings) const [wslAvailable, setWslAvailable] = useState(false) const [pwshAvailable, setPwshAvailable] = useState(false) - useEffect(() => { - if (!isWindows) { - setWslAvailable(false) - setPwshAvailable(false) - return - } - - void window.api.wsl.isAvailable().then(setWslAvailable) - void window.api.pwsh.isAvailable().then(setPwshAvailable) - }, [isWindows]) const [fontSuggestions, setFontSuggestions] = useState( Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()])) ) const [activeSectionId, setActiveSectionId] = useState('general') + const [mountedSectionIds, setMountedSectionIds] = useState>( + getInitialMountedSectionIds + ) const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0) const [hasUnsavedCommitPromptChanges, setHasUnsavedCommitPromptChanges] = useState(false) const [commitPromptDiscardSignal, setCommitPromptDiscardSignal] = useState(0) @@ -255,8 +254,11 @@ function Settings(): React.JSX.Element { const contentScrollRef = useRef(null) const searchInputRef = useRef(null) const terminalFontsLoadedRef = useRef(false) + const terminalCapabilitiesLoadedRef = useRef(false) const pendingNavSectionRef = useRef(null) const pendingScrollTargetRef = useRef(null) + const repoHooksRequestSeqRef = useRef(0) + const repoHooksRuntimeIdentityRef = useRef('local') const confirmDiscardCommitPromptChanges = useCallback((): boolean => { if (!hasUnsavedCommitPromptChanges) { @@ -283,6 +285,8 @@ function Settings(): React.JSX.Element { fetchSettings() }, [fetchSettings]) + const runtimeTargetIdentity = getRuntimeTargetIdentity(settings) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) { @@ -361,35 +365,6 @@ function Settings(): React.JSX.Element { clearSettingsTarget() }, [clearSettingsTarget, settings, settingsNavigationTarget]) - useEffect(() => { - if (terminalFontsLoadedRef.current) { - return - } - - let stale = false - - const loadFontSuggestions = async (): Promise => { - try { - const fonts = await window.api.settings.listFonts() - if (stale || fonts.length === 0) { - return - } - terminalFontsLoadedRef.current = true - setFontSuggestions((prev) => - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320) - ) - } catch { - // Fall back to curated cross-platform suggestions. - } - } - - void loadFontSuggestions() - - return () => { - stale = true - } - }, []) - // Why: only recompute scrollback mode when the byte value actually changes, // not on every unrelated settings mutation. if (settings?.terminalScrollbackBytes !== prevScrollbackBytes) { @@ -404,45 +379,6 @@ function Settings(): React.JSX.Element { } } - useEffect(() => { - let stale = false - - const checkHooks = async (): Promise => { - const results = await Promise.all( - repos.map(async (repo) => { - if (isFolderRepo(repo)) { - return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const - } - try { - const result = await checkRuntimeHooks(settings, repo.id) - return [repo.id, result] as const - } catch { - return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const - } - }) - ) - - if (!stale) { - setRepoHooksMap( - Object.fromEntries(results) as Record< - string, - { hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean } - > - ) - } - } - - if (repos.length > 0) { - void checkHooks() - } else { - setRepoHooksMap({}) - } - - return () => { - stale = true - } - }, [repos, settings]) - const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => { applyDocumentTheme(theme) }, []) @@ -653,13 +589,191 @@ function Settings(): React.JSX.Element { ), [hasUnsavedCommitPromptChanges, navSections, settingsSearchQuery] ) + const visibleSectionIds = useMemo( + () => new Set(visibleNavSections.map((section) => section.id)), + [visibleNavSections] + ) + const neededSectionIds = useMemo( + () => + deriveNeededSectionIds({ + navSectionIds: navSections.map((section) => section.id), + mountedSectionIds, + activeSectionId, + pendingSectionId: pendingNavSectionRef.current, + query: settingsSearchQuery, + visibleSectionIds + }), + [activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds] + ) + + useEffect(() => { + setMountedSectionIds((previous) => { + let changed = false + const next = new Set(previous) + for (const id of neededSectionIds) { + if (!next.has(id)) { + next.add(id) + changed = true + } + } + return changed ? next : previous + }) + }, [neededSectionIds]) + + useEffect(() => { + if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) { + return + } + if (terminalFontsLoadedRef.current) { + return + } + + let stale = false + const loadFontSuggestions = async (): Promise => { + try { + const fonts = await window.api.settings.listFonts() + if (stale || fonts.length === 0) { + return + } + terminalFontsLoadedRef.current = true + setFontSuggestions((prev) => + Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320) + ) + } catch { + // Fall back to curated cross-platform suggestions. + } + } + void loadFontSuggestions() + return () => { + stale = true + } + }, [neededSectionIds]) + + useEffect(() => { + if (!isWindows) { + setWslAvailable(false) + setPwshAvailable(false) + terminalCapabilitiesLoadedRef.current = true + return + } + if (!neededSectionIds.has('terminal') || terminalCapabilitiesLoadedRef.current) { + return + } + + let stale = false + terminalCapabilitiesLoadedRef.current = true + void window.api.wsl.isAvailable().then((available) => { + if (!stale) { + setWslAvailable(available) + } + }) + void window.api.pwsh.isAvailable().then((available) => { + if (!stale) { + setPwshAvailable(available) + } + }) + return () => { + stale = true + } + }, [isWindows, neededSectionIds]) + + const neededRepoIds = useMemo( + () => deriveNeededRepoIds(repos, neededSectionIds), + [neededSectionIds, repos] + ) + + useEffect(() => { + const repoIdSet = new Set(repos.map((repo) => repo.id)) + setRepoHooksMap((previous) => { + const next = Object.fromEntries( + Object.entries(previous).filter(([repoId]) => repoIdSet.has(repoId)) + ) as Record + return Object.keys(next).length === Object.keys(previous).length ? previous : next + }) + }, [repos]) + + useEffect(() => { + if (repoHooksRuntimeIdentityRef.current !== runtimeTargetIdentity) { + repoHooksRuntimeIdentityRef.current = runtimeTargetIdentity + repoHooksRequestSeqRef.current += 1 + setRepoHooksMap({}) + } + }, [runtimeTargetIdentity]) + + useEffect(() => { + if (neededRepoIds.length === 0) { + return + } + + let stale = false + const requestSeq = ++repoHooksRequestSeqRef.current + const repoById = new Map(repos.map((repo) => [repo.id, repo] as const)) + + void Promise.all( + neededRepoIds.map(async (repoId) => { + const repo = repoById.get(repoId) + if (!repo) { + return + } + if (isFolderRepo(repo)) { + setRepoHooksMap((previous) => { + if (previous[repoId]) { + return previous + } + return { + ...previous, + [repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + }) + return + } + try { + const result = await checkRuntimeHooks( + runtimeTargetIdentity === 'local' + ? { activeRuntimeEnvironmentId: null } + : { activeRuntimeEnvironmentId: runtimeTargetIdentity }, + repoId + ) + if (stale || requestSeq !== repoHooksRequestSeqRef.current) { + return + } + setRepoHooksMap((previous) => { + if (!repos.some((entry) => entry.id === repoId)) { + return previous + } + return { ...previous, [repoId]: result } + }) + } catch { + // Keep last known value on transient failures. + if (stale || requestSeq !== repoHooksRequestSeqRef.current) { + return + } + setRepoHooksMap((previous) => { + if (!repos.some((entry) => entry.id === repoId)) { + return previous + } + if (previous[repoId]) { + return previous + } + return { + ...previous, + [repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + }) + } + }) + ) + + return () => { + stale = true + } + }, [neededRepoIds, repos, runtimeTargetIdentity]) useEffect(() => { const scrollTargetId = pendingScrollTargetRef.current const pendingNavSectionId = pendingNavSectionRef.current - const visibleIds = new Set(visibleNavSections.map((section) => section.id)) - if (scrollTargetId && pendingNavSectionId && visibleIds.has(pendingNavSectionId)) { + if (scrollTargetId && pendingNavSectionId && visibleSectionIds.has(pendingNavSectionId)) { scrollSectionIntoView(scrollTargetId, contentScrollRef.current) flashSectionHighlight(scrollTargetId) setActiveSectionId(pendingNavSectionId) @@ -673,7 +787,7 @@ function Settings(): React.JSX.Element { return } - if (!visibleIds.has(activeSectionId) && visibleNavSections.length > 0) { + if (!visibleSectionIds.has(activeSectionId) && visibleNavSections.length > 0) { setActiveSectionId(getFallbackVisibleSection(visibleNavSections)?.id ?? activeSectionId) } }, [ @@ -681,6 +795,7 @@ function Settings(): React.JSX.Element { pendingNavRequestTick, setSettingsSearchQuery, settingsSearchQuery, + visibleSectionIds, visibleNavSections ]) @@ -807,6 +922,7 @@ function Settings(): React.JSX.Element { const repo = repos.find((entry) => entry.id === section.id.replace('repo-', '')) return { ...section, badgeColor: repo?.badgeColor, isRemote: !!repo?.connectionId } }) + const isSectionMounted = (sectionId: string): boolean => neededSectionIds.has(sectionId) return (
@@ -837,7 +953,9 @@ function Settings(): React.JSX.Element { description="Workspace, editor, and updates." searchEntries={GENERAL_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('general') ? ( + + ) : null} - + {isSectionMounted('integrations') ? : null} - + {isSectionMounted('agents') ? ( + + ) : null} - + {isSectionMounted('accounts') ? ( + + ) : null} - - + {isSectionMounted('git') ? ( + <> + + + + ) : null} - + {isSectionMounted('tasks') ? ( + + ) : null} - + {isSectionMounted('appearance') ? ( + + ) : null} } > - font !== DEFAULT_APP_FONT_FAMILY - )} - scrollbackMode={scrollbackMode} - setScrollbackMode={setScrollbackMode} - ghostty={ghostty} - wslAvailable={wslAvailable} - pwshAvailable={pwshAvailable} - /> + {isSectionMounted('terminal') ? ( + font !== DEFAULT_APP_FONT_FAMILY + )} + scrollbackMode={scrollbackMode} + setScrollbackMode={setScrollbackMode} + ghostty={ghostty} + wslAvailable={wslAvailable} + pwshAvailable={pwshAvailable} + /> + ) : null} {showDesktopOnlySettings ? ( @@ -953,11 +1085,13 @@ function Settings(): React.JSX.Element { description="Home page, link routing, and session cookies." searchEntries={BROWSER_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('browser') ? ( + + ) : null} - + {isSectionMounted('notifications') ? ( + + ) : null} ) : null} @@ -977,7 +1113,7 @@ function Settings(): React.JSX.Element { description="Coordinate multiple coding agents through Orca." searchEntries={ORCHESTRATION_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('orchestration') ? : null} - + {isSectionMounted('servers') ? ( + + ) : null} {showDesktopOnlySettings ? ( @@ -1008,7 +1146,9 @@ function Settings(): React.JSX.Element { description="Control terminals and agents from your phone." searchEntries={MOBILE_SETTINGS_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('mobile') ? ( + + ) : null} - + {isSectionMounted('computer-use') ? : null} - + {isSectionMounted('voice') ? ( + + ) : null} ) : null} @@ -1063,7 +1205,9 @@ function Settings(): React.JSX.Element { description="macOS privacy access for terminal-launched developer tools." searchEntries={DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('developer-permissions') ? ( + + ) : null} ) : null} @@ -1073,7 +1217,7 @@ function Settings(): React.JSX.Element { description="Anonymous usage data and telemetry controls." searchEntries={PRIVACY_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('privacy') ? : null} - + {isSectionMounted('shortcuts') ? : null} - + {isSectionMounted('stats') ? : null} {showDesktopOnlySettings ? ( @@ -1101,7 +1245,7 @@ function Settings(): React.JSX.Element { description="Manage remote SSH connections. Connect to remote servers to browse files, run terminals, and use git." searchEntries={SSH_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('ssh') ? : null} ) : null} @@ -1111,11 +1255,13 @@ function Settings(): React.JSX.Element { description="New features that are still taking shape. Give them a try." searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES} > - + {isSectionMounted('experimental') ? ( + + ) : null} {repos.map((repo) => { @@ -1130,14 +1276,16 @@ function Settings(): React.JSX.Element { description={repo.path} searchEntries={getRepositoryPaneSearchEntries(repo)} > - + {isSectionMounted(repoSectionId) ? ( + + ) : null} ) })} diff --git a/src/renderer/src/components/settings/SettingsSection.tsx b/src/renderer/src/components/settings/SettingsSection.tsx index a4b8d2925..500be3b38 100644 --- a/src/renderer/src/components/settings/SettingsSection.tsx +++ b/src/renderer/src/components/settings/SettingsSection.tsx @@ -1,13 +1,14 @@ import type React from 'react' import { useAppStore } from '../../store' -import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' +import type { SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' type SettingsSectionProps = { id: string title: string description: string - searchEntries: SettingsSearchEntry[] - children: React.ReactNode + searchEntries?: SettingsSearchEntry[] + children?: React.ReactNode className?: string badge?: string badgeAccessory?: React.ReactNode @@ -31,7 +32,7 @@ export function SettingsSection({ headerAction }: SettingsSectionProps): React.JSX.Element | null { const query = useAppStore((state) => state.settingsSearchQuery) - if (!forceVisible && !matchesSettingsSearch(query, searchEntries)) { + if (!forceVisible && searchEntries && !matchesSettingsSearch(query, searchEntries)) { return null } diff --git a/src/renderer/src/components/settings/settings-load-performance.ts b/src/renderer/src/components/settings/settings-load-performance.ts new file mode 100644 index 000000000..4e1b4fdd6 --- /dev/null +++ b/src/renderer/src/components/settings/settings-load-performance.ts @@ -0,0 +1,48 @@ +import type { GlobalSettings } from '../../../../shared/types' + +const EAGER_SECTION_IDS = new Set(['general']) + +export function getRuntimeTargetIdentity( + settings: Pick | null | undefined +): string { + return settings?.activeRuntimeEnvironmentId?.trim() || 'local' +} + +export function deriveNeededSectionIds(args: { + navSectionIds: string[] + mountedSectionIds: Set + activeSectionId: string | null + pendingSectionId: string | null + query: string + visibleSectionIds: Set +}): Set { + const next = new Set(args.mountedSectionIds) + for (const sectionId of args.navSectionIds) { + if (EAGER_SECTION_IDS.has(sectionId)) { + next.add(sectionId) + } + } + if (args.activeSectionId) { + next.add(args.activeSectionId) + } + if (args.pendingSectionId) { + next.add(args.pendingSectionId) + } + if (args.query.trim() !== '') { + for (const visibleSectionId of args.visibleSectionIds) { + next.add(visibleSectionId) + } + } + return next +} + +export function deriveNeededRepoIds( + repos: readonly { id: string }[], + neededSectionIds: Set +): string[] { + return repos.map((repo) => repo.id).filter((repoId) => neededSectionIds.has(`repo-${repoId}`)) +} + +export function getInitialMountedSectionIds(): Set { + return new Set(EAGER_SECTION_IDS) +}