Files
openchamber/packages/ui/src/components/views/git/GitHeader.tsx
T
Erman HAVUÇandBohdan Triapitsyn 52ffe9daef feat(git-graph): VS Code-style git graph with commit actions in History modal (#1431)
* feat(types): add parents to GitLogEntry and new commit action types

* feat(git): add parent hashes and --all flag to getLog

* fix(git): move record separator to start of log format string

* feat(git): add checkoutCommit server function and route

* feat(git): add cherryPick server function and route

* feat(git): add revertCommit server function and route

* feat(git): add resetToCommit server function and route

* fix(tests): make git service tests branch-name portable, add error path tests

* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers

* feat(git-graph): add lane assignment algorithm with tests

* feat(git-graph): add GitGraphSegment per-row SVG renderer

* feat(i18n): add locale strings for git graph action buttons

* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests

* feat(git-graph): add ref badges and action buttons to HistoryCommitRow

* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges

* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n

* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage

* fix(lint): remove unused variables

* fix(git-graph): fix SVG height causing 150px row spacing

* fix(git-graph): smooth bezier curves, fill row height, round line caps

* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top

* fix(git-graph): remove viewBox scaling, match SVG height to actual row height

* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch

* feat(git-graph): replace SVG with Canvas for graph rendering

* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak

* feat(git-graph): align action buttons, add confirmation popups for all actions

* fix(git-graph): address code review findings CR-001 through CR-005

- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
  revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
  in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
  fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)

* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales

upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.

* fix: harden git history actions

* feat: split git history graph view

* chore: remove git graph planning docs

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-27 00:13:25 +03:00

391 lines
12 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
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,
} 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;
actionTabItems?: SortableTabsStripItem[];
activeActionTab?: string;
onSelectActionTab?: (tabID: string) => 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',
};
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,
actionTabItems,
activeActionTab,
onSelectActionTab,
}) => {
const { t } = useI18n();
if (!status) {
return null;
}
const managementButtons = (
<div className="flex items-center gap-1 shrink-0">
{onOpenHistory || onOpenGraph || onOpenStashes ? (
<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="git-repository" 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-merge" 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}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
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">
{managementButtons}
{identityControl}
</div>
</div>
{actionTabItems && activeActionTab && onSelectActionTab ? (
<div className="mt-3 flex h-8 min-w-0 items-center gap-2">
<div className="min-w-0 flex-1">
<SortableTabsStrip
items={actionTabItems}
activeId={activeActionTab}
onSelect={onSelectActionTab}
layoutMode="fit"
variant="active-pill"
iconOnlyActiveTab={true}
activePillButtonClassName="h-7"
className="h-full"
/>
</div>
{upstreamStatusPill ? (
<div className="min-w-0 shrink">{upstreamStatusPill}</div>
) : null}
<div className="shrink-0">{syncButtons}</div>
</div>
) : null}
</header>
);
};