254 lines
9.0 KiB
TypeScript
254 lines
9.0 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
};
|