diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 7fa06cbb..463886b3 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -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 = ({ 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 = ({ onCl {status?.current || currentDirectory || ''}

+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}
{state}
@@ -481,9 +493,10 @@ export const MobileChangesSurface: React.FC = ({ onCl 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) { + // 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( { {hasWalkthroughTab ? (
- +
) : null} diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 823adf72..8791e460 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 = ({ 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 = ({ 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 = ({ return (
+ {rootIsGitRepo === false && Array.isArray(nestedRepoOptions) && nestedRepoOptions.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null} {!isMobile && ( activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( @@ -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 ( { ); } + // 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 ( - - - +
+ {showRepositoryPicker ? ( +
+ { + if (currentDirectory) selectNestedRepo(currentDirectory, repository); + }} + repositoryRoot={currentDirectory ?? undefined} + /> +
+ ) : null} + + + +
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index bf80a40d..8324f7a3 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -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 = ({ } 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 = (
@@ -451,33 +441,13 @@ export const GitHeader: React.FC = ({ remotes={remotes} /> )} - {repositoryOptionsForPicker.length > 0 ? ( - + {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( + ) : null}
diff --git a/packages/ui/src/components/views/git/NestedRepoPicker.tsx b/packages/ui/src/components/views/git/NestedRepoPicker.tsx new file mode 100644 index 00000000..69b009f4 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoPicker.tsx @@ -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 = ({ + 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 ( + + ); +}; diff --git a/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx new file mode 100644 index 00000000..8e685852 --- /dev/null +++ b/packages/ui/src/components/views/git/NestedRepoResolutionStates.test.tsx @@ -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): string => + renderToStaticMarkup( + + + , + ); + +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...'); + }); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index c085fdaa..a504eb34 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -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(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 (
+ {rootIsGitRepo === false && Array.isArray(nestedRepos) && nestedRepos.length > 0 ? ( + { + if (rootDirectory) selectNestedRepo(rootDirectory, repository); + }} + repositoryRoot={rootDirectory ?? undefined} + /> + ) : null}