fix(git): resolve blank nested-repo surfaces; add repository pickers

The resolution gate on the pull-request, walkthrough, and mobile changes
surfaces stayed on forever (rootIsGitRepo stays false on a non-repo
root) while NestedRepoResolutionStates exits once the selected
repository probes as a repository, so those surfaces rendered nothing.
The gate now shows resolution states only while the operating directory
has not proven to be a repository, matching GitView.

Extract GitHeader's repository switcher into git/NestedRepoPicker and
mount it in the diff toolbar, a new slim header in the pull-request
view, the walkthrough header, and the mobile changes header. The pick
is shared per root, so every surface follows.

The walkthrough tab mounts keep-alive and hidden; it now receives a
visible prop so discovery waits until the tab is actually opened. Add
component tests for the shared resolution states.
This commit is contained in:
jaygupta17
2026-08-25 20:47:02 +05:30
parent e26b55e067
commit 11a48958e8
9 changed files with 241 additions and 67 deletions
+16 -3
View File
@@ -23,6 +23,7 @@ import {
useGitLoadingStatus,
} from '@/stores/useGitStore';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { getRuntimeKey } from '@/lib/runtime-switch';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
@@ -69,6 +70,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureAll = useGitStore((state) => state.ensureAll);
const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
@@ -472,6 +474,16 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
{status?.current || currentDirectory || ''}
</p>
</div>
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
</header>
<div className="min-h-0 flex-1">{state}</div>
</div>
@@ -481,9 +493,10 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
return renderListState(<MobileChangesState message={t('gitView.empty.selectSessionOrDirectory')} />);
}
// Non-repo root: surface nested-repository resolution (discovering, failed,
// unsupported, none found, or settling on the auto-selected repository).
if (rootIsGitRepo === false || isGitRepo === false) {
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return renderListState(
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
@@ -1228,7 +1228,7 @@ export const ContextPanel: React.FC = () => {
{hasWalkthroughTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
<React.Suspense fallback={null}>
<WalkthroughView directory={effectiveDirectory} />
<WalkthroughView directory={effectiveDirectory} visible={activeTab?.mode === 'walkthrough'} />
</React.Suspense>
</div>
) : null}
+13 -1
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
@@ -1001,7 +1002,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const rootDirectory = useEffectiveDirectory();
// Diffs belong to the repository being diffed: when the root is not
// itself a repository, operate on the resolved nested repository instead.
const { gitDirectory: nestedGitDirectory } = useNestedGitDirectory(rootDirectory ?? null);
const { rootIsGitRepo, gitDirectory: nestedGitDirectory, nestedRepos: nestedRepoOptions } = useNestedGitDirectory(rootDirectory ?? null);
const effectiveDirectory = nestedGitDirectory ?? rootDirectory;
const openContextSurface = useUIStore((state) => state.openContextSurface);
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
@@ -1012,6 +1013,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isLoadingStatus = useGitLoadingStatus(effectiveDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const ensureStatus = useGitStore((state) => state.ensureStatus);
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
@@ -2044,6 +2046,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepoOptions}
selectedRepository={nestedGitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
{!isMobile && (
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
<ChangeScopeSelector
@@ -16,6 +16,7 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { PullRequestSection } from './git/PullRequestSection';
import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates';
import { NestedRepoPicker } from './git/NestedRepoPicker';
import { deriveBaseBranch } from './git/baseBranch';
const normalizePath = (value?: string | null): string =>
@@ -44,9 +45,10 @@ export const PullRequestView: React.FC = () => {
const status = useGitStatus(gitDirectory ?? null);
const branches = useGitBranches(gitDirectory ?? null);
const isGitRepo = useIsGitRepo(gitDirectory ?? null);
const { ensureAll, ensureNestedRepos } = useGitStore(useShallow((state) => ({
const { ensureAll, ensureNestedRepos, selectNestedRepo } = useGitStore(useShallow((state) => ({
ensureAll: state.ensureAll,
ensureNestedRepos: state.ensureNestedRepos,
selectNestedRepo: state.selectNestedRepo,
})));
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -259,9 +261,10 @@ export const PullRequestView: React.FC = () => {
);
}
// Non-repo root: surface nested-repository resolution (discovering, failed,
// unsupported, none found, or settling on the auto-selected repository).
if (rootIsGitRepo === false || isGitRepo === false) {
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
@@ -284,22 +287,41 @@ export const PullRequestView: React.FC = () => {
);
}
// Repository switcher for non-repo roots with discovered nested
// repositories; the pick is shared per root across git surfaces.
const showRepositoryPicker =
rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0;
return (
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={gitDirectory ?? currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
/>
</ScrollableOverlay>
<div className="flex h-full min-h-0 flex-col">
{showRepositoryPicker ? (
<div className="flex shrink-0 items-center border-b border-border/60 px-4 py-2">
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (currentDirectory) selectNestedRepo(currentDirectory, repository);
}}
repositoryRoot={currentDirectory ?? undefined}
/>
</div>
) : null}
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0 flex-1"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={gitDirectory ?? currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
/>
</ScrollableOverlay>
</div>
);
};
@@ -12,12 +12,7 @@ import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { NestedRepoPicker } from './NestedRepoPicker';
import type {
GitStatus,
GitIdentityProfile,
@@ -282,11 +277,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
}
const repositoryOptionsForPicker = (repositoryOptions ?? []).filter(Boolean);
const repositoryRelativePath = (repository: string): string => {
const rootPrefix = `${repositoryRoot ?? ''}/`;
return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository;
};
const repositoryLabel = selectedRepository ? repositoryRelativePath(selectedRepository) : '';
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
@@ -451,33 +441,13 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
remotes={remotes}
/>
)}
{repositoryOptionsForPicker.length > 0 ? (
<Select
value={selectedRepository ?? undefined}
onValueChange={(value) => {
if (value && onSelectRepository) {
onSelectRepository(value);
}
}}
>
<SelectTrigger
size="sm"
className="max-w-[13rem] gap-1.5 px-2 py-1"
aria-label={t('gitView.empty.selectRepositoryPlaceholder')}
>
<Icon name="folder-3" className="size-4 text-muted-foreground" />
<span className="min-w-0 truncate font-medium text-left">
{repositoryLabel}
</span>
</SelectTrigger>
<SelectContent align="start">
{repositoryOptionsForPicker.map((repository) => (
<SelectItem key={repository} value={repository}>
<span className="truncate">{repositoryRelativePath(repository)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
<NestedRepoPicker
repositories={repositoryOptionsForPicker}
selectedRepository={selectedRepository ?? null}
onSelectRepository={onSelectRepository}
repositoryRoot={repositoryRoot}
/>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -0,0 +1,67 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type NestedRepoPickerProps = {
/** Discovered repository paths under the project root. */
repositories: string[];
/** Currently selected repository path (the operating directory). */
selectedRepository: string | null;
onSelectRepository: (repository: string) => void;
/** Root the repository paths are relative to for display labels. */
repositoryRoot?: string;
};
/**
* Repository switcher shown on git surfaces when a project root is not itself
* a git repository but nested repositories were discovered under it.
*/
export const NestedRepoPicker: React.FC<NestedRepoPickerProps> = ({
repositories,
selectedRepository,
onSelectRepository,
repositoryRoot,
}) => {
const { t } = useI18n();
const relativePath = (repository: string): string => {
const rootPrefix = `${repositoryRoot ?? ''}/`;
return repository.startsWith(rootPrefix) ? repository.slice(rootPrefix.length) : repository;
};
return (
<Select
value={selectedRepository ?? undefined}
onValueChange={(value) => {
if (value) {
onSelectRepository(value);
}
}}
>
<SelectTrigger
size="sm"
className="max-w-[13rem] gap-1.5 px-2 py-1"
aria-label={t('gitView.empty.selectRepositoryPlaceholder')}
>
<Icon name="folder-3" className="size-4 text-muted-foreground" />
<span className="min-w-0 truncate font-medium text-left">
{selectedRepository ? relativePath(selectedRepository) : ''}
</span>
</SelectTrigger>
<SelectContent align="start">
{repositories.map((repository) => (
<SelectItem key={repository} value={repository}>
<span className="truncate">{relativePath(repository)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
import { NestedRepoResolutionStates } from './NestedRepoResolutionStates';
const render = (props: React.ComponentProps<typeof NestedRepoResolutionStates>): string =>
renderToStaticMarkup(
<I18nProvider>
<NestedRepoResolutionStates {...props} />
</I18nProvider>,
);
const baseProps = {
onRetryDiscovery: () => {},
};
describe('NestedRepoResolutionStates', () => {
test('renders nothing while the root has not probed as a non-repository', () => {
for (const rootIsGitRepo of [null, true] as const) {
const markup = render({ ...baseProps, rootIsGitRepo, resolvedIsGitRepo: null, nestedRepos: undefined });
expect(markup).toBe('');
}
});
test('renders nothing once the operating directory resolved as a repository', () => {
const markup = render({
...baseProps,
rootIsGitRepo: false,
resolvedIsGitRepo: true,
nestedRepos: ['/root/one'],
});
expect(markup).toBe('');
});
test('shows the discovering state before discovery has run', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: undefined });
expect(markup).toContain('Looking for Git repositories...');
});
test('shows the failure state with a retry when discovery failed', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: null });
expect(markup).toContain('Could not scan for Git repositories');
expect(markup).toContain('Retry');
});
test('shows the plain not-a-repository state with no retry when unsupported', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: 'unsupported' });
expect(markup).toContain('This directory is not a Git repository');
expect(markup).not.toContain('Retry');
});
test('treats an empty discovery like the not-a-repository state', () => {
const markup = render({ ...baseProps, rootIsGitRepo: false, resolvedIsGitRepo: null, nestedRepos: [] });
expect(markup).toContain('This directory is not a Git repository');
});
test('holds a checking state while repositories are found but unresolved', () => {
const markup = render({
...baseProps,
rootIsGitRepo: false,
resolvedIsGitRepo: null,
nestedRepos: ['/root/one', '/root/two'],
});
expect(markup).toContain('Checking repository...');
});
});
@@ -38,9 +38,16 @@ import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
import { WalkthroughStream } from './WalkthroughStream';
import { WalkthroughToc } from './WalkthroughToc';
import { NestedRepoResolutionStates } from '@/components/views/git/NestedRepoResolutionStates';
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
interface WalkthroughViewProps {
directory: string;
/**
* The context panel keeps this view mounted but hidden via CSS, so work
* that should only run for a visible consumer has to be told. Defaults to
* true for mounts that have no visibility signal.
*/
visible?: boolean;
}
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
@@ -75,7 +82,7 @@ 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: rootDirectory }: WalkthroughViewProps) => {
export const WalkthroughView = ({ directory: rootDirectory, visible = true }: WalkthroughViewProps) => {
const { t, locale, locales, label } = useI18n();
const rootRef = useRef<HTMLDivElement | null>(null);
const [panelWidth, setPanelWidth] = useState(0);
@@ -83,7 +90,7 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro
// 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 { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null, { enabled: visible });
const directory = gitDirectory ?? rootDirectory;
// Panel width, not viewport width: this surface is resizable independently of
@@ -494,7 +501,11 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro
const isGitRepo = useIsGitRepo(gitDirectory || null);
const ensureNestedRepos = useGitStore((state) => state.ensureNestedRepos);
if (rootIsGitRepo === false || isGitRepo === false) {
const selectNestedRepo = useGitStore((state) => state.selectNestedRepo);
// Non-repo root: surface nested-repository resolution while the operating
// directory has not proven to be a repository (discovering, failed,
// unsupported, none found, or settling on the auto-selected one).
if (rootIsGitRepo === false && isGitRepo !== true) {
return (
<NestedRepoResolutionStates
rootIsGitRepo={rootIsGitRepo}
@@ -510,6 +521,16 @@ export const WalkthroughView = ({ directory: rootDirectory }: WalkthroughViewPro
return (
<div ref={rootRef} className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
{rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? (
<NestedRepoPicker
repositories={nestedRepos}
selectedRepository={gitDirectory ?? null}
onSelectRepository={(repository) => {
if (rootDirectory) selectNestedRepo(rootDirectory, repository);
}}
repositoryRoot={rootDirectory ?? undefined}
/>
) : null}
<DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}>
<DropdownMenuTrigger asChild>
<button
+1 -1
View File
@@ -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), 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
- 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 (Git tab, diff view, pull-request view, walkthrough view, mobile changes), 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