- Migrated from HIVE branding to WHOOSH across all components - Enhanced backend API with new services: AI models, BZZZ integration, templates, members - Added comprehensive testing suite with security, performance, and integration tests - Improved frontend with new components for project setup, AI models, and team management - Updated MCP server implementation with WHOOSH-specific tools and resources - Enhanced deployment configurations with production-ready Docker setups - Added comprehensive documentation and setup guides - Implemented age encryption service and UCXL integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
592 lines
20 KiB
TypeScript
592 lines
20 KiB
TypeScript
/**
|
|
* Git Repositories Management Page
|
|
* Allows users to add, manage, and configure git repositories for their projects
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
GitBranch,
|
|
Plus,
|
|
Settings,
|
|
RefreshCw,
|
|
Trash2,
|
|
FolderOpen,
|
|
FileText,
|
|
ExternalLink,
|
|
Key,
|
|
Lock,
|
|
Unlock,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Loader2,
|
|
Eye,
|
|
EyeOff
|
|
} from 'lucide-react';
|
|
import { apiConfig } from '../config/api';
|
|
import { useAuthenticatedFetch } from '../contexts/AuthContext';
|
|
|
|
interface GitRepository {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
project_id?: string;
|
|
local_path?: string;
|
|
default_branch: string;
|
|
status: string;
|
|
last_updated?: string;
|
|
commit_hash?: string;
|
|
commit_message?: string;
|
|
error_message?: string;
|
|
credentials: {
|
|
auth_type: string;
|
|
has_username: boolean;
|
|
has_password: boolean;
|
|
has_ssh_key: boolean;
|
|
};
|
|
}
|
|
|
|
interface Project {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export const GitRepositories: React.FC = () => {
|
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
url: '',
|
|
project_id: '',
|
|
auth_type: 'https',
|
|
username: '',
|
|
password: '',
|
|
ssh_key_content: ''
|
|
});
|
|
const [formLoading, setFormLoading] = useState(false);
|
|
const [error, setError] = useState<string>('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [selectedRepo, setSelectedRepo] = useState<GitRepository | null>(null);
|
|
const [showFiles, setShowFiles] = useState<string | null>(null);
|
|
const [fileStructure, setFileStructure] = useState<any>(null);
|
|
|
|
const authenticatedFetch = useAuthenticatedFetch();
|
|
const API_BASE_URL = apiConfig.baseURL + '/api';
|
|
|
|
useEffect(() => {
|
|
loadRepositories();
|
|
loadProjects();
|
|
}, []);
|
|
|
|
const loadRepositories = async () => {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/git-repositories/`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setRepositories(data.data.repositories);
|
|
} else {
|
|
setError('Failed to load repositories');
|
|
}
|
|
} catch (err: any) {
|
|
setError(`Error loading repositories: ${err.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadProjects = async () => {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/projects`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setProjects(data.data.projects || []);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error loading projects:', err);
|
|
}
|
|
};
|
|
|
|
const handleAddRepository = async () => {
|
|
try {
|
|
setFormLoading(true);
|
|
setError('');
|
|
|
|
const credentials = {
|
|
auth_type: formData.auth_type,
|
|
username: formData.username || undefined,
|
|
password: formData.password || undefined,
|
|
ssh_key_content: formData.ssh_key_content || undefined
|
|
};
|
|
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/git-repositories/`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
url: formData.url,
|
|
project_id: formData.project_id || undefined,
|
|
credentials
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setShowAddForm(false);
|
|
setFormData({
|
|
name: '',
|
|
url: '',
|
|
project_id: '',
|
|
auth_type: 'https',
|
|
username: '',
|
|
password: '',
|
|
ssh_key_content: ''
|
|
});
|
|
loadRepositories();
|
|
} else {
|
|
setError(data.data?.error || 'Failed to add repository');
|
|
}
|
|
} catch (err: any) {
|
|
setError(`Error adding repository: ${err.message}`);
|
|
} finally {
|
|
setFormLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateRepository = async (repoId: string) => {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/git-repositories/${repoId}/update`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
loadRepositories();
|
|
} else {
|
|
setError(data.data?.error || 'Failed to update repository');
|
|
}
|
|
} catch (err: any) {
|
|
setError(`Error updating repository: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const handleRemoveRepository = async (repoId: string) => {
|
|
if (!confirm('Are you sure you want to remove this repository?')) return;
|
|
|
|
try {
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/git-repositories/${repoId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
loadRepositories();
|
|
} else {
|
|
setError(data.data?.error || 'Failed to remove repository');
|
|
}
|
|
} catch (err: any) {
|
|
setError(`Error removing repository: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const loadFileStructure = async (repoId: string) => {
|
|
try {
|
|
const response = await authenticatedFetch(`${API_BASE_URL}/git-repositories/${repoId}/files`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setFileStructure(data.data.structure);
|
|
setShowFiles(repoId);
|
|
} else {
|
|
setError(data.data?.error || 'Failed to load file structure');
|
|
}
|
|
} catch (err: any) {
|
|
setError(`Error loading files: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'ready':
|
|
return <CheckCircle className="w-4 h-4 text-green-600" />;
|
|
case 'cloning':
|
|
return <Loader2 className="w-4 h-4 text-blue-600 animate-spin" />;
|
|
case 'error':
|
|
return <AlertCircle className="w-4 h-4 text-red-600" />;
|
|
default:
|
|
return <AlertCircle className="w-4 h-4 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'ready': return 'bg-green-100 text-green-800';
|
|
case 'cloning': return 'bg-blue-100 text-blue-800';
|
|
case 'error': return 'bg-red-100 text-red-800';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
const getAuthIcon = (authType: string) => {
|
|
switch (authType) {
|
|
case 'ssh': return <Key className="w-4 h-4" />;
|
|
case 'https': return <Lock className="w-4 h-4" />;
|
|
default: return <Unlock className="w-4 h-4" />;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
|
<span className="ml-2 text-gray-600">Loading repositories...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Git Repositories</h1>
|
|
<p className="text-gray-600 mt-2">
|
|
Manage git repositories for your projects with secure credential storage
|
|
</p>
|
|
</div>
|
|
|
|
<Dialog open={showAddForm} onOpenChange={setShowAddForm}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Repository
|
|
</Button>
|
|
</DialogTrigger>
|
|
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Git Repository</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="name">Repository Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="My Project Repository"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="project">Project (Optional)</Label>
|
|
<Select
|
|
value={formData.project_id}
|
|
onValueChange={(value) => setFormData(prev => ({ ...prev, project_id: value }))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select project" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">No project</SelectItem>
|
|
{projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
{project.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="url">Repository URL</Label>
|
|
<Input
|
|
id="url"
|
|
value={formData.url}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
|
|
placeholder="https://github.com/user/repo.git or git@github.com:user/repo.git"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="auth_type">Authentication Type</Label>
|
|
<Select
|
|
value={formData.auth_type}
|
|
onValueChange={(value) => setFormData(prev => ({ ...prev, auth_type: value }))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="https">HTTPS (Username/Password)</SelectItem>
|
|
<SelectItem value="ssh">SSH Key</SelectItem>
|
|
<SelectItem value="token">Personal Access Token</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{formData.auth_type === 'https' && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="username">Username</Label>
|
|
<Input
|
|
id="username"
|
|
value={formData.username}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
|
placeholder="Git username"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="password">Password/Token</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={formData.password}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
|
placeholder="Password or personal access token"
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{formData.auth_type === 'ssh' && (
|
|
<div>
|
|
<Label htmlFor="ssh_key">SSH Private Key</Label>
|
|
<Textarea
|
|
id="ssh_key"
|
|
value={formData.ssh_key_content}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, ssh_key_content: e.target.value }))}
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
|
rows={8}
|
|
/>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Paste your private SSH key content here
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{formData.auth_type === 'token' && (
|
|
<div>
|
|
<Label htmlFor="token">Personal Access Token</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="token"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={formData.password}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
|
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600"
|
|
>
|
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Generate a personal access token from your git provider
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleAddRepository}
|
|
disabled={formLoading || !formData.name || !formData.url}
|
|
>
|
|
{formLoading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Adding...
|
|
</>
|
|
) : (
|
|
'Add Repository'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{repositories.length === 0 ? (
|
|
<Card className="col-span-full">
|
|
<CardContent className="text-center py-12">
|
|
<GitBranch className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Repositories</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Add your first git repository to start working with your codebase
|
|
</p>
|
|
<Button onClick={() => setShowAddForm(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Repository
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
repositories.map((repo) => (
|
|
<Card key={repo.id}>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<GitBranch className="w-5 h-5" />
|
|
{repo.name}
|
|
<Badge className={`ml-2 ${getStatusColor(repo.status)}`}>
|
|
{getStatusIcon(repo.status)}
|
|
<span className="ml-1">{repo.status}</span>
|
|
</Badge>
|
|
</CardTitle>
|
|
<p className="text-sm text-gray-600 mt-1">{repo.url}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{getAuthIcon(repo.credentials.auth_type)}
|
|
<span className="text-xs text-gray-500">{repo.credentials.auth_type}</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{repo.commit_hash && (
|
|
<div className="text-sm">
|
|
<span className="font-medium">Latest commit:</span>
|
|
<div className="mt-1 p-2 bg-gray-50 rounded text-xs">
|
|
<div className="font-mono text-gray-700">{repo.commit_hash.substring(0, 8)}</div>
|
|
{repo.commit_message && (
|
|
<div className="text-gray-600 mt-1">{repo.commit_message}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{repo.error_message && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">{repo.error_message}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleUpdateRepository(repo.id)}
|
|
disabled={repo.status === 'cloning'}
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-1" />
|
|
Update
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => loadFileStructure(repo.id)}
|
|
disabled={repo.status !== 'ready'}
|
|
>
|
|
<FolderOpen className="w-4 h-4 mr-1" />
|
|
Browse
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => window.open(repo.url, '_blank')}
|
|
>
|
|
<ExternalLink className="w-4 h-4 mr-1" />
|
|
Remote
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRemoveRepository(repo.id)}
|
|
className="text-red-600 hover:text-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* File Browser Modal */}
|
|
{showFiles && fileStructure && (
|
|
<Dialog open={!!showFiles} onOpenChange={() => setShowFiles(null)}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Repository Files</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-sm text-gray-600 mb-4">
|
|
Browsing: {repositories.find(r => r.id === showFiles)?.name}
|
|
</div>
|
|
|
|
<div className="border rounded p-4 bg-gray-50 font-mono text-sm">
|
|
<pre>{JSON.stringify(fileStructure, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default GitRepositories; |