feat: implement default Git identity management and local identity check
This commit is contained in:
Generated
+1
-1
@@ -2977,7 +2977,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
version = "1.4.8"
|
version = "1.4.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -2029,6 +2029,27 @@ pub async fn get_current_git_identity(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_local_identity(
|
||||||
|
directory: String,
|
||||||
|
state: State<'_, DesktopRuntime>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let root = validate_git_path(&directory, state.settings())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let user_name = run_git(&["config", "--local", "--get", "user.name"], &root)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let user_email = run_git(&["config", "--local", "--get", "user.email"], &root)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
Ok(user_name.is_some() || user_email.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
|
pub async fn get_global_git_identity() -> Result<GitIdentitySummary, String> {
|
||||||
let user_name = tokio::process::Command::new("git")
|
let user_name = tokio::process::Command::new("git")
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
|||||||
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
result_obj.insert("defaultGitIdentityId".to_string(), Value::Null);
|
||||||
|
} else {
|
||||||
|
result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(Value::String(s)) = obj.get("commitMessageModel") {
|
if let Some(Value::String(s)) = obj.get("commitMessageModel") {
|
||||||
let trimmed = s.trim();
|
let trimmed = s.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use commands::git::{
|
|||||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
|
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
|
||||||
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||||
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
|
||||||
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff,
|
||||||
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
|
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
|
||||||
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
|
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
|
||||||
revert_git_file, set_git_identity, update_git_identity,
|
revert_git_file, set_git_identity, update_git_identity,
|
||||||
@@ -871,6 +871,7 @@ fn main() {
|
|||||||
update_git_identity,
|
update_git_identity,
|
||||||
delete_git_identity,
|
delete_git_identity,
|
||||||
get_current_git_identity,
|
get_current_git_identity,
|
||||||
|
has_local_identity,
|
||||||
get_global_git_identity,
|
get_global_git_identity,
|
||||||
get_remote_url,
|
get_remote_url,
|
||||||
set_git_identity,
|
set_git_identity,
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async hasLocalIdentity(directory: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await safeGitInvoke<boolean>('has_local_identity', { directory });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
||||||
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
||||||
return { success: true, profile };
|
return { success: true, profile };
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ interface GitIdentitiesSidebarProps {
|
|||||||
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
|
export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onItemSelect }) => {
|
||||||
const {
|
const {
|
||||||
selectedProfileId,
|
selectedProfileId,
|
||||||
|
defaultGitIdentityId,
|
||||||
profiles,
|
profiles,
|
||||||
globalIdentity,
|
globalIdentity,
|
||||||
setSelectedProfile,
|
setSelectedProfile,
|
||||||
@@ -58,6 +59,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
loadProfiles,
|
loadProfiles,
|
||||||
loadGlobalIdentity,
|
loadGlobalIdentity,
|
||||||
loadDiscoveredCredentials,
|
loadDiscoveredCredentials,
|
||||||
|
loadDefaultGitIdentityId,
|
||||||
|
setDefaultGitIdentityId,
|
||||||
getUnimportedCredentials,
|
getUnimportedCredentials,
|
||||||
} = useGitIdentitiesStore();
|
} = useGitIdentitiesStore();
|
||||||
|
|
||||||
@@ -82,7 +85,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
loadProfiles();
|
loadProfiles();
|
||||||
loadGlobalIdentity();
|
loadGlobalIdentity();
|
||||||
loadDiscoveredCredentials();
|
loadDiscoveredCredentials();
|
||||||
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials]);
|
loadDefaultGitIdentityId();
|
||||||
|
}, [loadProfiles, loadGlobalIdentity, loadDiscoveredCredentials, loadDefaultGitIdentityId]);
|
||||||
|
|
||||||
const handleImportCredential = (credential: DiscoveredGitCredential) => {
|
const handleImportCredential = (credential: DiscoveredGitCredential) => {
|
||||||
// Set a special "import" selection that carries the credential data
|
// 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 (
|
return (
|
||||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
<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={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||||
@@ -146,6 +160,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
<ProfileListItem
|
<ProfileListItem
|
||||||
profile={globalIdentity}
|
profile={globalIdentity}
|
||||||
isSelected={selectedProfileId === 'global'}
|
isSelected={selectedProfileId === 'global'}
|
||||||
|
isDefault={defaultGitIdentityId === 'global'}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedProfile('global');
|
setSelectedProfile('global');
|
||||||
onItemSelect?.();
|
onItemSelect?.();
|
||||||
@@ -153,6 +168,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onToggleDefault={() => handleToggleDefault('global')}
|
||||||
onDelete={undefined}
|
onDelete={undefined}
|
||||||
isReadOnly
|
isReadOnly
|
||||||
/>
|
/>
|
||||||
@@ -179,6 +195,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
key={profile.id}
|
key={profile.id}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
isSelected={selectedProfileId === profile.id}
|
isSelected={selectedProfileId === profile.id}
|
||||||
|
isDefault={defaultGitIdentityId === profile.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedProfile(profile.id);
|
setSelectedProfile(profile.id);
|
||||||
onItemSelect?.();
|
onItemSelect?.();
|
||||||
@@ -186,6 +203,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onToggleDefault={() => handleToggleDefault(profile.id)}
|
||||||
onDelete={() => handleDeleteProfile(profile)}
|
onDelete={() => handleDeleteProfile(profile)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -218,7 +236,9 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
interface ProfileListItemProps {
|
interface ProfileListItemProps {
|
||||||
profile: GitIdentityProfile;
|
profile: GitIdentityProfile;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onToggleDefault?: () => void | Promise<void>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
@@ -226,7 +246,9 @@ interface ProfileListItemProps {
|
|||||||
const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||||
profile,
|
profile,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isDefault = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggleDefault,
|
||||||
onDelete,
|
onDelete,
|
||||||
isReadOnly = false,
|
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">
|
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||||
{authType}
|
{authType}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
|
||||||
@@ -265,7 +292,7 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isReadOnly && onDelete && (
|
{(onToggleDefault || (!isReadOnly && onDelete)) && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -276,17 +303,29 @@ const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
|||||||
<RiMore2Line className="h-3.5 w-3.5" />
|
<RiMore2Line className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||||
<DropdownMenuItem
|
{onToggleDefault && (
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onDelete();
|
e.stopPropagation();
|
||||||
}}
|
void onToggleDefault();
|
||||||
className="text-destructive focus:text-destructive"
|
}}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
{isDefault ? 'Unset default' : 'Set as default'}
|
||||||
Delete
|
</DropdownMenuItem>
|
||||||
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/des
|
|||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
|
|
||||||
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
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 }) => {
|
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
||||||
|
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||||
|
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||||
|
|
||||||
const [label, setLabel] = React.useState('');
|
const [label, setLabel] = React.useState('');
|
||||||
const [source, setSource] = React.useState('');
|
const [source, setSource] = React.useState('');
|
||||||
@@ -104,13 +107,14 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
|||||||
setScanOk(false);
|
setScanOk(false);
|
||||||
setIdentityOptions([]);
|
setIdentityOptions([]);
|
||||||
setGitIdentityId(null);
|
setGitIdentityId(null);
|
||||||
|
void loadDefaultGitIdentityId();
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
|
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
|
||||||
setExistingCatalogs(catalogs || []);
|
setExistingCatalogs(catalogs || []);
|
||||||
})();
|
})();
|
||||||
}, [open]);
|
}, [open, loadDefaultGitIdentityId]);
|
||||||
|
|
||||||
const isDuplicate = React.useMemo(() => {
|
const isDuplicate = React.useMemo(() => {
|
||||||
const normalizedSource = source.trim();
|
const normalizedSource = source.trim();
|
||||||
@@ -153,7 +157,13 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
|||||||
const ids = (result.error.identities || []) as IdentityOption[];
|
const ids = (result.error.identities || []) as IdentityOption[];
|
||||||
setIdentityOptions(ids);
|
setIdentityOptions(ids);
|
||||||
if (!gitIdentityId && ids.length > 0) {
|
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.');
|
toast.error('Authentication required. Select a Git identity and scan again.');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
|||||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||||
|
|
||||||
interface InstallFromRepoDialogProps {
|
interface InstallFromRepoDialogProps {
|
||||||
@@ -37,6 +38,8 @@ type IdentityOption = { id: string; name: string };
|
|||||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
||||||
const installedSkills = useSkillsStore((s) => s.skills);
|
const installedSkills = useSkillsStore((s) => s.skills);
|
||||||
|
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||||
|
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||||
|
|
||||||
const [source, setSource] = React.useState('');
|
const [source, setSource] = React.useState('');
|
||||||
const [subpath, setSubpath] = React.useState('');
|
const [subpath, setSubpath] = React.useState('');
|
||||||
@@ -69,10 +72,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
setIdentities([]);
|
setIdentities([]);
|
||||||
setGitIdentityId(null);
|
setGitIdentityId(null);
|
||||||
|
void loadDefaultGitIdentityId();
|
||||||
|
|
||||||
setConflictsOpen(false);
|
setConflictsOpen(false);
|
||||||
|
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
setBaseInstallRequest(null);
|
setBaseInstallRequest(null);
|
||||||
}, [open]);
|
}, [open, loadDefaultGitIdentityId]);
|
||||||
|
|
||||||
const installedByName = React.useMemo(() => {
|
const installedByName = React.useMemo(() => {
|
||||||
const map = new Map<string, { scope: 'user' | 'project' }>();
|
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[];
|
const ids = (result.error.identities || []) as IdentityOption[];
|
||||||
setIdentities(ids);
|
setIdentities(ids);
|
||||||
if (!gitIdentityId && ids.length > 0) {
|
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.');
|
toast.error('Authentication required. Select a Git identity and try scanning again.');
|
||||||
return;
|
return;
|
||||||
@@ -195,7 +207,13 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
|||||||
const ids = (result.error.identities || []) as IdentityOption[];
|
const ids = (result.error.identities || []) as IdentityOption[];
|
||||||
setIdentities(ids);
|
setIdentities(ids);
|
||||||
if (!gitIdentityId && ids.length > 0) {
|
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.');
|
toast.error('Authentication required. Select a Git identity and try installing again.');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const GitView: React.FC = () => {
|
|||||||
? worktreeMap.get(currentSessionId) ?? undefined
|
? worktreeMap.get(currentSessionId) ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const { profiles, globalIdentity, loadProfiles, loadGlobalIdentity } =
|
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
||||||
useGitIdentitiesStore();
|
useGitIdentitiesStore();
|
||||||
|
|
||||||
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
||||||
@@ -95,6 +95,21 @@ export const GitView: React.FC = () => {
|
|||||||
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
|
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
|
||||||
const { triggerFireworks } = useFireworksCelebration();
|
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>>(
|
const [selectedPaths, setSelectedPaths] = React.useState<Set<string>>(
|
||||||
() => new Set(initialSnapshot?.selectedPaths ?? [])
|
() => new Set(initialSnapshot?.selectedPaths ?? [])
|
||||||
);
|
);
|
||||||
@@ -187,7 +202,8 @@ export const GitView: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
loadGlobalIdentity();
|
loadGlobalIdentity();
|
||||||
}, [loadProfiles, loadGlobalIdentity]);
|
loadDefaultGitIdentityId();
|
||||||
|
}, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory || !git?.getRemoteUrl) {
|
if (!currentDirectory || !git?.getRemoteUrl) {
|
||||||
@@ -238,6 +254,45 @@ export const GitView: React.FC = () => {
|
|||||||
await fetchIdentity(currentDirectory, git);
|
await fetchIdentity(currentDirectory, git);
|
||||||
}, [currentDirectory, git, fetchIdentity]);
|
}, [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(() => {
|
const changeEntries = React.useMemo(() => {
|
||||||
if (!status) return [];
|
if (!status) return [];
|
||||||
const files = status.files ?? [];
|
const files = status.files ?? [];
|
||||||
@@ -470,7 +525,7 @@ export const GitView: React.FC = () => {
|
|||||||
|
|
||||||
const handleApplyIdentity = async (profile: GitIdentityProfile) => {
|
const handleApplyIdentity = async (profile: GitIdentityProfile) => {
|
||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
setIsSettingIdentity(true);
|
beginIdentityApply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await git.setGitIdentity(currentDirectory, profile.id);
|
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';
|
const message = err instanceof Error ? err.message : 'Failed to apply git identity';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSettingIdentity(false);
|
endIdentityApply();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export interface GitAPI {
|
|||||||
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
||||||
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
||||||
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
||||||
|
hasLocalIdentity?(directory: string): Promise<boolean>;
|
||||||
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
||||||
getGitIdentities(): Promise<GitIdentityProfile[]>;
|
getGitIdentities(): Promise<GitIdentityProfile[]>;
|
||||||
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile>;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export type DesktopSettings = {
|
|||||||
defaultModel?: string; // format: "provider/model"
|
defaultModel?: string; // format: "provider/model"
|
||||||
defaultVariant?: string;
|
defaultVariant?: string;
|
||||||
defaultAgent?: string;
|
defaultAgent?: string;
|
||||||
|
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
|
||||||
autoCreateWorktree?: boolean;
|
autoCreateWorktree?: boolean;
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
commitMessageModel?: string; // format: "provider/model"
|
commitMessageModel?: string; // format: "provider/model"
|
||||||
|
|||||||
@@ -239,6 +239,12 @@ export async function getCurrentGitIdentity(directory: string): Promise<import('
|
|||||||
return gitHttp.getCurrentGitIdentity(directory);
|
return gitHttp.getCurrentGitIdentity(directory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.hasLocalIdentity) return runtime.hasLocalIdentity(directory);
|
||||||
|
return gitHttp.hasLocalIdentity(directory);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setGitIdentity(
|
export async function setGitIdentity(
|
||||||
directory: string,
|
directory: string,
|
||||||
profileId: string
|
profileId: string
|
||||||
|
|||||||
@@ -516,6 +516,18 @@ export async function getCurrentGitIdentity(directory: string): Promise<GitIdent
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasLocalIdentity(directory: string): Promise<boolean> {
|
||||||
|
if (!directory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const response = await fetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to check local identity: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return data?.hasLocalIdentity === true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
||||||
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
const response = await fetch(buildUrl(`${API_BASE}/global-identity`, undefined));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
discoverGitCredentials,
|
discoverGitCredentials,
|
||||||
getGlobalGitIdentity
|
getGlobalGitIdentity
|
||||||
} from "@/lib/gitApi";
|
} from "@/lib/gitApi";
|
||||||
|
import { getDesktopSettings, isDesktopRuntime } from "@/lib/desktop";
|
||||||
|
import { updateDesktopSettings } from "@/lib/persistence";
|
||||||
|
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
||||||
|
|
||||||
export type GitIdentityAuthType = 'ssh' | 'token';
|
export type GitIdentityAuthType = 'ssh' | 'token';
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ export interface DiscoveredGitCredential {
|
|||||||
interface GitIdentitiesStore {
|
interface GitIdentitiesStore {
|
||||||
|
|
||||||
selectedProfileId: string | null;
|
selectedProfileId: string | null;
|
||||||
|
defaultGitIdentityId: string | null; // null = unset, 'global' = system, profile id = custom
|
||||||
profiles: GitIdentityProfile[];
|
profiles: GitIdentityProfile[];
|
||||||
globalIdentity: GitIdentityProfile | null;
|
globalIdentity: GitIdentityProfile | null;
|
||||||
discoveredCredentials: DiscoveredGitCredential[];
|
discoveredCredentials: DiscoveredGitCredential[];
|
||||||
@@ -42,6 +46,9 @@ interface GitIdentitiesStore {
|
|||||||
loadProfiles: () => Promise<boolean>;
|
loadProfiles: () => Promise<boolean>;
|
||||||
loadGlobalIdentity: () => Promise<boolean>;
|
loadGlobalIdentity: () => Promise<boolean>;
|
||||||
loadDiscoveredCredentials: () => Promise<boolean>;
|
loadDiscoveredCredentials: () => Promise<boolean>;
|
||||||
|
loadDefaultGitIdentityId: () => Promise<boolean>;
|
||||||
|
setDefaultGitIdentityId: (id: string | null) => Promise<boolean>;
|
||||||
|
|
||||||
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
|
createProfile: (profile: Omit<GitIdentityProfile, 'id'> & { id?: string }) => Promise<boolean>;
|
||||||
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
|
updateProfile: (id: string, updates: Partial<GitIdentityProfile>) => Promise<boolean>;
|
||||||
deleteProfile: (id: string) => Promise<boolean>;
|
deleteProfile: (id: string) => Promise<boolean>;
|
||||||
@@ -61,6 +68,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
|
||||||
selectedProfileId: null,
|
selectedProfileId: null,
|
||||||
|
defaultGitIdentityId: null,
|
||||||
profiles: [],
|
profiles: [],
|
||||||
globalIdentity: null,
|
globalIdentity: null,
|
||||||
discoveredCredentials: [],
|
discoveredCredentials: [],
|
||||||
@@ -125,6 +133,70 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadDefaultGitIdentityId: async () => {
|
||||||
|
const normalize = (value: unknown): string | null => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let defaultId: string | null = null;
|
||||||
|
|
||||||
|
if (isDesktopRuntime()) {
|
||||||
|
const settings = await getDesktopSettings();
|
||||||
|
defaultId = normalize((settings as { defaultGitIdentityId?: unknown } | null | undefined)?.defaultGitIdentityId);
|
||||||
|
} else {
|
||||||
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
|
if (runtimeSettings) {
|
||||||
|
try {
|
||||||
|
const result = await runtimeSettings.load();
|
||||||
|
const settings = (result?.settings || {}) as Record<string, unknown>;
|
||||||
|
defaultId = normalize(settings.defaultGitIdentityId);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultId === null) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
defaultId = normalize(data?.defaultGitIdentityId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ defaultGitIdentityId: defaultId });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load default git identity setting:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultGitIdentityId: async (id) => {
|
||||||
|
try {
|
||||||
|
const trimmed = typeof id === 'string' ? id.trim() : '';
|
||||||
|
const value = trimmed.length > 0 ? trimmed : '';
|
||||||
|
await updateDesktopSettings({ defaultGitIdentityId: value });
|
||||||
|
set({ defaultGitIdentityId: value.length > 0 ? value : null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save default git identity setting:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createProfile: async (profileData) => {
|
createProfile: async (profileData) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeCon
|
|||||||
delete restChanges.lastDirectory;
|
delete restChanges.lastDirectory;
|
||||||
|
|
||||||
// Normalize empty-string clears to key removal (match web/desktop behavior)
|
// Normalize empty-string clears to key removal (match web/desktop behavior)
|
||||||
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent']) {
|
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId']) {
|
||||||
const value = restChanges[key];
|
const value = restChanges[key];
|
||||||
if (typeof value === 'string' && value.trim().length === 0) {
|
if (typeof value === 'string' && value.trim().length === 0) {
|
||||||
delete restChanges[key];
|
delete restChanges[key];
|
||||||
|
|||||||
@@ -605,6 +605,10 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
const trimmed = candidate.defaultAgent.trim();
|
const trimmed = candidate.defaultAgent.trim();
|
||||||
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
|
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.defaultGitIdentityId === 'string') {
|
||||||
|
const trimmed = candidate.defaultGitIdentityId.trim();
|
||||||
|
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||||
}
|
}
|
||||||
@@ -3366,6 +3370,22 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/git/has-local-identity', async (req, res) => {
|
||||||
|
const { hasLocalIdentity } = await getGitLibraries();
|
||||||
|
try {
|
||||||
|
const directory = req.query.directory;
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(400).json({ error: 'directory parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLocal = await hasLocalIdentity(directory);
|
||||||
|
res.json({ hasLocalIdentity: hasLocal });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check local git identity:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to check local git identity' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/git/set-identity', async (req, res) => {
|
app.post('/api/git/set-identity', async (req, res) => {
|
||||||
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
|
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ export async function getCurrentIdentity(directory) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasLocalIdentity(directory) {
|
||||||
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localName = await git.getConfig('user.name', 'local').catch(() => null);
|
||||||
|
const localEmail = await git.getConfig('user.email', 'local').catch(() => null);
|
||||||
|
return Boolean(localName?.value || localEmail?.value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setLocalIdentity(directory, profile) {
|
export async function setLocalIdentity(directory, profile) {
|
||||||
const git = simpleGit(normalizeDirectoryPath(directory));
|
const git = simpleGit(normalizeDirectoryPath(directory));
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const createWebGitAPI = (): GitAPI => ({
|
|||||||
},
|
},
|
||||||
getCommitFiles: gitApiHttp.getCommitFiles,
|
getCommitFiles: gitApiHttp.getCommitFiles,
|
||||||
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
||||||
|
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
|
||||||
setGitIdentity: gitApiHttp.setGitIdentity,
|
setGitIdentity: gitApiHttp.setGitIdentity,
|
||||||
getGitIdentities: gitApiHttp.getGitIdentities,
|
getGitIdentities: gitApiHttp.getGitIdentities,
|
||||||
createGitIdentity: gitApiHttp.createGitIdentity,
|
createGitIdentity: gitApiHttp.createGitIdentity,
|
||||||
|
|||||||
Reference in New Issue
Block a user