feat(git): support nested git repositories in the Git tab
When the project root is not itself a git repository, discover nested repositories (depth- and visit-capped readdir walk via a new /api/fs/git-dirs route), auto-select the first one, and show a repository picker next to the branch dropdown to switch. Selections persist per runtime and root; discovery failure is a distinct marker with a retry action, never an empty success.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,12 @@ 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 type {
|
||||
GitStatus,
|
||||
GitIdentityProfile,
|
||||
@@ -51,6 +57,13 @@ interface GitHeaderProps {
|
||||
pullRequest?: GitHubPullRequest | null;
|
||||
prChecks?: GitHubChecksSummary | null;
|
||||
onOpenPullRequest?: () => void;
|
||||
// Nested repository picker: shown when the Git tab operates on a repository
|
||||
// nested inside a non-repository root. Options are absolute repository
|
||||
// paths; `repositoryRoot` is the root those paths are relative to.
|
||||
repositoryOptions?: string[];
|
||||
selectedRepository?: string | null;
|
||||
onSelectRepository?: (repository: string) => void;
|
||||
repositoryRoot?: string;
|
||||
}
|
||||
|
||||
const IDENTITY_ICON_MAP: Record<string, IconName> = {
|
||||
@@ -258,12 +271,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
pullRequest,
|
||||
prChecks,
|
||||
onOpenPullRequest,
|
||||
repositoryOptions,
|
||||
selectedRepository,
|
||||
onSelectRepository,
|
||||
repositoryRoot,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
|
||||
@@ -410,7 +434,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
return (
|
||||
<header className="@container/git-header px-3 py-2 bg-transparent">
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{isWorktreeMode ? (
|
||||
<WorktreeBranchDisplay
|
||||
currentBranch={status.current}
|
||||
@@ -427,6 +451,34 @@ 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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{identityControl}
|
||||
|
||||
@@ -115,6 +115,23 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGitDirectories(root: string): Promise<string[]> {
|
||||
const response = await runtimeFetch('/api/fs/git-dirs', { query: { path: root } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list git directories: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || !Array.isArray(data.repositories)) {
|
||||
throw new Error('Unexpected git directories response');
|
||||
}
|
||||
return data.repositories
|
||||
.map((entry: unknown) => {
|
||||
const path = entry && typeof entry === 'object' && 'path' in entry ? (entry as { path?: unknown }).path : undefined;
|
||||
return typeof path === 'string' && path.trim() ? path.trim() : null;
|
||||
})
|
||||
.filter((path: string | null): path is string => path !== null);
|
||||
}
|
||||
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
||||
const mode = options?.mode;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
|
||||
@@ -859,6 +859,10 @@ export const dict = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.',
|
||||
'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.',
|
||||
'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft',
|
||||
'gitView.empty.discoveringRepositories': 'Suche nach Git-Repositories...',
|
||||
'gitView.empty.discoverFailed': 'Git-Repositories konnten nicht durchsucht werden',
|
||||
'gitView.empty.retryDiscovery': 'Erneut versuchen',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'Repository auswählen...',
|
||||
'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen',
|
||||
'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.',
|
||||
'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.',
|
||||
|
||||
@@ -923,6 +923,10 @@ export const dict = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
|
||||
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
||||
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
|
||||
'gitView.empty.discoveringRepositories': 'Looking for Git repositories...',
|
||||
'gitView.empty.discoverFailed': 'Could not scan for Git repositories',
|
||||
'gitView.empty.retryDiscovery': 'Retry',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'Select a repository...',
|
||||
'worktree.bootstrap.toast.failed': 'Worktree setup failed',
|
||||
'worktree.bootstrap.toast.failedDescription': 'The worktree was created, but background setup did not finish.',
|
||||
'worktree.bootstrap.toast.timeoutDescription': 'The worktree was created, but background setup timed out.',
|
||||
|
||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.empty.worktreeFeaturesUnavailable": "Las características de worktree no están disponibles en este modo de espacio de trabajo.",
|
||||
"gitView.empty.worktreeSetupDescription": "Finalizando la configuración de worktree y preparando el estado del repositorio.",
|
||||
"gitView.empty.worktreeSetupInProgress": "Configuración de worktree en progreso",
|
||||
"gitView.empty.discoveringRepositories": "Buscando repositorios de Git...",
|
||||
"gitView.empty.discoverFailed": "No se pudo escanear en busca de repositorios de Git",
|
||||
"gitView.empty.retryDiscovery": "Reintentar",
|
||||
"gitView.empty.selectRepositoryPlaceholder": "Selecciona un repositorio...",
|
||||
"worktree.bootstrap.toast.failed": "Error al configurar el worktree",
|
||||
"worktree.bootstrap.toast.failedDescription": "El worktree se creó, pero la configuración en segundo plano no terminó.",
|
||||
"worktree.bootstrap.toast.timeoutDescription": "El worktree se creó, pero la configuración en segundo plano agotó el tiempo de espera.",
|
||||
|
||||
@@ -751,6 +751,10 @@ export const dict = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Les fonctionnalités Worktree ne sont pas disponibles dans ce mode d’espace de travail.',
|
||||
'gitView.empty.worktreeSetupDescription': 'Termine la configuration du worktree et prépare l\'état du dépôt.',
|
||||
'gitView.empty.worktreeSetupInProgress': 'Configuration de worktree en cours',
|
||||
'gitView.empty.discoveringRepositories': 'Recherche des dépôts Git...',
|
||||
'gitView.empty.discoverFailed': 'Impossible d’analyser les dépôts Git',
|
||||
'gitView.empty.retryDiscovery': 'Réessayer',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'Sélectionnez un dépôt...',
|
||||
'gitView.gitmoji.empty': 'Aucun gitmoji trouvé',
|
||||
'gitView.gitmoji.searchPlaceholder': 'Rechercher des gitmoji...',
|
||||
'gitView.gitmoji.title': 'Insérer un gitmoji',
|
||||
|
||||
@@ -920,6 +920,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'このワークスペースモードではワークツリー機能は利用できません。',
|
||||
'gitView.empty.worktreeSetupDescription': 'ワークツリーのセットアップを完了し、リポジトリ状態を準備中。',
|
||||
'gitView.empty.worktreeSetupInProgress': 'ワークツリーのセットアップ進行中',
|
||||
'gitView.empty.discoveringRepositories': 'Git リポジトリを検索しています...',
|
||||
'gitView.empty.discoverFailed': 'Git リポジトリを検索できませんでした',
|
||||
'gitView.empty.retryDiscovery': '再試行',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'リポジトリを選択...',
|
||||
'worktree.bootstrap.toast.failed': 'ワークツリーのセットアップに失敗しました',
|
||||
'worktree.bootstrap.toast.failedDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップが完了しませんでした。',
|
||||
'worktree.bootstrap.toast.timeoutDescription': 'ワークツリーは作成されましたが、バックグラウンドセットアップがタイムアウトしました。',
|
||||
|
||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': '이 워크스페이스 모드에서는 워크트리 기능을 사용할 수 없습니다.',
|
||||
'gitView.empty.worktreeSetupDescription': '워크트리 설정을 마치고 레포지토리 상태를 준비하고 있습니다.',
|
||||
'gitView.empty.worktreeSetupInProgress': '워크트리 설정 중',
|
||||
'gitView.empty.discoveringRepositories': 'Git 저장소를 찾는 중...',
|
||||
'gitView.empty.discoverFailed': 'Git 저장소를 검색할 수 없습니다',
|
||||
'gitView.empty.retryDiscovery': '다시 시도',
|
||||
'gitView.empty.selectRepositoryPlaceholder': '저장소 선택...',
|
||||
'worktree.bootstrap.toast.failed': '워크트리 설정 실패',
|
||||
'worktree.bootstrap.toast.failedDescription': '워크트리는 생성되었지만 백그라운드 설정이 완료되지 않았습니다.',
|
||||
'worktree.bootstrap.toast.timeoutDescription': '워크트리는 생성되었지만 백그라운드 설정 시간이 초과되었습니다.',
|
||||
|
||||
@@ -1958,6 +1958,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': 'Worktree features are unavailable in this workspace mode.',
|
||||
'gitView.empty.worktreeSetupDescription': 'Finishing worktree setup and preparing repository state.',
|
||||
'gitView.empty.worktreeSetupInProgress': 'Worktree setup in progress',
|
||||
'gitView.empty.discoveringRepositories': 'Szukanie repozytoriów Git...',
|
||||
'gitView.empty.discoverFailed': 'Nie udało się przeskanować repozytoriów Git',
|
||||
'gitView.empty.retryDiscovery': 'Ponów',
|
||||
'gitView.empty.selectRepositoryPlaceholder': 'Wybierz repozytorium...',
|
||||
'worktree.bootstrap.toast.failed': 'Konfiguracja drzewa pracy nie powiodła się',
|
||||
'worktree.bootstrap.toast.failedDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle nie została ukończona.',
|
||||
'worktree.bootstrap.toast.timeoutDescription': 'Drzewo pracy zostało utworzone, ale konfiguracja w tle przekroczyła limit czasu.',
|
||||
|
||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.empty.worktreeFeaturesUnavailable": "Os recursos de worktree não estão disponíveis neste modo de workspace.",
|
||||
"gitView.empty.worktreeSetupDescription": "Finalizando a configuração de worktree e preparando o status do repositório.",
|
||||
"gitView.empty.worktreeSetupInProgress": "Configuração de worktree em andamento",
|
||||
"gitView.empty.discoveringRepositories": "Procurando repositórios Git...",
|
||||
"gitView.empty.discoverFailed": "Não foi possível verificar os repositórios Git",
|
||||
"gitView.empty.retryDiscovery": "Tentar novamente",
|
||||
"gitView.empty.selectRepositoryPlaceholder": "Selecione um repositório...",
|
||||
"worktree.bootstrap.toast.failed": "Falha na configuração do worktree",
|
||||
"worktree.bootstrap.toast.failedDescription": "O worktree foi criado, mas a configuração em segundo plano não terminou.",
|
||||
"worktree.bootstrap.toast.timeoutDescription": "O worktree foi criado, mas a configuração em segundo plano atingiu o tempo limite.",
|
||||
|
||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.empty.worktreeFeaturesUnavailable": "У цьому режимі робочої області функції worktree недоступні.",
|
||||
"gitView.empty.worktreeSetupDescription": "Завершення налаштування worktree та підготовка стану сховища.",
|
||||
"gitView.empty.worktreeSetupInProgress": "Виконується налаштування worktree",
|
||||
"gitView.empty.discoveringRepositories": "Пошук репозиторіїв Git...",
|
||||
"gitView.empty.discoverFailed": "Не вдалося просканувати репозиторії Git",
|
||||
"gitView.empty.retryDiscovery": "Повторити",
|
||||
"gitView.empty.selectRepositoryPlaceholder": "Виберіть репозиторій...",
|
||||
"worktree.bootstrap.toast.failed": "Не вдалося налаштувати worktree",
|
||||
"worktree.bootstrap.toast.failedDescription": "Worktree створено, але фонове налаштування не завершилося.",
|
||||
"worktree.bootstrap.toast.timeoutDescription": "Worktree створено, але час очікування фонового налаштування минув.",
|
||||
|
||||
@@ -924,6 +924,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': '当前工作区模式下,工作树功能不可用。',
|
||||
'gitView.empty.worktreeSetupDescription': '正在完成工作树设置并准备仓库状态。',
|
||||
'gitView.empty.worktreeSetupInProgress': '工作树设置进行中',
|
||||
'gitView.empty.discoveringRepositories': '正在查找 Git 仓库...',
|
||||
'gitView.empty.discoverFailed': '无法扫描 Git 仓库',
|
||||
'gitView.empty.retryDiscovery': '重试',
|
||||
'gitView.empty.selectRepositoryPlaceholder': '选择仓库...',
|
||||
'worktree.bootstrap.toast.failed': '工作树设置失败',
|
||||
'worktree.bootstrap.toast.failedDescription': '工作树已创建,但后台设置未完成。',
|
||||
'worktree.bootstrap.toast.timeoutDescription': '工作树已创建,但后台设置超时。',
|
||||
|
||||
@@ -936,6 +936,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.empty.worktreeFeaturesUnavailable': '目前工作區模式下,worktree 功能無法使用。',
|
||||
'gitView.empty.worktreeSetupDescription': '正在完成 worktree 設定並準備儲存庫狀態。',
|
||||
'gitView.empty.worktreeSetupInProgress': 'worktree 設定進行中',
|
||||
'gitView.empty.discoveringRepositories': '正在尋找 Git 儲存庫...',
|
||||
'gitView.empty.discoverFailed': '無法掃描 Git 儲存庫',
|
||||
'gitView.empty.retryDiscovery': '重試',
|
||||
'gitView.empty.selectRepositoryPlaceholder': '選擇儲存庫...',
|
||||
'worktree.bootstrap.toast.failed': 'worktree 設定失敗',
|
||||
'worktree.bootstrap.toast.failedDescription': 'worktree 已建立,但背景設定未完成。',
|
||||
'worktree.bootstrap.toast.timeoutDescription': 'worktree 已建立,但背景設定逾時。',
|
||||
|
||||
@@ -133,6 +133,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)
|
||||
- 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
|
||||
|
||||
@@ -335,3 +335,61 @@ describe('useGitStore', () => {
|
||||
expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGitStore nested repository discovery', () => {
|
||||
beforeEach(() => {
|
||||
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
|
||||
});
|
||||
|
||||
test('selects a nested repo per root and persists the selection', () => {
|
||||
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/repo-one');
|
||||
|
||||
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
|
||||
|
||||
// Re-seeding from storage (as a page refresh would) restores the pick.
|
||||
useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey());
|
||||
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/repo-one');
|
||||
});
|
||||
|
||||
test('keeps selections isolated per root', () => {
|
||||
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
|
||||
useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
|
||||
|
||||
expect(useGitStore.getState().nestedRepoSelection.get('/root-a')).toBe('/root-a/one');
|
||||
expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
|
||||
});
|
||||
|
||||
test('clears only the given root selection', () => {
|
||||
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
|
||||
useGitStore.getState().selectNestedRepo('/root-b', '/root-b/two');
|
||||
|
||||
useGitStore.getState().clearNestedRepoSelection('/root-a');
|
||||
|
||||
expect(useGitStore.getState().nestedRepoSelection.has('/root-a')).toBe(false);
|
||||
expect(useGitStore.getState().nestedRepoSelection.get('/root-b')).toBe('/root-b/two');
|
||||
});
|
||||
|
||||
test('runtime switch does not leak selections or discovery across runtimes', () => {
|
||||
useGitStore.getState().selectNestedRepo('/root-a', '/root-a/one');
|
||||
useGitStore.setState({ nestedReposByRoot: new Map([['/root-a', ['/root-a/one']]]) });
|
||||
|
||||
useGitStore.getState().resetForRuntimeSwitch('runtime-b');
|
||||
|
||||
expect(useGitStore.getState().nestedRepoSelection.size).toBe(0);
|
||||
expect(useGitStore.getState().nestedReposByRoot.size).toBe(0);
|
||||
});
|
||||
|
||||
test('marks discovery failure as a failed marker, not an empty success', async () => {
|
||||
await useGitStore.getState().ensureNestedRepos('/root-a');
|
||||
|
||||
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
|
||||
});
|
||||
|
||||
test('dedupes concurrent discovery runs for the same root', async () => {
|
||||
const first = useGitStore.getState().ensureNestedRepos('/root-a');
|
||||
const second = useGitStore.getState().ensureNestedRepos('/root-a');
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(useGitStore.getState().nestedReposByRoot.get('/root-a')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@/lib/api/types';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { listGitDirectories } from '@/lib/gitApiHttp';
|
||||
|
||||
const LOG_STALE_THRESHOLD = 10000;
|
||||
const REPO_CHECK_STALE_THRESHOLD = 60_000;
|
||||
@@ -77,6 +78,16 @@ interface GitStore {
|
||||
|
||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||
|
||||
// Nested repository discovery: when the root directory is not itself a git
|
||||
// repository, these hold the discovered repositories and the user's pick.
|
||||
// `nestedReposByRoot` values are `null` when discovery failed — never a
|
||||
// valid empty result — and absent when discovery has not run yet.
|
||||
nestedReposByRoot: Map<string, string[] | null>;
|
||||
nestedRepoSelection: Map<string, string>;
|
||||
ensureNestedRepos: (root: string, options?: { force?: boolean }) => Promise<void>;
|
||||
selectNestedRepo: (root: string, repository: string) => void;
|
||||
clearNestedRepoSelection: (root: string) => void;
|
||||
|
||||
refresh: (git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
||||
}
|
||||
@@ -101,6 +112,7 @@ const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||
const inFlightStatusFetches = new Map<string, Promise<boolean>>();
|
||||
const inFlightEnsureAllByDirectory = new Map<string, Promise<void>>();
|
||||
const inFlightNestedRepoDiscovery = new Map<string, Promise<void>>();
|
||||
const requestGenerationByChannel = new Map<string, number>();
|
||||
const statusMutationRevisionByDirectory = new Map<string, number>();
|
||||
let gitRuntimeGeneration = 0;
|
||||
@@ -276,6 +288,59 @@ const seedDirectoriesFromBranchCache = (runtimeKey: string): Map<string, Directo
|
||||
return directories;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted nested-repo selection (per runtime, per root)
|
||||
//
|
||||
// Only the user's pick is cached — never the discovery result, which is cheap
|
||||
// to re-scan and must not go stale. Seeding the selection lets the Git tab
|
||||
// target the right repository on cold start before discovery completes; a
|
||||
// selection whose repository vanished falls back to discovery in GitView.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GIT_NESTED_REPO_SELECTION_KEY = 'oc.gitNestedRepoSelection.v1';
|
||||
const MAX_NESTED_REPO_RUNTIMES = 8;
|
||||
const MAX_NESTED_REPO_ROOTS = 50;
|
||||
type NestedRepoSelectionEnvelope = {
|
||||
version: 1;
|
||||
runtimes: Record<string, { updatedAt: number; roots: Record<string, string> }>;
|
||||
};
|
||||
|
||||
const emptyNestedRepoSelection = (): NestedRepoSelectionEnvelope => ({ version: 1, runtimes: {} });
|
||||
|
||||
const readNestedRepoSelectionEnvelope = (): NestedRepoSelectionEnvelope => {
|
||||
try {
|
||||
const storage = getDeferredSafeStorage();
|
||||
const raw = storage.getItem(GIT_NESTED_REPO_SELECTION_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) as Partial<NestedRepoSelectionEnvelope> : emptyNestedRepoSelection();
|
||||
return parsed?.version === 1 && parsed.runtimes && typeof parsed.runtimes === 'object'
|
||||
? { version: 1, runtimes: parsed.runtimes }
|
||||
: emptyNestedRepoSelection();
|
||||
} catch {
|
||||
return emptyNestedRepoSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const writeCachedNestedRepoSelection = (runtimeKey: string, roots: Record<string, string>): void => {
|
||||
try {
|
||||
const envelope = readNestedRepoSelectionEnvelope();
|
||||
const now = Date.now();
|
||||
const boundedRoots = Object.fromEntries(
|
||||
Object.entries(roots).slice(0, MAX_NESTED_REPO_ROOTS)
|
||||
);
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: now, roots: boundedRoots };
|
||||
envelope.runtimes = Object.fromEntries(
|
||||
Object.entries(envelope.runtimes).sort(([, left], [, right]) => right.updatedAt - left.updatedAt).slice(0, MAX_NESTED_REPO_RUNTIMES),
|
||||
);
|
||||
getDeferredSafeStorage().setItem(GIT_NESTED_REPO_SELECTION_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
// quota / serialization — ignore; the selection still lives in memory
|
||||
}
|
||||
};
|
||||
|
||||
const seedNestedRepoSelection = (runtimeKey: string): Map<string, string> => {
|
||||
const roots = readNestedRepoSelectionEnvelope().runtimes[runtimeKey]?.roots ?? {};
|
||||
return new Map(Object.entries(roots).filter(([root, repository]) => root && repository));
|
||||
};
|
||||
|
||||
// LRU eviction helper for diff cache
|
||||
const evictDiffCacheIfNeeded = (
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
||||
@@ -549,6 +614,8 @@ export const useGitStore = create<GitStore>()(
|
||||
runtimeKey: initialGitRuntimeKey,
|
||||
directories: seedDirectoriesFromBranchCache(initialGitRuntimeKey),
|
||||
activeDirectory: null,
|
||||
nestedReposByRoot: new Map(),
|
||||
nestedRepoSelection: seedNestedRepoSelection(initialGitRuntimeKey),
|
||||
|
||||
resetForRuntimeSwitch: (runtimeKey) => {
|
||||
gitRuntimeGeneration += 1;
|
||||
@@ -557,9 +624,16 @@ export const useGitStore = create<GitStore>()(
|
||||
statusMutationRevisionByDirectory.clear();
|
||||
inFlightStatusFetches.clear();
|
||||
inFlightEnsureAllByDirectory.clear();
|
||||
inFlightNestedRepoDiscovery.clear();
|
||||
inFlightDiffFetchesByDirectory.clear();
|
||||
diffFetchGenerationByDirectory.clear();
|
||||
set({ runtimeKey, directories: seedDirectoriesFromBranchCache(runtimeKey), activeDirectory: null });
|
||||
set({
|
||||
runtimeKey,
|
||||
directories: seedDirectoriesFromBranchCache(runtimeKey),
|
||||
activeDirectory: null,
|
||||
nestedReposByRoot: new Map(),
|
||||
nestedRepoSelection: seedNestedRepoSelection(runtimeKey),
|
||||
});
|
||||
},
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
@@ -1125,6 +1199,66 @@ export const useGitStore = create<GitStore>()(
|
||||
set({ directories: newDirectories });
|
||||
},
|
||||
|
||||
ensureNestedRepos: async (root, options = {}) => {
|
||||
if (!root) return;
|
||||
const { force = false } = options;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = runtimeDirectoryKey(runtimeKey, root);
|
||||
const current = get().nestedReposByRoot.get(root);
|
||||
if (!force && (current !== undefined || inFlightNestedRepoDiscovery.has(key))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = inFlightNestedRepoDiscovery.get(key);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const discovery = (async () => {
|
||||
let repositories: string[] | null = null;
|
||||
try {
|
||||
repositories = await listGitDirectories(root);
|
||||
} catch (error) {
|
||||
console.error('Failed to discover nested git repositories:', error);
|
||||
repositories = null;
|
||||
}
|
||||
|
||||
// A failed retry must not clobber an earlier successful discovery.
|
||||
const previous = get().nestedReposByRoot.get(root);
|
||||
const nextValue = repositories ?? previous ?? null;
|
||||
const next = new Map(get().nestedReposByRoot);
|
||||
next.set(root, nextValue);
|
||||
set({ nestedReposByRoot: next });
|
||||
})();
|
||||
|
||||
inFlightNestedRepoDiscovery.set(key, discovery);
|
||||
try {
|
||||
await discovery;
|
||||
} finally {
|
||||
if (inFlightNestedRepoDiscovery.get(key) === discovery) {
|
||||
inFlightNestedRepoDiscovery.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selectNestedRepo: (root, repository) => {
|
||||
if (!root || !repository) return;
|
||||
const next = new Map(get().nestedRepoSelection);
|
||||
next.set(root, repository);
|
||||
set({ nestedRepoSelection: next });
|
||||
writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next));
|
||||
},
|
||||
|
||||
clearNestedRepoSelection: (root) => {
|
||||
if (!root) return;
|
||||
if (!get().nestedRepoSelection.has(root)) return;
|
||||
const next = new Map(get().nestedRepoSelection);
|
||||
next.delete(root);
|
||||
set({ nestedRepoSelection: next });
|
||||
writeCachedNestedRepoSelection(getRuntimeKey(), Object.fromEntries(next));
|
||||
},
|
||||
|
||||
ensureStatus: async (directory, git) => {
|
||||
const dirState = get().directories.get(directory);
|
||||
const now = Date.now();
|
||||
@@ -1221,6 +1355,35 @@ export const useIsGitRepo = (directory: string | null) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Resolves the directory the Git tab operates on. A root that is itself a git
|
||||
// repository is always used directly; otherwise a per-root nested-repo
|
||||
// selection (when present) becomes the effective directory.
|
||||
export const useEffectiveGitDirectory = (root: string | null) => {
|
||||
return useGitStore((state) => {
|
||||
if (!root) return null;
|
||||
if (state.directories.get(root)?.isGitRepo === true) {
|
||||
return root;
|
||||
}
|
||||
return state.nestedRepoSelection.get(root) ?? root;
|
||||
});
|
||||
};
|
||||
|
||||
// `undefined` = discovery not run yet, `null` = discovery failed, otherwise
|
||||
// the discovered nested repository paths (possibly empty).
|
||||
export const useNestedRepos = (root: string | null) => {
|
||||
return useGitStore((state) => {
|
||||
if (!root) return undefined;
|
||||
return state.nestedReposByRoot.get(root);
|
||||
});
|
||||
};
|
||||
|
||||
export const useNestedRepoSelection = (root: string | null) => {
|
||||
return useGitStore((state) => {
|
||||
if (!root) return null;
|
||||
return state.nestedRepoSelection.get(root) ?? null;
|
||||
});
|
||||
};
|
||||
|
||||
export const useGitBranchLabel = (directory: string | null) => {
|
||||
return useGitStore((state) => {
|
||||
if (!directory) return null;
|
||||
|
||||
Reference in New Issue
Block a user