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:
committed by
GitHub
parent
d2d39c48ac
commit
d2358c2c03
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user