feat: redesign settings pages to match canonical flat UI patterns (#493)

* refactor(settings): new IA shell + projects section + skills catalog discoverability

* chore(settings): split providers list by scope; show user before project

* fix: navigation flow in mobile Settings

* feat: redesign settings pages to use modern elevated surface patterns

* feat: replace helper text with tooltips in settings

* ui: redesign update dialog and fix external link routing

- Restructures UpdateDialog to focus on changelog readability with a wider max-w-4xl canvas
- Highlights @username contributor mentions with theme primary color
- Strips excessive vertical padding and right-aligns compact action buttons
- Disables streamdown's internal link safety dialog in favor of direct Tauri shell routing

* feat: refactor Git identities into dedicated Git settings page

* feat: unify sidebar background styling across VS Code and web/mobile

* fix: adjust button styling and layout for mobile settings pages

* feat: add MCP settings page and sidebar

* feat: hide models in provider view (thanks to @nguyenngothuong)

* feat: add "Add new provider" option to model selector dropdown

* fix: local evroc logo + provider dropdown icons

* fix: increase width of provider menu

* fix: dark theme background color for better contrast

* feat: update @opencode-ai/sdk dependency to v1.2.10

* fix: restore session sorting to only use updated time

* fix: added settings for sessions deletion dialog

* fix: adjust padding on settings pages for better layout

* fix: standardize select dropdown height across UI

* fix: agent selector UI and notification settings

* fix: remove redundant helper text from settings pages

* fix: update UI layout for description fields

* fix: remove border-none and shadow-none from textarea classes

* fix: enable context menu on sidebar items

* feat: refactor UI controls and layout patterns across settings pages

* fix: use headerless blocks when page title already provides context

* fix: remove subtask option from command settings

* fix: refactor mcp page settings

* fix: reduce spacing in skills configuration pages

* feat: refactor voice settings

* feat: refactor settings sidebar sections
This commit is contained in:
Bohdan Triapitsyn
2026-02-24 03:28:30 +02:00
committed by GitHub
parent d2d39c48ac
commit d2358c2c03
90 changed files with 8062 additions and 7128 deletions
@@ -1,533 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import {
RiUser3Line,
RiSaveLine,
RiDeleteBinLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiInformationLine,
RiKeyLine,
RiLock2Line
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
const PROFILE_COLORS = [
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
{ key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
];
const PROFILE_ICONS = [
{ key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
{ key: 'house', Icon: RiHomeLine, label: 'Personal' },
{ key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
{ key: 'code', Icon: RiCodeLine, label: 'Code' },
];
export const GitIdentitiesPage: React.FC = () => {
const {
selectedProfileId,
getProfileById,
createProfile,
updateProfile,
deleteProfile,
} = useGitIdentitiesStore();
// Parse import: prefix for credential import flow
const importData = React.useMemo(() => {
if (selectedProfileId?.startsWith('import:')) {
const [, host, username] = selectedProfileId.split(':');
return { host, username };
}
return null;
}, [selectedProfileId]);
const selectedProfile = React.useMemo(() =>
selectedProfileId && selectedProfileId !== 'new' && !importData ? getProfileById(selectedProfileId) : null,
[selectedProfileId, getProfileById, importData]
);
const isNewProfile = selectedProfileId === 'new' || importData !== null;
const isGlobalProfile = selectedProfileId === 'global';
const [name, setName] = React.useState('');
const [userName, setUserName] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
const [sshKey, setSshKey] = React.useState('');
const [host, setHost] = React.useState('');
const [color, setColor] = React.useState('keyword');
const [icon, setIcon] = React.useState('branch');
const [isSaving, setIsSaving] = React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
React.useEffect(() => {
if (importData) {
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string');
setIcon('code');
} else if (isNewProfile) {
setName('');
setUserName('');
setUserEmail('');
setAuthType('ssh');
setSshKey('');
setHost('');
setColor('keyword');
setIcon('branch');
} else if (selectedProfile) {
setName(selectedProfile.name);
setUserName(selectedProfile.userName);
setUserEmail(selectedProfile.userEmail);
setAuthType(selectedProfile.authType || 'ssh');
setSshKey(selectedProfile.sshKey || '');
setHost(selectedProfile.host || '');
setColor(selectedProfile.color || 'keyword');
setIcon(selectedProfile.icon || 'branch');
}
}, [selectedProfile, isNewProfile, selectedProfileId, importData]);
const handleSave = async () => {
if (!userName.trim() || !userEmail.trim()) {
toast.error('User name and email are required');
return;
}
if (authType === 'token' && !host.trim()) {
toast.error('Host is required for token-based authentication');
return;
}
setIsSaving(true);
try {
const profileData: Omit<GitIdentityProfile, 'id'> & { id?: string } = {
name: name.trim() || userName.trim(),
userName: userName.trim(),
userEmail: userEmail.trim(),
authType,
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
host: authType === 'token' ? (host.trim() || null) : null,
color,
icon,
};
let success: boolean;
if (isNewProfile) {
success = await createProfile(profileData);
} else if (selectedProfileId) {
success = await updateProfile(selectedProfileId, profileData);
} else {
return;
}
if (success) {
toast.success(isNewProfile ? 'Profile created successfully' : 'Profile updated successfully');
} else {
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
}
} catch (error) {
console.error('Error saving profile:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedProfileId || isNewProfile) return;
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (!selectedProfileId || isNewProfile) {
return;
}
setIsDeleting(true);
try {
const success = await deleteProfile(selectedProfileId);
if (success) {
toast.success('Profile deleted successfully');
setIsDeleteDialogOpen(false);
} else {
toast.error('Failed to delete profile');
}
} catch (error) {
console.error('Error deleting profile:', error);
toast.error('An error occurred while deleting');
} finally {
setIsDeleting(false);
}
};
const currentColorValue = React.useMemo(() => {
const colorConfig = PROFILE_COLORS.find(c => c.key === color);
return colorConfig?.cssVar || 'var(--syntax-keyword)';
}, [color]);
if (!selectedProfileId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiUser3Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a profile from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
</div>
</div>
);
}
return (
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
{importData ? 'Import Credential' : isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
</h1>
<p className="typography-body text-muted-foreground mt-1">
{importData
? `Import token credential for ${importData.host} - please fill in your email address`
: isNewProfile
? 'Create a new Git identity profile for your repositories'
: isGlobalProfile
? 'System-wide Git identity from global configuration (read-only)'
: 'Configure Git identity settings for this profile'}
</p>
</div>
{}
{!isGlobalProfile && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Profile Information</h2>
<p className="typography-meta text-muted-foreground/80">
Basic profile settings and display name
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Display Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
/>
<p className="typography-meta text-muted-foreground">
Friendly name to identify this profile (optional, defaults to user name)
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Color
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
onClick={() => setColor(c.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon
</label>
<div className="flex gap-2 flex-wrap">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
onClick={() => setIcon(i.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === i.key
? 'border-primary bg-accent scale-110'
: 'border-border hover:border-primary/50'
)}
title={i.label}
>
<IconComponent
className="w-4 h-4"
style={{ color: currentColorValue }}
/>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
{}
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-h2 font-semibold text-foreground">Git Configuration</h2>
<p className="typography-meta text-muted-foreground/80">
Git user settings that will be applied to repositories
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Name {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.<br/>
This is the author name shown in git log and GitHub/GitLab interfaces.
</TooltipContent>
</Tooltip>
</label>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.name configuration value
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
User Email {!isGlobalProfile && <span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The email address for Git commits.<br/>
This should match your email in GitHub/GitLab<br/>
to ensure proper attribution of commits.
</TooltipContent>
</Tooltip>
</label>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Git user.email configuration value
</p>
</div>
{/* Auth Type Selector */}
{!isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Authentication Type
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
SSH: Uses SSH key for authentication<br/>
Token: Uses personal access token from ~/.git-credentials
</TooltipContent>
</Tooltip>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAuthType('ssh')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'ssh'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiLock2Line className="w-4 h-4" />
<span className="typography-ui-label">SSH Key</span>
</button>
<button
type="button"
onClick={() => setAuthType('token')}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md border transition-all',
authType === 'token'
? 'border-primary bg-accent'
: 'border-border hover:border-primary/50'
)}
>
<RiKeyLine className="w-4 h-4" />
<span className="typography-ui-label">Token (HTTPS)</span>
</button>
</div>
</div>
)}
{/* SSH Key Path - only for SSH auth type */}
{authType === 'ssh' && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
SSH Key Path
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Path to SSH private key used for Git authentication.<br/>
This key will be used for SSH Git operations.<br/>
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</label>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="/Users/username/.ssh/id_rsa"
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
/>
<p className="typography-meta text-muted-foreground">
Path to SSH private key for authentication (optional)
</p>
</div>
)}
{/* Host - only for Token auth type */}
{authType === 'token' && !isGlobalProfile && (
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
Host {<span className="text-destructive">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The Git host this credential applies to.<br/>
Token will be read from ~/.git-credentials for this host.<br/>
Examples: github.com, gitlab.com
</TooltipContent>
</Tooltip>
</label>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
/>
<p className="typography-meta text-muted-foreground">
Git host for token authentication (from ~/.git-credentials)
</p>
</div>
)}
{}
{!isGlobalProfile && (
<div className="flex justify-between border-t border-border/40 pt-4">
{!isNewProfile && (
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
className="gap-2 h-6 px-2 text-xs"
>
<RiDeleteBinLine className="h-3 w-3" />
Delete Profile
</Button>
)}
<div className={cn('flex gap-2', isNewProfile && 'ml-auto')}>
<Button
size="sm"
variant="default"
onClick={handleSave}
disabled={isSaving}
className="gap-2 h-6 px-2 text-xs"
>
<RiSaveLine className="h-3 w-3" />
{isSaving ? 'Saving...' : 'Save Profile'}
</Button>
</div>
</div>
)}
</div>
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (!isDeleting) {
setIsDeleteDialogOpen(open);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{selectedProfile?.name || name || 'this profile'}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollableOverlay>
);
};
@@ -1,416 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiAddLine,
RiGitBranchLine,
RiMore2Line,
RiDeleteBinLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiDownloadLine,
} from '@remixicon/react';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import type { GitIdentityProfile, DiscoveredGitCredential } 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)',
};
interface GitIdentitiesSidebarProps {
onItemSelect?: () => void;
}
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
const {
selectedProfileId,
defaultGitIdentityId,
profiles,
globalIdentity,
setSelectedProfile,
deleteProfile,
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const unimportedCredentials = getUnimportedCredentials();
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
loadDiscoveredCredentials();
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const handleImportCredential = (credential: DiscoveredGitCredential) => {
// Set a special "import" selection that carries the credential data
// The form will read this and pre-fill fields
setSelectedProfile(`import:${credential.host}:${credential.username}`);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateProfile = () => {
setSelectedProfile('new');
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
};
const handleDeleteProfile = async (profile: GitIdentityProfile) => {
setDeleteDialogProfile(profile);
};
const handleConfirmDeleteProfile = async () => {
if (!deleteDialogProfile) {
return;
}
setIsDeletePending(true);
const success = await deleteProfile(deleteDialogProfile.id);
if (success) {
toast.success(`Profile "${deleteDialogProfile.name}" deleted successfully`);
setDeleteDialogProfile(null);
} else {
toast.error('Failed to delete profile');
}
setIsDeletePending(false);
};
const handleToggleDefault = async (profileId: string) => {
const next = defaultGitIdentityId === profileId ? null : profileId;
const ok = await setDefaultGitIdentityId(next);
if (!ok) {
toast.error('Failed to update default identity');
return;
}
toast.success(next ? 'Default identity updated' : 'Default identity unset');
};
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {profiles.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={handleCreateProfile}
aria-label="Create new profile"
>
<RiAddLine className="size-4" />
</Button>
</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'}
isDefault={defaultGitIdentityId === 'global'}
onSelect={() => {
setSelectedProfile('global');
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault('global')}
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 && unimportedCredentials.length === 0 ? (
<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}
isDefault={defaultGitIdentityId === profile.id}
onSelect={() => {
setSelectedProfile(profile.id);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</>
)}
{/* Discovered Credentials Section */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Discovered Credentials
</div>
<p className="px-2 pb-2 typography-micro text-muted-foreground/60">
Found in ~/.git-credentials
</p>
{unimportedCredentials.map((cred) => (
<DiscoveredCredentialItem
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => handleImportCredential(cred)}
/>
))}
</>
)}
</ScrollableOverlay>
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(open) => {
if (!open && !isDeletePending) {
setDeleteDialogProfile(null);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete profile "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<Button variant="destructive" onClick={() => void handleConfirmDeleteProfile()} disabled={isDeletePending}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface ProfileListItemProps {
profile: GitIdentityProfile;
isSelected: boolean;
isDefault?: boolean;
onSelect: () => void;
onToggleDefault?: () => void | Promise<void>;
onDelete?: () => void;
isReadOnly?: boolean;
}
const ProfileListItem: React.FC<ProfileListItemProps> = ({
profile,
isSelected,
isDefault = false,
onSelect,
onToggleDefault,
onDelete,
isReadOnly = false,
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
return (
<div
className={cn(
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
)}
>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-1.5">
<IconComponent
className="w-4 h-4 flex-shrink-0"
style={{ color: iconColor }}
/>
<span className="typography-ui-label font-normal truncate text-foreground">
{profile.name}
</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</button>
{(onToggleDefault || (!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"
aria-label="Profile actions"
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
{onToggleDefault && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void onToggleDefault();
}}
>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
)}
{!isReadOnly && onDelete && (
<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>
);
};
interface DiscoveredCredentialItemProps {
credential: DiscoveredGitCredential;
onImport: () => void;
}
const getCredentialDisplayName = (host: string): string => {
const parts = host.split('/');
if (parts.length >= 3) {
return parts[parts.length - 1];
}
return host;
};
const DiscoveredCredentialItem: React.FC<DiscoveredCredentialItemProps> = ({
credential,
onImport,
}) => {
const displayName = getCredentialDisplayName(credential.host);
const isRepoSpecific = credential.host.includes('/');
return (
<div className="group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 hover:bg-interactive-hover">
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label font-normal truncate text-foreground">
{displayName}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={onImport}
className="h-6 px-2 text-xs gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<RiDownloadLine className="h-3 w-3" />
Import
</Button>
</div>
</div>
);
};
@@ -0,0 +1,478 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useGitIdentitiesStore, type GitIdentityProfile, type GitIdentityAuthType } from '@/stores/useGitIdentitiesStore';
import {
RiDeleteBinLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiInformationLine,
RiKeyLine,
RiLock2Line,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
const PROFILE_COLORS = [
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
{ key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
{ key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
];
const PROFILE_ICONS = [
{ key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
{ key: 'house', Icon: RiHomeLine, label: 'Personal' },
{ key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
{ key: 'code', Icon: RiCodeLine, label: 'Code' },
];
interface GitIdentityEditorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Profile ID to edit, 'new' for creation, or null */
profileId: string | null;
/** Pre-fill data for importing a discovered credential */
importData?: { host: string; username: string } | null;
}
export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = ({
open,
onOpenChange,
profileId,
importData,
}) => {
const {
getProfileById,
createProfile,
updateProfile,
deleteProfile,
} = useGitIdentitiesStore();
const selectedProfile = React.useMemo(() =>
profileId && profileId !== 'new' && !importData ? getProfileById(profileId) : null,
[profileId, getProfileById, importData]
);
const isNewProfile = profileId === 'new' || importData != null;
const isGlobalProfile = profileId === 'global';
const [name, setName] = React.useState('');
const [userName, setUserName] = React.useState('');
const [userEmail, setUserEmail] = React.useState('');
const [authType, setAuthType] = React.useState<GitIdentityAuthType>('ssh');
const [sshKey, setSshKey] = React.useState('');
const [host, setHost] = React.useState('');
const [color, setColor] = React.useState('keyword');
const [icon, setIcon] = React.useState('branch');
const [isSaving, setIsSaving] = React.useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
React.useEffect(() => {
if (!open) return;
if (importData) {
const parts = importData.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : importData.host;
setName(displayName);
setUserName(importData.username);
setUserEmail('');
setAuthType('token');
setSshKey('');
setHost(importData.host);
setColor('string');
setIcon('code');
} else if (isNewProfile) {
setName('');
setUserName('');
setUserEmail('');
setAuthType('ssh');
setSshKey('');
setHost('');
setColor('keyword');
setIcon('branch');
} else if (selectedProfile) {
setName(selectedProfile.name);
setUserName(selectedProfile.userName);
setUserEmail(selectedProfile.userEmail);
setAuthType(selectedProfile.authType || 'ssh');
setSshKey(selectedProfile.sshKey || '');
setHost(selectedProfile.host || '');
setColor(selectedProfile.color || 'keyword');
setIcon(selectedProfile.icon || 'branch');
} else if (isGlobalProfile) {
const global = getProfileById('global');
if (global) {
setName(global.name);
setUserName(global.userName);
setUserEmail(global.userEmail);
setAuthType(global.authType || 'ssh');
setSshKey(global.sshKey || '');
setHost(global.host || '');
setColor(global.color || 'keyword');
setIcon(global.icon || 'branch');
}
}
}, [open, profileId, selectedProfile, isNewProfile, importData, isGlobalProfile, getProfileById]);
const handleSave = async () => {
if (!userName.trim() || !userEmail.trim()) {
toast.error('User name and email are required');
return;
}
if (authType === 'token' && !host.trim()) {
toast.error('Host is required for token-based authentication');
return;
}
setIsSaving(true);
try {
const profileData: Omit<GitIdentityProfile, 'id'> & { id?: string } = {
name: name.trim() || userName.trim(),
userName: userName.trim(),
userEmail: userEmail.trim(),
authType,
sshKey: authType === 'ssh' ? (sshKey.trim() || null) : null,
host: authType === 'token' ? (host.trim() || null) : null,
color,
icon,
};
let success: boolean;
if (isNewProfile) {
success = await createProfile(profileData);
} else if (profileId) {
success = await updateProfile(profileId, profileData);
} else {
return;
}
if (success) {
toast.success(isNewProfile ? 'Profile created' : 'Profile updated');
onOpenChange(false);
} else {
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
}
} catch (error) {
console.error('Error saving profile:', error);
toast.error('An error occurred while saving');
} finally {
setIsSaving(false);
}
};
const handleConfirmDelete = async () => {
if (!profileId || isNewProfile) return;
setIsDeleting(true);
try {
const success = await deleteProfile(profileId);
if (success) {
toast.success('Profile deleted');
setIsDeleteDialogOpen(false);
onOpenChange(false);
} else {
toast.error('Failed to delete profile');
}
} catch (error) {
console.error('Error deleting profile:', error);
toast.error('An error occurred while deleting');
} finally {
setIsDeleting(false);
}
};
const currentColorValue = React.useMemo(() => {
const colorConfig = PROFILE_COLORS.find(c => c.key === color);
return colorConfig?.cssVar || 'var(--syntax-keyword)';
}, [color]);
const title = importData
? 'Import Credential'
: isNewProfile
? 'New Identity'
: isGlobalProfile
? 'Global Identity'
: (selectedProfile?.name || 'Edit Identity');
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{isGlobalProfile
? 'System-wide Git identity (read-only)'
: isNewProfile
? 'Create a new Git identity profile'
: 'Edit identity profile settings'}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Profile Display */}
{!isGlobalProfile && (
<div className="space-y-3">
<div>
<label className="typography-ui-label text-foreground block mb-1.5">Profile Name</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Work Profile, Personal, etc."
className="h-8"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Color</span>
<div className="flex gap-1.5">
{PROFILE_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
className={cn(
'w-6 h-6 rounded-md border-2 transition-all cursor-pointer',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Icon</span>
<div className="flex gap-1.5">
{PROFILE_ICONS.map((i) => {
const IconComponent = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
className={cn(
'w-7 h-7 rounded-md border-2 transition-all flex items-center justify-center cursor-pointer',
icon === i.key
? 'border-[var(--interactive-border)] bg-[var(--surface-muted)]'
: 'border-transparent hover:border-[var(--interactive-border)] hover:bg-[var(--surface-muted)]/50'
)}
title={i.label}
>
<IconComponent
className="w-3.5 h-3.5"
style={{ color: icon === i.key ? currentColorValue : 'var(--surface-muted-foreground)' }}
/>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Separator */}
{!isGlobalProfile && <div className="border-t border-border/40" />}
{/* Git Author */}
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">User Name</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The name that will appear in Git commit messages.
</TooltipContent>
</Tooltip>
</div>
<Input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="John Doe"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Email Address</label>
{!isGlobalProfile && <span className="text-[var(--status-error)] text-xs">*</span>}
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Should match your email in GitHub/GitLab for proper attribution.
</TooltipContent>
</Tooltip>
</div>
<Input
type="email"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
placeholder="john@example.com"
required={!isGlobalProfile}
readOnly={isGlobalProfile}
disabled={isGlobalProfile}
className="h-8"
/>
</div>
</div>
{/* Authentication */}
{!isGlobalProfile && (
<>
<div className="border-t border-border/40" />
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<span className="typography-ui-label text-foreground">Auth Method</span>
<div className="flex items-center gap-1">
<ButtonSmall
type="button"
variant="outline"
onClick={() => setAuthType('ssh')}
className={cn(
authType === 'ssh'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
<RiLock2Line className="w-3.5 h-3.5 mr-1" /> SSH
</ButtonSmall>
<ButtonSmall
type="button"
variant="outline"
onClick={() => setAuthType('token')}
className={cn(
authType === 'token'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
</ButtonSmall>
</div>
</div>
{authType === 'ssh' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">SSH Key Path</label>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Optional path to private key. e.g. ~/.ssh/id_ed25519
</TooltipContent>
</Tooltip>
</div>
<Input
value={sshKey}
onChange={(e) => setSshKey(e.target.value)}
placeholder="~/.ssh/id_ed25519"
className="h-8 font-mono text-xs"
/>
</div>
)}
{authType === 'token' && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<label className="typography-ui-label text-foreground">Host</label>
<span className="text-[var(--status-error)] text-xs">*</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Token will be read from ~/.git-credentials for this host.
</TooltipContent>
</Tooltip>
</div>
<Input
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="github.com"
required
className="h-8 font-mono text-xs"
/>
</div>
)}
</div>
</>
)}
</div>
<DialogFooter className="gap-2">
{!isGlobalProfile && !isNewProfile && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-[var(--status-error)] hover:text-[var(--status-error)] border-[var(--status-error)]/30 hover:bg-[var(--status-error)]/10 mr-auto"
>
<RiDeleteBinLine className="w-3.5 h-3.5 mr-1" /> Delete
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-foreground hover:bg-interactive-hover hover:text-foreground">
{isGlobalProfile ? 'Close' : 'Cancel'}
</Button>
{!isGlobalProfile && (
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : isNewProfile ? 'Create' : 'Save'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(o) => { if (!isDeleting) setIsDeleteDialogOpen(o); }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedProfile?.name || name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button size="sm" onClick={() => void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,349 @@
import React from 'react';
import { ButtonSmall } from '@/components/ui/button-small';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiAddLine,
RiGitBranchLine,
RiBriefcaseLine,
RiHomeLine,
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiMore2Line,
RiDeleteBinLine,
RiDownloadLine,
RiShieldKeyholeLine,
} from '@remixicon/react';
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
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 GitPage: React.FC = () => {
const {
profiles,
globalIdentity,
defaultGitIdentityId,
deleteProfile,
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
const [editorOpen, setEditorOpen] = React.useState(false);
const [editorProfileId, setEditorProfileId] = React.useState<string | null>(null);
const [editorImportData, setEditorImportData] = React.useState<{ host: string; username: string } | null>(null);
const [deleteDialogProfile, setDeleteDialogProfile] = React.useState<GitIdentityProfile | null>(null);
const [isDeletePending, setIsDeletePending] = React.useState(false);
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
loadDiscoveredCredentials();
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const unimportedCredentials = getUnimportedCredentials();
const openEditor = (id: string | null, importData?: { host: string; username: string } | null) => {
setEditorProfileId(id);
setEditorImportData(importData ?? null);
setEditorOpen(true);
};
const handleToggleDefault = async (profileId: string) => {
const next = defaultGitIdentityId === profileId ? null : profileId;
const ok = await setDefaultGitIdentityId(next);
if (!ok) {
toast.error('Failed to update default identity');
return;
}
toast.success(next ? 'Default identity updated' : 'Default identity unset');
};
const handleConfirmDelete = async () => {
if (!deleteDialogProfile) return;
setIsDeletePending(true);
const success = await deleteProfile(deleteDialogProfile.id);
if (success) {
toast.success(`Profile "${deleteDialogProfile.name}" deleted`);
setDeleteDialogProfile(null);
} else {
toast.error('Failed to delete profile');
}
setIsDeletePending(false);
};
return (
<>
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
<GitHubSettings />
{/* Identities Section */}
<div className="border-t border-border/40 pt-6">
<div className="mb-3 px-1 flex items-start justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
</div>
<ButtonSmall variant="outline" onClick={() => openEditor('new')}>
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
</ButtonSmall>
</div>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
{/* Global identity */}
{globalIdentity && (
<IdentityRow
profile={globalIdentity}
isDefault={defaultGitIdentityId === 'global'}
onEdit={() => openEditor('global')}
onToggleDefault={() => handleToggleDefault('global')}
isReadOnly
hasBorder={profiles.length > 0 || unimportedCredentials.length > 0}
/>
)}
{/* Custom profiles */}
{profiles.map((profile, i) => (
<IdentityRow
key={profile.id}
profile={profile}
isDefault={defaultGitIdentityId === profile.id}
onEdit={() => openEditor(profile.id)}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => setDeleteDialogProfile(profile)}
hasBorder={i < profiles.length - 1 || unimportedCredentials.length > 0}
/>
))}
{/* Empty state */}
{!globalIdentity && profiles.length === 0 && unimportedCredentials.length === 0 && (
<div className="py-8 px-4 text-center text-muted-foreground">
<RiShieldKeyholeLine className="mx-auto mb-2 h-8 w-8 opacity-40" />
<p className="typography-ui-label">No identities configured</p>
<p className="typography-meta mt-1 opacity-75">Create one to manage Git author settings per project</p>
</div>
)}
{/* Discovered credentials */}
{unimportedCredentials.length > 0 && (
<>
<div className="px-4 py-2 border-t border-[var(--surface-subtle)]">
<span className="typography-micro text-muted-foreground">
Found in ~/.git-credentials
</span>
</div>
{unimportedCredentials.map((cred, i) => (
<DiscoveredRow
key={`${cred.host}-${cred.username}`}
credential={cred}
onImport={() => openEditor('new', { host: cred.host, username: cred.username })}
hasBorder={i < unimportedCredentials.length - 1}
/>
))}
</>
)}
</div>
</div>
<GitSettings />
</div>
</ScrollableOverlay>
{/* Editor dialog */}
<GitIdentityEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
profileId={editorProfileId}
importData={editorImportData}
/>
{/* Delete confirmation */}
<Dialog
open={deleteDialogProfile !== null}
onOpenChange={(o) => { if (!isDeletePending) { if (!o) setDeleteDialogProfile(null); } }}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deleteDialogProfile?.name}"?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
Cancel
</Button>
<ButtonLarge onClick={() => void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
Delete
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
// --- Identity row ---
interface IdentityRowProps {
profile: GitIdentityProfile;
isDefault: boolean;
onEdit: () => void;
onToggleDefault: () => void;
onDelete?: () => void;
isReadOnly?: boolean;
hasBorder?: boolean;
}
const IdentityRow: React.FC<IdentityRowProps> = ({
profile,
isDefault,
onEdit,
onToggleDefault,
onDelete,
isReadOnly,
hasBorder,
}) => {
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
const iconColor = COLOR_MAP[profile.color || ''];
const authType = profile.authType || 'ssh';
return (
<div
className={cn(
'group flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30 cursor-pointer',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
onClick={onEdit}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onEdit(); }}
>
<div className="flex items-center gap-3 min-w-0">
<IconComponent className="w-4 h-4 shrink-0" style={{ color: iconColor }} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="typography-ui-label text-foreground truncate">{profile.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{authType}
</span>
{isDefault && (
<span className="typography-micro text-primary bg-primary/12 px-1 rounded flex-shrink-0 leading-none pb-px border border-primary/25">
default
</span>
)}
{isReadOnly && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
system
</span>
)}
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{authType === 'token' && profile.host ? profile.host : profile.userEmail}
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onToggleDefault(); }}>
{isDefault ? 'Unset default' : 'Set as default'}
</DropdownMenuItem>
{!isReadOnly && onDelete && (
<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>
);
};
// --- Discovered credential row ---
interface DiscoveredRowProps {
credential: DiscoveredGitCredential;
onImport: () => void;
hasBorder?: boolean;
}
const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, hasBorder }) => {
const parts = credential.host.split('/');
const displayName = parts.length >= 3 ? parts[parts.length - 1] : credential.host;
const isRepoSpecific = credential.host.includes('/');
return (
<div
className={cn(
'flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-[var(--interactive-hover)]/30',
hasBorder && 'border-b border-[var(--surface-subtle)]'
)}
>
<div className="min-w-0">
<span className="typography-ui-label text-foreground truncate block">{displayName}</span>
<span className="typography-micro text-muted-foreground/60 truncate block leading-tight">
{isRepoSpecific ? credential.host : credential.username}
</span>
</div>
<ButtonSmall variant="ghost" onClick={onImport} className="gap-1 shrink-0">
<RiDownloadLine className="h-3 w-3" />
Import
</ButtonSmall>
</div>
);
};