feat: implement default Git identity management and local identity check

This commit is contained in:
Bohdan Triapitsyn
2026-01-14 22:34:17 +02:00
parent e0cc212823
commit e0279e59b0
18 changed files with 310 additions and 25 deletions
@@ -51,6 +51,7 @@ interface GitIdentitiesSidebarProps {
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
const {
selectedProfileId,
defaultGitIdentityId,
profiles,
globalIdentity,
setSelectedProfile,
@@ -58,6 +59,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
loadProfiles,
loadGlobalIdentity,
loadDiscoveredCredentials,
loadDefaultGitIdentityId,
setDefaultGitIdentityId,
getUnimportedCredentials,
} = useGitIdentitiesStore();
@@ -82,7 +85,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
loadProfiles();
loadGlobalIdentity();
loadDiscoveredCredentials();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]);
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
const handleImportCredential = (credential: DiscoveredGitCredential) => {
// Set a special "import" selection that carries the credential data
@@ -119,6 +123,16 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
}
};
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')}>
@@ -146,6 +160,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
<ProfileListItem
profile={globalIdentity}
isSelected={selectedProfileId === 'global'}
isDefault={defaultGitIdentityId === 'global'}
onSelect={() => {
setSelectedProfile('global');
onItemSelect?.();
@@ -153,6 +168,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault('global')}
onDelete={undefined}
isReadOnly
/>
@@ -179,6 +195,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
key={profile.id}
profile={profile}
isSelected={selectedProfileId === profile.id}
isDefault={defaultGitIdentityId === profile.id}
onSelect={() => {
setSelectedProfile(profile.id);
onItemSelect?.();
@@ -186,6 +203,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
setSidebarOpen(false);
}
}}
onToggleDefault={() => handleToggleDefault(profile.id)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
@@ -218,7 +236,9 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
interface ProfileListItemProps {
profile: GitIdentityProfile;
isSelected: boolean;
isDefault?: boolean;
onSelect: () => void;
onToggleDefault?: () => void | Promise<void>;
onDelete?: () => void;
isReadOnly?: boolean;
}
@@ -226,7 +246,9 @@ interface ProfileListItemProps {
const ProfileListItem: React.FC<ProfileListItemProps> = ({
profile,
isSelected,
isDefault = false,
onSelect,
onToggleDefault,
onDelete,
isReadOnly = false,
}) => {
@@ -258,6 +280,11 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
<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">
@@ -265,7 +292,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
</div>
</button>
{!isReadOnly && onDelete && (
{(onToggleDefault || (!isReadOnly && onDelete)) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -276,17 +303,29 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
<RiMore2Line className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive focus:text-destructive"
>
<RiDeleteBinLine className="h-4 w-4 mr-px" />
Delete
</DropdownMenuItem>
<DropdownMenuContent 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>
)}
@@ -25,6 +25,7 @@ import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/des
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -81,6 +82,8 @@ interface AddCatalogDialogProps {
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const [label, setLabel] = React.useState('');
const [source, setSource] = React.useState('');
@@ -104,13 +107,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
setScanOk(false);
setIdentityOptions([]);
setGitIdentityId(null);
void loadDefaultGitIdentityId();
void (async () => {
const settings = await loadSettings();
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
setExistingCatalogs(catalogs || []);
})();
}, [open]);
}, [open, loadDefaultGitIdentityId]);
const isDuplicate = React.useMemo(() => {
const normalizedSource = source.trim();
@@ -153,7 +157,13 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
const ids = (result.error.identities || []) as IdentityOption[];
setIdentityOptions(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
const preferred =
defaultGitIdentityId &&
defaultGitIdentityId !== 'global' &&
ids.some((i) => i.id === defaultGitIdentityId)
? defaultGitIdentityId
: ids[0].id;
setGitIdentityId(preferred);
}
toast.error('Authentication required. Select a Git identity and scan again.');
return;
@@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
interface InstallFromRepoDialogProps {
@@ -37,6 +38,8 @@ type IdentityOption = { id: string; name: string };
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
const installedSkills = useSkillsStore((s) => s.skills);
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
@@ -69,10 +72,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
setSearch('');
setIdentities([]);
setGitIdentityId(null);
void loadDefaultGitIdentityId();
setConflictsOpen(false);
setConflicts([]);
setBaseInstallRequest(null);
}, [open]);
}, [open, loadDefaultGitIdentityId]);
const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project' }>();
@@ -127,7 +133,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
const preferred =
defaultGitIdentityId &&
defaultGitIdentityId !== 'global' &&
ids.some((i) => i.id === defaultGitIdentityId)
? defaultGitIdentityId
: ids[0].id;
setGitIdentityId(preferred);
}
toast.error('Authentication required. Select a Git identity and try scanning again.');
return;
@@ -195,7 +207,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
const preferred =
defaultGitIdentityId &&
defaultGitIdentityId !== 'global' &&
ids.some((i) => i.id === defaultGitIdentityId)
? defaultGitIdentityId
: ids[0].id;
setGitIdentityId(preferred);
}
toast.error('Authentication required. Select a Git identity and try installing again.');
return;
+59 -4
View File
@@ -60,7 +60,7 @@ export const GitView: React.FC = () => {
? worktreeMap.get(currentSessionId) ?? undefined
: undefined;
const { profiles, globalIdentity, loadProfiles, loadGlobalIdentity } =
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
useGitIdentitiesStore();
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
@@ -95,6 +95,21 @@ export const GitView: React.FC = () => {
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
const { triggerFireworks } = useFireworksCelebration();
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
const identityApplyCountRef = React.useRef(0);
const beginIdentityApply = React.useCallback(() => {
identityApplyCountRef.current += 1;
setIsSettingIdentity(true);
}, []);
const endIdentityApply = React.useCallback(() => {
identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1);
if (identityApplyCountRef.current === 0) {
setIsSettingIdentity(false);
}
}, []);
const [selectedPaths, setSelectedPaths] = React.useState<Set<string>>(
() => new Set(initialSnapshot?.selectedPaths ?? [])
);
@@ -187,7 +202,8 @@ export const GitView: React.FC = () => {
React.useEffect(() => {
loadProfiles();
loadGlobalIdentity();
}, [loadProfiles, loadGlobalIdentity]);
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
React.useEffect(() => {
if (!currentDirectory || !git?.getRemoteUrl) {
@@ -238,6 +254,45 @@ export const GitView: React.FC = () => {
await fetchIdentity(currentDirectory, git);
}, [currentDirectory, git, fetchIdentity]);
React.useEffect(() => {
if (!currentDirectory) return;
if (!git?.hasLocalIdentity) return;
if (isGitRepo !== true) return;
const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : '';
if (!defaultId || defaultId === 'global') return;
const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory);
if (previousAttempt === defaultId) return;
let cancelled = false;
const run = async () => {
try {
const hasLocal = await git.hasLocalIdentity!(currentDirectory);
if (cancelled) return;
if (hasLocal) return;
beginIdentityApply();
await git.setGitIdentity(currentDirectory, defaultId);
autoAppliedDefaultRef.current.set(currentDirectory, defaultId);
await refreshIdentity();
} catch (error) {
console.warn('Failed to auto-apply default git identity:', error);
} finally {
if (!cancelled) {
endIdentityApply();
}
}
};
void run();
return () => {
cancelled = true;
};
}, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
const changeEntries = React.useMemo(() => {
if (!status) return [];
const files = status.files ?? [];
@@ -470,7 +525,7 @@ export const GitView: React.FC = () => {
const handleApplyIdentity = async (profile: GitIdentityProfile) => {
if (!currentDirectory) return;
setIsSettingIdentity(true);
beginIdentityApply();
try {
await git.setGitIdentity(currentDirectory, profile.id);
@@ -480,7 +535,7 @@ export const GitView: React.FC = () => {
const message = err instanceof Error ? err.message : 'Failed to apply git identity';
toast.error(message);
} finally {
setIsSettingIdentity(false);
endIdentityApply();
}
};