Files
openchamber/packages/ui/src/components/views/PullRequestView.tsx
T

254 lines
9.0 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
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 { useShallow } from 'zustand/react/shallow';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getRuntimeKey } from '@/lib/runtime-switch';
import type { GitRemote } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { PullRequestSection } from './git/PullRequestSection';
import { deriveBaseBranch } from './git/baseBranch';
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
// Remotes rarely change; remembering the last fetched list per directory lets
// a remount pick the same PR-status key immediately instead of flashing
// through the remote-less "checking status" state while remotes reload.
// Runtime-scoped so a backend switch never serves another runtime's remotes.
const remotesCacheByDirectory = new Map<string, GitRemote[]>();
const remoteUrlCacheByDirectory = new Map<string, string | null>();
const remoteCacheKey = (directory: string): string => `${getRuntimeKey()}::${directory}`;
/**
* Standalone pull-request surface: resolves the same repository context
* GitView does (branch, base branch, remotes) from the shared git stores and
* renders the pull-request workflow full-size in the context panel.
*/
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 })));
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees);
const normalizedCurrentDirectory = normalizePath(currentDirectory);
const inferredWorktreeMetadata = React.useMemo(() => {
if (!normalizedCurrentDirectory) {
return undefined;
}
const fromAvailable = availableWorktrees.find(
(metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory
);
if (fromAvailable) {
return fromAvailable;
}
for (const metadata of worktreeMap.values()) {
if (normalizePath(metadata.path) === normalizedCurrentDirectory) {
return metadata;
}
}
return undefined;
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
const storeWorktreeMetadata = React.useMemo(() => {
if (currentSessionId) {
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
}
if (newSessionDraft?.open) {
return inferredWorktreeMetadata;
}
return undefined;
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
const worktreeAttachment = useSessionWorktreeStore((s) =>
currentSessionId ? s.getAttachment(currentSessionId) : undefined
);
const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy
? worktreeAttachment.worktreeRoot ?? undefined
: undefined;
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
React.useEffect(() => {
if (!currentDirectory || !git) {
return;
}
void ensureAll(currentDirectory, git);
}, [currentDirectory, ensureAll, git]);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
React.useEffect(() => {
const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory;
if (!projectRoot) {
setRootBranchHint(null);
return;
}
let cancelled = false;
void getRootBranch(projectRoot)
.then((branch) => {
if (cancelled) return;
const normalized = branch.trim();
setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null);
})
.catch(() => {
if (!cancelled) {
setRootBranchHint(null);
}
});
return () => {
cancelled = true;
};
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
const [remotes, setRemotes] = React.useState<GitRemote[]>(() =>
(currentDirectory ? remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? []
);
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(() =>
(currentDirectory ? remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) : undefined) ?? null
);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemotes) {
setRemotes([]);
return;
}
setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []);
let cancelled = false;
void git.getRemotes(currentDirectory)
.then((remoteList) => {
if (cancelled) return;
remotesCacheByDirectory.set(remoteCacheKey(currentDirectory), remoteList ?? []);
setRemotes(remoteList ?? []);
})
.catch(() => { if (!cancelled) setRemotes(remotesCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? []); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
return;
}
setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null);
let cancelled = false;
void git.getRemoteUrl(currentDirectory)
.then((url) => {
if (cancelled) return;
remoteUrlCacheByDirectory.set(remoteCacheKey(currentDirectory), url);
setRemoteUrl(url);
})
.catch(() => { if (!cancelled) setRemoteUrl(remoteUrlCacheByDirectory.get(remoteCacheKey(currentDirectory)) ?? null); });
return () => {
cancelled = true;
};
}, [currentDirectory, git]);
const localBranches = React.useMemo(() => {
if (!branches?.all) return [];
return branches.all
.filter((branchName: string) => !branchName.startsWith('remotes/'))
.sort();
}, [branches]);
const remoteBranches = React.useMemo(() => {
if (!branches?.all) return [];
return branches.all
.filter((branchName: string) => branchName.startsWith('remotes/'))
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
.sort();
}, [branches]);
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
if (remotes.length > 0) {
return remotes;
}
const inferredNames = new Set<string>();
const tracking = status?.tracking?.trim();
if (tracking && tracking.includes('/')) {
inferredNames.add(tracking.split('/')[0]);
}
for (const branchName of remoteBranches) {
const slashIndex = branchName.indexOf('/');
if (slashIndex > 0) {
inferredNames.add(branchName.slice(0, slashIndex));
}
}
if (inferredNames.size === 0 && remoteUrl) {
inferredNames.add('origin');
}
return Array.from(inferredNames).map((name) => ({
name,
fetchUrl: remoteUrl ?? '',
pushUrl: remoteUrl ?? '',
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const currentBranch = status?.current ?? null;
if (!currentDirectory || !currentBranch) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Icon name="git-pull-request" className="h-12 w-12 text-muted-foreground/50" />
<div className="typography-ui-header text-foreground">{t('gitView.pullRequest.title')}</div>
<div className="max-w-sm typography-micro text-muted-foreground">{t('gitView.pullRequest.createHint')}</div>
</div>
);
}
return (
<ScrollableOverlay
as={ScrollShadow}
outerClassName="h-full min-h-0"
className="px-4 py-3"
disableHorizontal
preventOverscroll
>
<PullRequestSection
directory={currentDirectory}
branch={currentBranch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
/>
</ScrollableOverlay>
);
};