Files
openchamber/packages/ui/src/components/views/git/GitHeader.tsx
T

488 lines
15 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { BranchSelector } from './BranchSelector';
import { WorktreeBranchDisplay } from './WorktreeBranchDisplay';
import { SyncActions } from './SyncActions';
import type {
GitStatus,
GitIdentityProfile,
GitRemote,
GitRemoteComparison,
GitHubPullRequest,
GitHubChecksSummary,
GitLabMergeRequestSummary,
} from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
interface GitHeaderProps {
status: GitStatus | null;
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, { ahead?: number; behind?: number }> | undefined;
syncAction: SyncAction;
remotes: GitRemote[];
onFetch: (remote: GitRemote) => void;
onSync: (remote: GitRemote) => void;
onRemoveRemote: (remote: GitRemote) => void;
removingRemoteName: string | null;
onCheckoutBranch: (branch: string) => void;
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
activeIdentityProfile: GitIdentityProfile | null;
availableIdentities: GitIdentityProfile[];
onSelectIdentity: (profile: GitIdentityProfile) => void;
isApplyingIdentity: boolean;
isWorktreeMode: boolean;
onOpenHistory?: () => void;
onOpenGraph?: () => void;
onOpenStashes?: () => void;
onOpenUpdateBranch?: () => void;
onOpenReintegrateCommits?: () => void;
pullRequest?: GitHubPullRequest | null;
prChecks?: GitHubChecksSummary | null;
onOpenPullRequest?: () => void;
gitLabMr?: GitLabMergeRequestSummary | null;
onOpenGitLabMr?: () => void;
}
const IDENTITY_ICON_MAP: Record<string, IconName> = {
branch: 'git-branch',
briefcase: 'briefcase',
house: 'home',
graduation: 'graduation-cap',
code: 'code',
heart: 'heart',
user: 'user-3',
fingerprint: 'fingerprint',
};
const IDENTITY_COLOR_MAP: Record<string, string> = {
keyword: 'var(--syntax-keyword)',
error: 'var(--status-error)',
string: 'var(--syntax-string)',
function: 'var(--syntax-function)',
type: 'var(--syntax-type)',
success: 'var(--status-success)',
info: 'var(--status-info)',
warning: 'var(--status-warning)',
};
function getIdentityColor(token?: string | null) {
if (!token) {
return 'var(--primary)';
}
return IDENTITY_COLOR_MAP[token] || 'var(--primary)';
}
interface IdentityIconProps {
icon?: string | null;
className?: string;
colorToken?: string | null;
}
const IdentityIcon: React.FC<IdentityIconProps> = ({ icon, className, colorToken }) => {
const iconName = IDENTITY_ICON_MAP[icon ?? 'branch'] ?? 'user-3';
return (
<Icon
name={iconName}
className={className}
style={{ color: getIdentityColor(colorToken) }}
/>
);
};
interface IdentityDropdownProps {
activeProfile: GitIdentityProfile | null;
identities: GitIdentityProfile[];
onSelect: (profile: GitIdentityProfile) => void;
isApplying: boolean;
iconOnly?: boolean;
}
export const IdentityDropdown: React.FC<IdentityDropdownProps> = ({
activeProfile,
identities,
onSelect,
isApplying,
iconOnly = false,
}) => {
const { t } = useI18n();
const isDisabled = isApplying || identities.length === 0;
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-[15rem] justify-start gap-1.5 px-2 py-1 typography-ui-label"
style={{ color: getIdentityColor(activeProfile?.color) }}
disabled={isDisabled}
>
{isApplying ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<IdentityIcon
icon={activeProfile?.icon}
colorToken={activeProfile?.color}
className="size-4"
/>
)}
{!iconOnly && (
<span className="git-identity-label min-w-0 flex-1 truncate text-left">
{activeProfile?.name || t('gitView.header.noIdentity')}
</span>
)}
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.identityTooltip')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-64">
{identities.length === 0 ? (
<div className="px-2 py-1.5">
<p className="typography-meta text-muted-foreground">
{t('gitView.header.noProfiles')}
</p>
</div>
) : (
identities.map((profile) => {
const isSelected = activeProfile?.id === profile.id;
return (
<DropdownMenuItem key={profile.id} onSelect={() => onSelect(profile)}>
<span className="flex items-center gap-2">
<IdentityIcon
icon={profile.icon}
colorToken={profile.color}
className="size-4"
/>
<span className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">
{profile.name}
</span>
<span className="typography-meta text-muted-foreground">
{profile.userEmail}
</span>
</span>
{isSelected ? (
<Icon name="check" className="ml-auto size-4 text-foreground" />
) : null}
</span>
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
interface UpstreamStatusPillProps {
comparison: GitRemoteComparison;
trackingBranch: string | null;
tooltipDelayMs?: number;
}
const UpstreamStatusPill: React.FC<UpstreamStatusPillProps> = ({
comparison,
trackingBranch,
tooltipDelayMs = 1000,
}) => {
const { t } = useI18n();
const target = `${comparison.remote}/${comparison.branch}`;
const isSynced = comparison.ahead === 0 && comparison.behind === 0;
const tooltipText = trackingBranch
? t('gitView.header.upstreamTooltipTracking', { target, tracking: trackingBranch })
: t('gitView.header.upstreamTooltip', { target });
return (
<Tooltip delayDuration={tooltipDelayMs}>
<TooltipTrigger asChild>
<div className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 typography-micro text-muted-foreground">
<Icon name="git-branch" className="size-3.5 shrink-0" />
<span className="min-w-0 truncate text-foreground/80">{target}</span>
{isSynced ? (
<span className="tabular-nums text-muted-foreground">{t('gitView.header.upstreamSynced')}</span>
) : (
<span className="inline-flex items-center gap-1 tabular-nums">
{comparison.ahead > 0 ? (
<span className="text-[var(--status-info)]">{comparison.ahead}</span>
) : null}
{comparison.behind > 0 ? (
<span className="text-[var(--status-warning)]">{comparison.behind}</span>
) : null}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
);
};
export const GitHeader: React.FC<GitHeaderProps> = ({
status,
localBranches,
remoteBranches,
branchInfo,
syncAction,
remotes,
onFetch,
onSync,
onRemoveRemote,
removingRemoteName,
onCheckoutBranch,
onCreateBranch,
onRenameBranch,
activeIdentityProfile,
availableIdentities,
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
onOpenHistory,
onOpenGraph,
onOpenStashes,
onOpenUpdateBranch,
onOpenReintegrateCommits,
pullRequest,
prChecks,
onOpenPullRequest,
gitLabMr,
onOpenGitLabMr,
}) => {
const { t } = useI18n();
if (!status) {
return null;
}
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes || onOpenUpdateBranch ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 px-0"
aria-label={t('gitView.header.repositoryViews')}
>
<Icon name="more-fill" className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.repositoryViews')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{onOpenHistory ? (
<DropdownMenuItem onSelect={onOpenHistory}>
<Icon name="history" className="size-4" />
{t('gitView.history.title')}
</DropdownMenuItem>
) : null}
{onOpenGraph ? (
<DropdownMenuItem onSelect={onOpenGraph}>
<Icon name="git-branch" className="size-4" />
{t('gitView.graph.title')}
</DropdownMenuItem>
) : null}
{onOpenStashes ? (
<DropdownMenuItem onSelect={onOpenStashes}>
<Icon name="archive-stack" className="size-4" />
{t('gitView.stashes.title')}
</DropdownMenuItem>
) : null}
{onOpenUpdateBranch ? (
<DropdownMenuItem onSelect={onOpenUpdateBranch}>
<Icon name="git-merge" className="size-4" />
{t('gitView.header.updateBranch')}
</DropdownMenuItem>
) : null}
{onOpenReintegrateCommits ? (
<DropdownMenuItem onSelect={onOpenReintegrateCommits}>
<Icon name="split-cells-horizontal" className="size-4" />
{t('gitView.integrate.title')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
const prChecksColor = prChecks
? prChecks.state === 'success'
? 'var(--status-success)'
: prChecks.state === 'failure'
? 'var(--status-error)'
: 'var(--status-warning)'
: null;
const prVisualState = pullRequest
? pullRequest.state === 'merged'
? 'merged'
: pullRequest.state === 'closed'
? 'closed'
: pullRequest.draft
? 'draft'
: prChecks?.state === 'failure'
|| pullRequest.mergeable === false
|| pullRequest.mergeableState === 'blocked'
|| pullRequest.mergeableState === 'dirty'
? 'blocked'
: 'open'
: null;
const prChip = pullRequest && onOpenPullRequest ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onOpenPullRequest}
className="h-8 gap-1.5 px-2 typography-micro"
>
<Icon
name="git-pull-request"
className="size-3.5"
style={{ color: `var(--pr-${prVisualState})` }}
/>
<span className="tabular-nums text-foreground/80">{t('gitView.pr.numberLabel', { number: pullRequest.number })}</span>
{prChecksColor ? (
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: prChecksColor }}
/>
) : null}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.openPullRequest')}</TooltipContent>
</Tooltip>
) : null;
// GitLab merge request chip, mirroring the GitHub PR chip above. GitLab
// states are surfaced with the same PR state palette so merged/closed/open
// read identically across providers.
const gitLabMrVisualState = gitLabMr
? gitLabMr.state === 'merged'
? 'merged'
: gitLabMr.state === 'closed'
? 'closed'
: gitLabMr.draft
? 'draft'
: 'open'
: null;
const gitLabMrChip = gitLabMr && onOpenGitLabMr ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={onOpenGitLabMr}
className="h-8 gap-1.5 px-2 typography-micro"
>
<Icon
name="git-merge"
className="size-3.5"
style={{ color: `var(--pr-${gitLabMrVisualState})` }}
/>
<span className="tabular-nums text-foreground/80">!{gitLabMr.number}</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('gitView.header.openMergeRequest')}</TooltipContent>
</Tooltip>
) : null;
const syncButtons = (
<SyncActions
syncAction={syncAction}
remotes={remotes}
onFetch={onFetch}
onSync={onSync}
onRemoveRemote={onRemoveRemote}
removingRemoteName={removingRemoteName}
disabled={!status}
iconOnly={true}
aheadCount={status.ahead}
behindCount={status.behind}
trackingRemoteName={status.tracking?.split('/')[0]}
hasUncommittedChanges={(status.files?.length ?? 0) > 0}
/>
);
const upstreamStatusPill = status.upstreamComparison ? (
<UpstreamStatusPill
comparison={status.upstreamComparison}
trackingBranch={status.tracking}
tooltipDelayMs={1000}
/>
) : null;
const identityControl = (
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
onSelect={onSelectIdentity}
isApplying={isApplyingIdentity}
iconOnly={true}
/>
);
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">
{isWorktreeMode ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
/>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{identityControl}
</div>
</div>
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
{prChip ? <div className="shrink-0">{prChip}</div> : null}
{gitLabMrChip ? <div className="shrink-0">{gitLabMrChip}</div> : null}
<div className="min-w-0 flex-1" />
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
{managementButtons}
<div className="shrink-0">{syncButtons}</div>
</div>
</header>
);
};