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:
@@ -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...');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user