Files
openchamber/packages/ui/src/components/sections/git-identities/GitIdentitiesSidebar.tsx
T

241 lines
7.4 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiAddLine,
RiGitBranchLine,
RiMore2Line,
RiDeleteBinLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
} from '@remixicon/react';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
branch: RiGitBranchLine,
briefcase: RiBriefcaseLine,
house: RiHomeLine,
graduation: RiGraduationCapLine,
code: RiCodeLine,
heart: RiHeartLine,
};
const 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)',
};
export const GitIdentitiesSidebar: React.FC = () => {
const {
selectedProfileId,
profiles,
globalIdentity,
setSelectedProfile,
deleteProfile,
loadProfiles,
loadGlobalIdentity,
} = useGitIdentitiesStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]);
const handleCreateProfile = () => {
setSelectedProfile('new');
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteProfile = async (profile: GitIdentityProfile) => {
if (window.confirm(`Are you sure you want to delete profile "${profile.name}"?`)) {
const success = await deleteProfile(profile.id);
if (success) {
toast.success(`Profile "${profile.name}" deleted successfully`);
} else {
toast.error('Failed to delete profile');
}
}
};
return (
<div className="flex h-full flex-col bg-sidebar">
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Git Profiles</h2>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{profiles.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={handleCreateProfile}
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{}
{globalIdentity && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
System Default
</div>
<ProfileListItem
profile={globalIdentity}
isSelected={selectedProfileId === 'global'}
onSelect={() => {
setSelectedProfile('global');
if (isMobile) {
setSidebarOpen(false);
}
}}
onDelete={undefined}
isReadOnly
/>
</>
)}
{}
{profiles.length > 0 && (
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Custom Profiles
</div>
)}
{profiles.length === 0 && !globalIdentity ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No profiles configured</p>
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
</div>
) : (
<>
{profiles.map((profile) => (
<ProfileListItem
key={profile.id}
profile={profile}
isSelected={selectedProfileId === profile.id}
onSelect={() => {
setSelectedProfile(profile.id);
if (isMobile) {
setSidebarOpen(false);
}
}}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</>
)}
</ScrollableOverlay>
</div>
);
};
interface ProfileListItemProps {
profile: GitIdentityProfile;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
isReadOnly?: boolean;
}
const ProfileListItem: React.FC<ProfileListItemProps> = ({
profile,
isSelected,
onSelect,
onDelete,
isReadOnly = false,
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
return (
<div className="group transition-all duration-200">
<div className="relative">
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
<button
onClick={onSelect}
className="flex-1 text-left overflow-hidden"
inputMode="none"
tabIndex={0}
>
<div className="flex items-center gap-2">
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
<div className={cn(
"typography-ui-label font-medium truncate flex-1",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
{profile.name}
</div>
</div>
{}
<div className="typography-meta text-muted-foreground truncate mt-0.5">
{profile.userEmail}
</div>
</button>
{!isReadOnly && onDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
);
};