diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index b8e98b59..9d12dd69 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -10,6 +10,7 @@ import { SyncActions } from '@/components/views/git/SyncActions'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import type { GitStatus } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { generateCommitMessage, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from '@/lib/gitApi'; @@ -21,6 +22,7 @@ import { useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; import { getRuntimeKey } from '@/lib/runtime-switch'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; @@ -56,12 +58,17 @@ type MobileChangesSurfaceProps = { export const MobileChangesSurface: React.FC = ({ onClose, initialDiffPath, initialDiffStaged = false }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); - const currentDirectory = normalizePath(useEffectiveDirectory() ?? null); + const rootDirectory = normalizePath(useEffectiveDirectory() ?? null); + // When the root is not itself a repository, changes come from the resolved + // nested repository instead. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const currentDirectory = gitDirectory ?? rootDirectory; const status = useGitStatus(currentDirectory || null); const isGitRepo = useIsGitRepo(currentDirectory || null); const isLoadingStatus = useGitLoadingStatus(currentDirectory || null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureAll = useGitStore((state) => state.ensureAll); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); const fetchStatus = useGitStore((state) => state.fetchStatus); const fetchBranches = useGitStore((state) => state.fetchBranches); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); @@ -474,12 +481,22 @@ export const MobileChangesSurface: React.FC = ({ onCl return renderListState(); } - if (isLoadingStatus && isGitRepo === null) { - return renderListState(); + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return renderListState( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); } - if (isGitRepo === false) { - return renderListState(); + if (isLoadingStatus && isGitRepo === null) { + return renderListState(); } if (route.type === 'diff') { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 0603452c..5317c041 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -18,10 +18,9 @@ import { useIsGitRepo, useGitLoadingStatus, useGitLoadingLog, - useEffectiveGitDirectory, - useNestedRepos, - useNestedRepoSelection, } from '@/stores/useGitStore'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { toast } from '@/components/ui'; @@ -256,13 +255,14 @@ export const GitView: React.FC = ({ isActive }) => { // The root the view is anchored to (session/worktree context stays keyed on // it). When the root is not itself a repository and the user picked a nested // one, `gitDirectory` is the effective repository all git data and actions - // operate on. - const rootIsGitRepo = useIsGitRepo(currentDirectory ?? null); - const gitDirectory = useEffectiveGitDirectory(currentDirectory ?? null); + // operate on. The hook owns probing, discovery, auto-select, and + // stale-selection recovery; data fetching below keys off its result. + const { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection } = useNestedGitDirectory( + currentDirectory ?? null, + { enabled: isActive }, + ); const isGitRepo = useIsGitRepo(gitDirectory ?? null); const status = useGitStatus(gitDirectory ?? null); - const nestedRepos = useNestedRepos(currentDirectory ?? null); - const nestedRepoSelection = useNestedRepoSelection(currentDirectory ?? null); // Authoritative session↔worktree attachment for repair action display const worktreeAttachment = useSessionWorktreeStore((s) => @@ -298,7 +298,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision, ensureNestedRepos, selectNestedRepo, - clearNestedRepoSelection, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, fetchAll: state.fetchAll, @@ -315,7 +314,6 @@ export const GitView: React.FC = ({ isActive }) => { bumpIndexRevision: state.bumpIndexRevision, ensureNestedRepos: state.ensureNestedRepos, selectNestedRepo: state.selectNestedRepo, - clearNestedRepoSelection: state.clearNestedRepoSelection, }))); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); @@ -898,37 +896,6 @@ export const GitView: React.FC = ({ isActive }) => { }); }, [isActive, clearDiffCache, gitDirectory, fetchStatus, git]); - // Discover nested repositories once the root probe confirms it is not one. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - void ensureNestedRepos(currentDirectory); - }, [currentDirectory, ensureNestedRepos, isActive, rootIsGitRepo]); - - // Auto-select the first nested repository so the tab opens straight into - // repository data; the header picker switches between repositories. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (rootIsGitRepo !== false) return; - if (!nestedRepos || nestedRepos.length === 0) return; - if (nestedRepoSelection) return; - selectNestedRepo(currentDirectory, nestedRepos[0]); - }, [currentDirectory, isActive, nestedRepos, nestedRepoSelection, rootIsGitRepo, selectNestedRepo]); - - // A selected repository that is no longer a git repository is stale: drop - // the selection and re-scan so the picker reflects the current tree. - React.useEffect(() => { - if (!isActive) return; - if (!currentDirectory) return; - if (!nestedRepoSelection) return; - if (gitDirectory === currentDirectory) return; - if (isGitRepo !== false) return; - clearNestedRepoSelection(currentDirectory); - void ensureNestedRepos(currentDirectory, { force: true }); - }, [clearNestedRepoSelection, currentDirectory, ensureNestedRepos, gitDirectory, isActive, isGitRepo, nestedRepoSelection]); - const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { if (!gitDirectory) return; @@ -2371,66 +2338,25 @@ export const GitView: React.FC = ({ isActive }) => { ); } - // Nested repository discovery: while unknown or failed keep a loading - // state with the failure signal; the picker appears once repositories are - // found (a single repository is auto-selected by an effect above). - if (nestedRepos === undefined || nestedRepos === null) { - return ( -
- -

- {nestedRepos === null - ? t('gitView.empty.discoverFailed') - : t('gitView.empty.discoveringRepositories')} -

- {nestedRepos === null ? ( - - ) : null} -
- ); - } - - if (nestedRepos.length === 0) { - return ( -
- -

- {t('gitView.empty.notGitRepository')} -

-

- {t('gitView.empty.notGitRepositoryDescription')} -

- {repairActions.includes('open-without-worktree-features') ? ( + // Nested repository discovery states (discovering, failed, unsupported, + // none found, or settling on the auto-selected repository). + return ( + { + if (currentDirectory) { + void ensureNestedRepos(currentDirectory, { force: true }); + } + }} + emptyStateFooter={ + repairActions.includes('open-without-worktree-features') ? (

{t('gitView.empty.worktreeFeaturesUnavailable')}

- ) : null} -
- ); - } - - // Repositories were found and are about to be auto-selected (or the - // selected repository is still probing) — hold a brief loading state. - return ( -
- -

- {t('gitView.loading.checkingRepository')} -

-
+ ) : undefined + } + /> ); } @@ -2465,7 +2391,9 @@ export const GitView: React.FC = ({ isActive }) => { onOpenPullRequest={ gitDirectory ? () => openContextSurface(gitDirectory, 'pr') : undefined } - repositoryOptions={gitDirectory !== currentDirectory ? (nestedRepos ?? undefined) : undefined} + repositoryOptions={ + gitDirectory !== currentDirectory && Array.isArray(nestedRepos) ? nestedRepos : undefined + } selectedRepository={gitDirectory !== currentDirectory ? gitDirectory : null} onSelectRepository={ gitDirectory !== currentDirectory && currentDirectory diff --git a/packages/ui/src/components/views/PullRequestView.tsx b/packages/ui/src/components/views/PullRequestView.tsx index baade489..a597b11e 100644 --- a/packages/ui/src/components/views/PullRequestView.tsx +++ b/packages/ui/src/components/views/PullRequestView.tsx @@ -2,10 +2,11 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; -import { useGitStatus, useGitBranches, useGitStore } from '@/stores/useGitStore'; +import { useGitStatus, useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useShallow } from 'zustand/react/shallow'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -14,6 +15,7 @@ import { useI18n } from '@/lib/i18n'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { PullRequestSection } from './git/PullRequestSection'; +import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { deriveBaseBranch } from './git/baseBranch'; const normalizePath = (value?: string | null): string => @@ -36,9 +38,16 @@ export const PullRequestView: React.FC = () => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); - const status = useGitStatus(currentDirectory ?? null); - const branches = useGitBranches(currentDirectory ?? null); - const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); + // When the root is not itself a repository, the pull-request workflow + // operates on the resolved nested repository instead. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(currentDirectory ?? null); + const status = useGitStatus(gitDirectory ?? null); + const branches = useGitBranches(gitDirectory ?? null); + const isGitRepo = useIsGitRepo(gitDirectory ?? null); + const { ensureAll, ensureNestedRepos } = useGitStore(useShallow((state) => ({ + ensureAll: state.ensureAll, + ensureNestedRepos: state.ensureNestedRepos, + }))); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -89,11 +98,11 @@ export const PullRequestView: React.FC = () => { const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined); React.useEffect(() => { - if (!currentDirectory || !git) { + if (!gitDirectory || !git) { return; } - void ensureAll(currentDirectory, git); - }, [currentDirectory, ensureAll, git]); + void ensureAll(gitDirectory, git); + }, [gitDirectory, ensureAll, git]); const [rootBranchHint, setRootBranchHint] = React.useState(null); React.useEffect(() => { @@ -122,52 +131,52 @@ export const PullRequestView: React.FC = () => { }, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]); const [remotes, setRemotes] = React.useState(() => - (currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? [] + (gitDirectory ? remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? [] ); const [remoteUrl, setRemoteUrl] = React.useState(() => - (currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null + (gitDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) : undefined) ?? null ); React.useEffect(() => { - if (!currentDirectory || !git?.getRemotes) { + if (!gitDirectory || !git?.getRemotes) { setRemotes([]); return; } - setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); + setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); let cancelled = false; - void git.getRemotes(currentDirectory) + void git.getRemotes(gitDirectory) .then((remoteList) => { if (cancelled) return; - remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []); + remotesCacheByDirectory.set(remoteCacheKey(gitDirectory), remoteList ?? []); setRemotes(remoteList ?? []); }) - .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); }); + .catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? []); }); return () => { cancelled = true; }; - }, [currentDirectory, git]); + }, [gitDirectory, git]); React.useEffect(() => { - if (!currentDirectory || !git?.getRemoteUrl) { + if (!gitDirectory || !git?.getRemoteUrl) { setRemoteUrl(null); return; } - setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); + setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); let cancelled = false; - void git.getRemoteUrl(currentDirectory) + void git.getRemoteUrl(gitDirectory) .then((url) => { if (cancelled) return; - remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url); + remoteUrlCacheByDirectory.set(remoteCacheKey(gitDirectory), url); setRemoteUrl(url); }) - .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); }); + .catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(gitDirectory)) ?? null); }); return () => { cancelled = true; }; - }, [currentDirectory, git]); + }, [gitDirectory, git]); const localBranches = React.useMemo(() => { if (!branches?.all) return []; @@ -240,7 +249,31 @@ export const PullRequestView: React.FC = () => { worktreeMetadata?.createdFromBranch, ]); - if (!currentDirectory || !currentBranch) { + if (!currentDirectory) { + return ( +
+ +
{t('gitView.pullRequest.title')}
+
{t('gitView.pullRequest.createHint')}
+
+ ); + } + + // Non-repo root: surface nested-repository resolution (discovering, failed, + // unsupported, none found, or settling on the auto-selected repository). + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + void ensureNestedRepos(currentDirectory, { force: true }); + }} + /> + ); + } + + if (!currentBranch) { return (
@@ -259,7 +292,7 @@ export const PullRequestView: React.FC = () => { preventOverscroll > void; + /** Optional extra line under the not-a-repository description. */ + emptyStateFooter?: React.ReactNode; +}; + +/** + * Shared empty/loading states for git surfaces while nested-repository + * resolution is pending, failed, or impossible. Renders null once + * repositories are resolved so the consumer can proceed into its own content. + * + * A runtime without the discovery route (VS Code) reports "unsupported": the + * honest state there is the plain not-a-repository empty state, without a + * retry that can never succeed. + */ +export const NestedRepoResolutionStates: React.FC = ({ + rootIsGitRepo, + nestedRepos, + onRetryDiscovery, + emptyStateFooter, +}) => { + const { t } = useI18n(); + + if (rootIsGitRepo !== false) return null; + + if (nestedRepos === undefined || nestedRepos === null) { + return ( +
+ +

+ {nestedRepos === null + ? t('gitView.empty.discoverFailed') + : t('gitView.empty.discoveringRepositories')} +

+ {nestedRepos === null ? ( + + ) : null} +
+ ); + } + + if (nestedRepos === 'unsupported' || nestedRepos.length === 0) { + return ( +
+ +

+ {t('gitView.empty.notGitRepository')} +

+

+ {t('gitView.empty.notGitRepositoryDescription')} +

+ {emptyStateFooter} +
+ ); + } + + // Repositories were found and one is about to be auto-selected (or the + // selected repository is still probing) — hold a brief loading state. + return ( +
+ +

+ {t('gitView.loading.checkingRepository')} +

+
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 51b20387..86eada61 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -19,7 +19,7 @@ import { ModelSelector } from '@/components/sections/agents/ModelSelector'; import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore'; +import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getFreshestPrStatusForBranch, @@ -27,6 +27,7 @@ import { useGitHubPrStatusStore, } from '@/stores/useGitHubPrStatusStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { useUIStore } from '@/stores/useUIStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { cn } from '@/lib/utils'; @@ -36,6 +37,7 @@ import { WalkthroughStages } from './WalkthroughStages'; import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; import { WalkthroughStream } from './WalkthroughStream'; import { WalkthroughToc } from './WalkthroughToc'; +import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates'; interface WalkthroughViewProps { directory: string; @@ -73,11 +75,17 @@ const TOC_MAX_FRACTION = 0.5; // pickers, 32px action, 36px arrows) read as misalignment, not hierarchy. const HEADER_COMPACT_WIDTH = 680; -export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { +export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewProps) => { const { t, locale, locales, label } = useI18n(); const rootRef = useRef(null); const [panelWidth, setPanelWidth] = useState(0); + // The walkthrough documents one repository. When the root is not itself a + // repository, that is the resolved nested repository; everything below keys + // off `directory`. + const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); + const directory = gitDirectory ?? rootDirectory; + // Panel width, not viewport width: this surface is resizable independently of // the window. useEffect(() => { @@ -484,6 +492,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [activeLanguage, directory, generate, generateDisabled, source] ); + const isGitRepo = useIsGitRepo(gitDirectory || null); + const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos); + if (rootIsGitRepo === false || isGitRepo === false) { + return ( + { + if (rootDirectory) void ensureNestedRepos(rootDirectory, { force: true }); + }} + /> + ); + } + return (
diff --git a/packages/ui/src/hooks/useNestedGitDirectory.ts b/packages/ui/src/hooks/useNestedGitDirectory.ts new file mode 100644 index 00000000..c0855805 --- /dev/null +++ b/packages/ui/src/hooks/useNestedGitDirectory.ts @@ -0,0 +1,98 @@ +import React from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { + useEffectiveGitDirectory, + useGitStore, + useIsGitRepo, + useNestedRepoSelection, + useNestedRepos, +} from '@/stores/useGitStore'; + +type UseNestedGitDirectoryOptions = { + /** False defers all probing/discovery work while the surface is hidden. */ + enabled?: boolean; +}; + +/** + * Resolves the repository a git surface operates on when the project root may + * not itself be a git repository. Owns the full resolution flow: probing the + * root, discovering nested repositories, auto-selecting the first one, and + * dropping a selection whose repository disappeared. + * + * Consumers still fetch their own git data for the returned `gitDirectory`; + * this hook only owns who that directory is. + */ +export const useNestedGitDirectory = ( + root: string | null, + options: UseNestedGitDirectoryOptions = {}, +) => { + const { enabled = true } = options; + const { git } = useRuntimeAPIs(); + + const rootIsGitRepo = useIsGitRepo(root); + const gitDirectory = useEffectiveGitDirectory(root); + const nestedRepos = useNestedRepos(root); + const nestedRepoSelection = useNestedRepoSelection(root); + + // Probe of the resolved repository, used to detect a stale selection. Null + // when there is nothing selected to probe. + const selectedIsGitRepo = useIsGitRepo( + gitDirectory && gitDirectory !== root ? gitDirectory : null, + ); + + const { ensureStatus, ensureNestedRepos, selectNestedRepo, clearNestedRepoSelection } = useGitStore( + useShallow((state) => ({ + ensureStatus: state.ensureStatus, + ensureNestedRepos: state.ensureNestedRepos, + selectNestedRepo: state.selectNestedRepo, + clearNestedRepoSelection: state.clearNestedRepoSelection, + })), + ); + + // Probe the root itself so nested-repo resolution never depends on some + // other surface (e.g. the sidebar badge) having probed it first. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== null) return; + void ensureStatus(root, git); + }, [enabled, ensureStatus, git, root, rootIsGitRepo]); + + // Discover nested repositories once the root probe confirms it is not one. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== false) return; + void ensureNestedRepos(root); + }, [enabled, ensureNestedRepos, root, rootIsGitRepo]); + + // Auto-select the first nested repository so the surface opens straight + // into repository data; a picker (where rendered) switches between them. + React.useEffect(() => { + if (!enabled || !root) return; + if (rootIsGitRepo !== false) return; + if (!nestedRepos || nestedRepos.length === 0) return; + if (nestedRepoSelection) return; + selectNestedRepo(root, nestedRepos[0]); + }, [enabled, nestedRepos, nestedRepoSelection, root, rootIsGitRepo, selectNestedRepo]); + + // A selected repository that is no longer a git repository is stale: drop + // the selection and re-scan so resolution reflects the current tree. + React.useEffect(() => { + if (!enabled || !root || !nestedRepoSelection) return; + if (!gitDirectory || gitDirectory === root) return; + if (selectedIsGitRepo !== false) return; + clearNestedRepoSelection(root); + void ensureNestedRepos(root, { force: true }); + }, [ + clearNestedRepoSelection, + enabled, + ensureNestedRepos, + gitDirectory, + nestedRepoSelection, + root, + selectedIsGitRepo, + ]); + + return { rootIsGitRepo, gitDirectory, nestedRepos, nestedRepoSelection }; +}; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 8dae236a..1897e087 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -148,7 +148,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()` -- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory the Git tab operates on (`root` when the root is a repository, the selected nested repository otherwise) +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface, and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes