Initial commit for frontend
This commit is contained in:
parent
99107c06c5
commit
117f22e67c
@ -2,7 +2,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@tldraw/tldraw'],
|
||||
output: 'export',
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
webpack: (config, { isServer }) => {
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@ -16,7 +16,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@ -3708,7 +3708,7 @@
|
||||
"version": "19.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
|
||||
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -3718,7 +3718,7 @@
|
||||
"version": "19.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@ -4896,7 +4896,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
@ -8393,6 +8393,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
|
||||
@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Redirect to unified service through API Gateway
|
||||
const apiGatewayUrl = process.env.BACKEND_URL || 'https://backend.codenuk.com';
|
||||
const apiGatewayUrl = process.env.BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
const response = await fetch(`${apiGatewayUrl}/api/unified/comprehensive-recommendations`, {
|
||||
method: 'POST',
|
||||
|
||||
78
src/app/api/diffs/[...path]/route.ts
Normal file
78
src/app/api/diffs/[...path]/route.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// app/api/diffs/[...path]/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch diff data',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const path = params.path.join('/');
|
||||
const body = await request.json();
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams.toString();
|
||||
const fullUrl = `${GIT_INTEGRATION_URL}/api/diffs/${path}${searchParams ? `?${searchParams}` : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error proxying diff request:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to process diff request',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/app/api/diffs/repositories/route.ts
Normal file
32
src/app/api/diffs/repositories/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// app/api/diffs/repositories/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GIT_INTEGRATION_URL = process.env.GIT_INTEGRATION_URL || 'http://localhost:8012';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const response = await fetch(`${GIT_INTEGRATION_URL}/api/diffs/repositories`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Git integration service responded with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching repositories:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to fetch repositories',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
324
src/app/diff-viewer/page.tsx
Normal file
324
src/app/diff-viewer/page.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
// app/diff-viewer/page.tsx
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
GitCommit,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import DiffViewer from '@/components/diff-viewer/DiffViewer';
|
||||
|
||||
interface Repository {
|
||||
id: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
sync_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
message: string;
|
||||
committed_at: string;
|
||||
files_changed: number;
|
||||
diffs_processed: number;
|
||||
total_diff_size: number;
|
||||
}
|
||||
|
||||
const DiffViewerPage: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [commits, setCommits] = useState<Commit[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState<string>('');
|
||||
const [selectedCommit, setSelectedCommit] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Handle URL parameters
|
||||
useEffect(() => {
|
||||
const repoId = searchParams.get('repo');
|
||||
if (repoId) {
|
||||
setSelectedRepository(repoId);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/diffs/repositories');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRepositories(data.data.repositories);
|
||||
} else {
|
||||
setError(data.message || 'Failed to load repositories');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
// Load commits when repository is selected
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
// Auto-select first commit
|
||||
if (data.data.commits.length > 0) {
|
||||
setSelectedCommit(data.data.commits[0].id);
|
||||
}
|
||||
} else {
|
||||
setError(data.message || 'Failed to load commits');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
}, [selectedRepository]);
|
||||
|
||||
const handleRepositoryChange = (repositoryId: string) => {
|
||||
setSelectedRepository(repositoryId);
|
||||
setSelectedCommit('');
|
||||
};
|
||||
|
||||
const handleCommitChange = (commitId: string) => {
|
||||
setSelectedCommit(commitId);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedRepository) {
|
||||
const loadCommits = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`/api/diffs/repositories/${selectedRepository}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCommits(data.data.commits);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh commits');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommits();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCommits = commits.filter(commit =>
|
||||
commit.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.author_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
commit.commit_sha.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Git Diff Viewer</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
View and analyze git diffs from your repositories
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading || !selectedRepository}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repository and Commit Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
<span>Select Repository & Commit</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Repository Selection */}
|
||||
<div>
|
||||
<Label htmlFor="repository">Repository</Label>
|
||||
<select
|
||||
id="repository"
|
||||
value={selectedRepository}
|
||||
onChange={(e) => handleRepositoryChange(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.owner_name}/{repo.repository_name} ({repo.sync_status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Commit Selection */}
|
||||
{selectedRepository && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="commit">Commit</Label>
|
||||
<Badge variant="outline">
|
||||
{commits.length} commits
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search commits..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="commit"
|
||||
value={selectedCommit}
|
||||
onChange={(e) => handleCommitChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">Select a commit...</option>
|
||||
{filteredCommits.map((commit) => (
|
||||
<option key={commit.id} value={commit.id}>
|
||||
{commit.commit_sha.substring(0, 8)} - {commit.message.substring(0, 50)}
|
||||
{commit.message.length > 50 ? '...' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit Info */}
|
||||
{selectedCommit && (
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
<span className="font-medium">Selected Commit</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const commit = commits.find(c => c.id === selectedCommit);
|
||||
return commit ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-xs bg-muted px-2 py-1 rounded">
|
||||
{commit.commit_sha.substring(0, 8)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">by {commit.author_name}</span>
|
||||
</div>
|
||||
<p className="font-medium">{commit.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
|
||||
<span>{commit.files_changed} files changed</span>
|
||||
<span>{commit.diffs_processed} diffs processed</span>
|
||||
<span>{(commit.total_diff_size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{selectedRepository && selectedCommit && (
|
||||
<DiffViewer
|
||||
repositoryId={selectedRepository}
|
||||
commitId={selectedCommit}
|
||||
initialView="side-by-side"
|
||||
className="min-h-[600px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No Selection State */}
|
||||
{!selectedRepository && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Repository Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a repository to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedRepository && !selectedCommit && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<GitCommit className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">No Commit Selected</p>
|
||||
<p className="text-sm mt-2">
|
||||
Please select a commit to view its diffs
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerPage;
|
||||
@ -14,6 +14,9 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const [fileLoading, setFileLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
@ -48,6 +51,8 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
@ -55,71 +60,141 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const parts = path.split("/")
|
||||
parts.pop()
|
||||
setPath(parts.join("/"))
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
}
|
||||
|
||||
const handleFileClick = async (fileName: string) => {
|
||||
const filePath = path ? `${path}/${fileName}` : fileName
|
||||
setSelectedFile(filePath)
|
||||
setFileLoading(true)
|
||||
|
||||
try {
|
||||
const content = await getRepositoryFileContent(repositoryId, filePath)
|
||||
setFileContent(content?.content || null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content:', error)
|
||||
setFileContent(null)
|
||||
} finally {
|
||||
setFileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repos
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Back to Repos
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold truncate">Repository #{repositoryId}</h1>
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-emerald-900/40 text-emerald-300 border border-emerald-800">Attached</span>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-semibold">Repository #{repositoryId}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
Attached
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2">
|
||||
<Button variant="outline" className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">
|
||||
<GitBranch className="h-4 w-4 mr-2"/> main
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={goUp}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/> Up one level
|
||||
</Button>
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||
<Input value={fileQuery} onChange={e=>setFileQuery(e.target.value)} placeholder="Go to file" className="pl-9 h-8 text-sm bg-white/5 border-white/10 text-white placeholder:text-white/40"/>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||
<Input
|
||||
placeholder="Q Go to file"
|
||||
value={fileQuery}
|
||||
onChange={(e) => setFileQuery(e.target.value)}
|
||||
className="pl-10 bg-white/5 border-white/10 text-white placeholder-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={goUp} className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">Up one level</Button>
|
||||
<Button className="h-8 text-sm bg-emerald-600 hover:bg-emerald-500 text-black">
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<Code className="h-4 w-4 mr-2"/> Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : void 0}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* File Tree - Left Side */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">
|
||||
Files {path && `- ${path}`}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||
<Clock className="h-4 w-4"/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className={`flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer ${
|
||||
selectedFile === (path ? `${path}/${e.name}` : e.name) ? 'bg-white/10' : ''
|
||||
}`}
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : handleFileClick(e.name)}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-20 text-right text-sm text-white/60">
|
||||
{e.size && `${Math.round(Number(e.size) / 1024)}KB`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">README</div>
|
||||
{!readme ? (
|
||||
<div className="p-8 text-center">
|
||||
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90">{readme}</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* File Content - Right Side */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="bg-white/5 border-white/10 h-[70vh]">
|
||||
<CardContent className="p-0 h-full flex flex-col">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex-shrink-0">
|
||||
{selectedFile ? `File: ${selectedFile}` : 'README'}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedFile ? (
|
||||
<div className="h-full p-4">
|
||||
{fileLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<span className="ml-2 text-white/60">Loading file content...</span>
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">File content not available</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">This file could not be loaded or is binary.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !readme ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<pre className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 p-4 rounded overflow-auto h-full">{readme}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,198 +1,275 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ArrowLeft, ExternalLink, FolderGit2, GitFork, Star, Shield, Search, Brain } from "lucide-react"
|
||||
import { getGitHubAuthStatus, getUserRepositories, type GitHubRepoSummary } from "@/lib/api/github"
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Github,
|
||||
FolderOpen,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Star,
|
||||
Eye,
|
||||
Code,
|
||||
Calendar,
|
||||
GitCompare
|
||||
} from 'lucide-react';
|
||||
import { getUserRepositories, type GitHubRepoSummary } from '@/lib/api/github';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function GitHubUserReposPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [connected, setConnected] = useState<boolean>(false)
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null)
|
||||
const [repos, setRepos] = useState<GitHubRepoSummary[]>([])
|
||||
const [query, setQuery] = useState("")
|
||||
const [analyzingRepo, setAnalyzingRepo] = useState<string | null>(null)
|
||||
const GitHubReposPage: React.FC = () => {
|
||||
const [repositories, setRepositories] = useState<GitHubRepoSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'public' | 'private'>('all');
|
||||
|
||||
// Load repositories
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
const loadRepositories = async () => {
|
||||
try {
|
||||
setAuthLoading(true)
|
||||
const status = await getGitHubAuthStatus()
|
||||
if (!mounted) return
|
||||
setConnected(Boolean(status?.data?.connected))
|
||||
setAuthUrl(status?.data?.auth_url || null)
|
||||
} catch (_) {
|
||||
setConnected(false)
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories();
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load repositories');
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
setIsLoading(false);
|
||||
}
|
||||
})()
|
||||
;(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const list = await getUserRepositories()
|
||||
if (!mounted) return
|
||||
setRepos(Array.isArray(list) ? list : [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [])
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return repos
|
||||
return repos.filter(r =>
|
||||
(r.full_name || "").toLowerCase().includes(q) ||
|
||||
(r.description || "").toLowerCase().includes(q) ||
|
||||
(r.language || "").toLowerCase().includes(q)
|
||||
)
|
||||
}, [repos, query])
|
||||
loadRepositories();
|
||||
}, []);
|
||||
|
||||
const handleConnect = () => {
|
||||
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||
const base = authUrl || "/api/github/auth/github"
|
||||
const url = `${base}?redirect=1${userId ? `&user_id=${encodeURIComponent(userId)}` : ''}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
const handleAnalyzeWithAI = async (repo: GitHubRepoSummary) => {
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setAnalyzingRepo(repo.full_name || repo.name || '')
|
||||
|
||||
// Navigate to AI analysis page with repository details
|
||||
const repoId = (repo as any).id || repo.full_name || repo.name
|
||||
const repoName = repo.full_name || repo.name
|
||||
|
||||
const analysisUrl = `/github/analyze?repoId=${encodeURIComponent(repoId)}&repoName=${encodeURIComponent(repoName)}`
|
||||
window.location.href = analysisUrl
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing repository:', error)
|
||||
alert('Failed to analyze repository. Please try again.')
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const repos = await getUserRepositories(true); // Clear cache
|
||||
setRepositories(repos);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to refresh repositories');
|
||||
} finally {
|
||||
setAnalyzingRepo(null)
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRepositories = repositories.filter(repo => {
|
||||
const matchesSearch = repo.full_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
repo.language?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesFilter = filter === 'all' ||
|
||||
(filter === 'public' && repo.visibility === 'public') ||
|
||||
(filter === 'private' && repo.visibility === 'private');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/project-builder">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Builder
|
||||
<div className="max-w-7xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center space-x-2">
|
||||
<Github className="h-8 w-8" />
|
||||
<span>My GitHub Repositories</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Browse and analyze your GitHub repositories
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/diff-viewer">
|
||||
<GitCompare className="h-4 w-4 mr-2" />
|
||||
Git Diff
|
||||
</Link>
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-3xl md:text-4xl font-bold">My GitHub Repositories</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
||||
<Input
|
||||
placeholder="Search by name, description, or language..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-10 h-11 text-base border border-white/10 bg-white/5 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
{!connected && (
|
||||
<Button onClick={handleConnect} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
<Shield className="mr-2 h-4 w-4" /> Connect GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Search and Filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search repositories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'public' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('public')}
|
||||
>
|
||||
Public
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'private' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('private')}
|
||||
>
|
||||
Private
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(authLoading || loading) && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!authLoading && !connected && repos.length === 0 && (
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5 text-orange-400" />
|
||||
Connect your GitHub account
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-white/70 space-y-4">
|
||||
<p>Connect GitHub to view your private repositories and enable one-click attach to projects.</p>
|
||||
<Button onClick={handleConnect} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||
<Shield className="mr-2 h-4 w-4" /> Connect GitHub
|
||||
</Button>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Error</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="text-center text-white/60 py-16">
|
||||
<p>No repositories found{query ? ' for your search.' : '.'}</p>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading repositories...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Repositories Grid */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRepositories.map((repo) => (
|
||||
<Card key={repo.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FolderOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{repo.name || 'Unknown Repository'}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{repo.full_name || 'Unknown Owner'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={repo.visibility === 'public' ? 'default' : 'secondary'}>
|
||||
{repo.visibility || 'unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{repo.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Repository Stats */}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
{repo.language && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>{repo.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>{repo.stargazers_count || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span>{repo.forks_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Updated Date */}
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/github/repo?id=${repo.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-white/10 rounded-lg border border-white/10 overflow-hidden">
|
||||
{filtered.map((repo) => (
|
||||
<div key={repo.id || repo.full_name} className="p-5 hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{repo.html_url ? (
|
||||
<a href={repo.html_url} target="_blank" rel="noopener noreferrer" className="font-semibold text-orange-400 hover:text-orange-300 truncate">
|
||||
{repo.full_name || repo.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-semibold truncate">{repo.full_name || repo.name}</span>
|
||||
)}
|
||||
<Badge className={repo.visibility === 'private' ? 'bg-rose-900/40 text-rose-300 border border-rose-800' : 'bg-emerald-900/40 text-emerald-300 border border-emerald-800'}>
|
||||
{repo.visibility === 'private' ? 'Private' : 'Public'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-white/70 mt-1 line-clamp-2">
|
||||
{repo.description || 'No description provided.'}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-white/60">
|
||||
{repo.language && (
|
||||
<span className="inline-flex items-center gap-1">{repo.language}</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1"><Star className="h-4 w-4 text-yellow-400" /> {repo.stargazers_count ?? 0}</span>
|
||||
<span className="inline-flex items-center gap-1"><GitFork className="h-4 w-4 text-blue-400" /> {repo.forks_count ?? 0}</span>
|
||||
{repo.updated_at && (
|
||||
<span className="inline-flex items-center gap-1">Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
onClick={() => handleAnalyzeWithAI(repo)}
|
||||
disabled={analyzingRepo === (repo.full_name || repo.name)}
|
||||
>
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
{analyzingRepo === (repo.full_name || repo.name) ? 'Analyzing...' : 'Analyze with AI'}
|
||||
</Button>
|
||||
<Link
|
||||
href={`/github/repo?id=${encodeURIComponent(String((repo as any).id ?? ''))}`}
|
||||
>
|
||||
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||
<ExternalLink className="mr-2 h-4 w-4" /> Open
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredRepositories.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Github className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">
|
||||
{searchQuery || filter !== 'all' ? 'No repositories found' : 'No repositories available'}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
{searchQuery || filter !== 'all'
|
||||
? 'Try adjusting your search or filter criteria'
|
||||
: 'Make sure you have connected your GitHub account and have repositories'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubReposPage;
|
||||
|
||||
@ -25,6 +25,7 @@ function ProjectBuilderContent() {
|
||||
|
||||
const githubConnected = searchParams.get('github_connected')
|
||||
const githubUser = searchParams.get('user')
|
||||
const processing = searchParams.get('processing')
|
||||
const repoAttached = searchParams.get('repo_attached')
|
||||
const repositoryId = searchParams.get('repository_id')
|
||||
const syncStatus = searchParams.get('sync_status')
|
||||
@ -32,16 +33,28 @@ function ProjectBuilderContent() {
|
||||
if (githubConnected === '1') {
|
||||
console.log('🎉 GitHub OAuth callback successful!', {
|
||||
githubUser,
|
||||
processing,
|
||||
repoAttached,
|
||||
repositoryId,
|
||||
syncStatus
|
||||
})
|
||||
|
||||
// Clear any pending git attach from sessionStorage
|
||||
try {
|
||||
sessionStorage.removeItem('pending_git_attach')
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear pending attach:', e)
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (repoAttached === '1') {
|
||||
alert(`🎉 Repository attached successfully!\n\nGitHub User: ${githubUser}\nRepository ID: ${repositoryId}\nSync Status: ${syncStatus}`)
|
||||
if (processing === '1') {
|
||||
// Repository is being processed in background
|
||||
alert(`GitHub account connected successfully!\n\nGitHub User: ${githubUser}\n\nYour repository is being processed in the background. This may take a few moments.\n\nYou can start working, and the repository will be available shortly.`)
|
||||
} else if (repoAttached === '1' && repositoryId) {
|
||||
alert(`Repository attached successfully!\n\nGitHub User: ${githubUser}\nRepository ID: ${repositoryId}\nSync Status: ${syncStatus}`)
|
||||
} else {
|
||||
alert(`🎉 GitHub account connected successfully!\n\nGitHub User: ${githubUser}`)
|
||||
// Generic success message
|
||||
alert(`GitHub account connected successfully!\n\nGitHub User: ${githubUser}`)
|
||||
}
|
||||
|
||||
// Clean up URL parameters
|
||||
|
||||
219
src/components/diff-viewer/DiffControls.tsx
Normal file
219
src/components/diff-viewer/DiffControls.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
// components/diff-viewer/DiffControls.tsx
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
Settings,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Type,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface DiffControlsProps {
|
||||
currentView: 'side-by-side' | 'unified';
|
||||
onViewChange: (view: 'side-by-side' | 'unified') => void;
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
onPreferencesChange: (preferences: DiffPreferences) => void;
|
||||
}
|
||||
|
||||
const DiffControls: React.FC<DiffControlsProps> = ({
|
||||
currentView,
|
||||
onViewChange,
|
||||
statistics,
|
||||
preferences,
|
||||
onPreferencesChange
|
||||
}) => {
|
||||
const handlePreferenceChange = (key: keyof DiffPreferences, value: any) => {
|
||||
onPreferencesChange({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: number[]) => {
|
||||
handlePreferenceChange('fontSize', value[0]);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: DiffPreferences['theme']) => {
|
||||
handlePreferenceChange('theme', theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* View selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">View Mode</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={currentView === 'side-by-side' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('side-by-side')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentView === 'unified' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewChange('unified')}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{statistics && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Statistics</Label>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Files:</span>
|
||||
<Badge variant="outline">{statistics.total_files}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Additions:</span>
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.total_additions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Deletions:</span>
|
||||
<Badge variant="destructive">
|
||||
-{statistics.total_deletions}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Size:</span>
|
||||
<Badge variant="outline">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display preferences */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Display Options</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">
|
||||
Show Line Numbers
|
||||
</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.showLineNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showLineNumbers', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="whitespace" className="text-sm">
|
||||
Show Whitespace
|
||||
</Label>
|
||||
<Switch
|
||||
id="whitespace"
|
||||
checked={preferences.showWhitespace}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('showWhitespace', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wrap-lines" className="text-sm">
|
||||
Wrap Lines
|
||||
</Label>
|
||||
<Switch
|
||||
id="wrap-lines"
|
||||
checked={preferences.wrapLines}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePreferenceChange('wrapLines', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font size */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">
|
||||
Font Size: {preferences.fontSize}px
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[preferences.fontSize]}
|
||||
onValueChange={handleFontSizeChange}
|
||||
min={10}
|
||||
max={24}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font family */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Font Family</Label>
|
||||
<select
|
||||
value={preferences.fontFamily}
|
||||
onChange={(e) => handlePreferenceChange('fontFamily', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-sm"
|
||||
>
|
||||
<option value="monospace">Monospace</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Consolas">Consolas</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Theme selector */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Theme</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={preferences.theme === 'light' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'dark' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={preferences.theme === 'high-contrast' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleThemeChange('high-contrast')}
|
||||
>
|
||||
High Contrast
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffControls;
|
||||
186
src/components/diff-viewer/DiffStats.tsx
Normal file
186
src/components/diff-viewer/DiffStats.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// components/diff-viewer/DiffStats.tsx
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
GitCommit,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { DiffStatistics } from './DiffViewerContext';
|
||||
|
||||
interface DiffStatsProps {
|
||||
statistics: DiffStatistics;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffStats: React.FC<DiffStatsProps> = ({ statistics, className = '' }) => {
|
||||
const totalChanges = statistics.total_additions + statistics.total_deletions;
|
||||
const additionPercentage = totalChanges > 0 ? (statistics.total_additions / totalChanges) * 100 : 0;
|
||||
const deletionPercentage = totalChanges > 0 ? (statistics.total_deletions / totalChanges) * 100 : 0;
|
||||
|
||||
const getChangeTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'modified':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'deleted':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'renamed':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center space-x-2 text-lg">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Diff Statistics</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Overview stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{statistics.total_files}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Files Changed</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
+{statistics.total_additions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Additions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="text-2xl font-bold text-red-600">
|
||||
-{statistics.total_deletions}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Deletions</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 mb-1">
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">
|
||||
{totalChanges}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Total Changes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Change Distribution</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Additions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={additionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{additionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Deletions</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={deletionPercentage} className="w-20" />
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{deletionPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File types breakdown */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">File Types</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(statistics.files_by_type).map(([type, count]) => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className={`${getChangeTypeColor(type)} border`}
|
||||
>
|
||||
{type}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size information */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Size Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Size:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{(statistics.total_size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Avg per File:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{statistics.total_files > 0
|
||||
? (statistics.total_size_bytes / statistics.total_files / 1024).toFixed(1)
|
||||
: 0} KB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net change indicator */}
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
{statistics.total_additions > statistics.total_deletions ? (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Net Addition: +{statistics.total_additions - statistics.total_deletions} lines
|
||||
</span>
|
||||
</>
|
||||
) : statistics.total_deletions > statistics.total_additions ? (
|
||||
<>
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm text-red-600 font-medium">
|
||||
Net Deletion: -{statistics.total_deletions - statistics.total_additions} lines
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-medium">
|
||||
Balanced: {statistics.total_additions} additions, {statistics.total_deletions} deletions
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffStats;
|
||||
249
src/components/diff-viewer/DiffViewer.tsx
Normal file
249
src/components/diff-viewer/DiffViewer.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
// components/diff-viewer/DiffViewer.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Monitor,
|
||||
Code,
|
||||
GitCommit,
|
||||
FileText,
|
||||
Settings,
|
||||
Search,
|
||||
Filter,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
import SideBySideView from './SideBySideView';
|
||||
import UnifiedView from './UnifiedView';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { DiffViewerProvider, useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
interface DiffViewerProps {
|
||||
repositoryId: string;
|
||||
commitId?: string;
|
||||
initialView?: 'side-by-side' | 'unified';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
repositoryId,
|
||||
commitId,
|
||||
initialView = 'side-by-side',
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = useState(initialView);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
state,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
const { commit, files, statistics, preferences } = state;
|
||||
const theme = preferences.theme;
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (commitId) {
|
||||
await loadCommitDiffs(commitId);
|
||||
} else {
|
||||
await loadRepositoryCommits(repositoryId);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [repositoryId, commitId, loadCommitDiffs, loadRepositoryCommits]);
|
||||
|
||||
const handleViewChange = useCallback((newView: string) => {
|
||||
const view = newView as 'side-by-side' | 'unified';
|
||||
setCurrentView(view);
|
||||
setPreferences({ ...preferences, defaultView: view });
|
||||
}, [setPreferences, preferences]);
|
||||
|
||||
const handleFileSelect = useCallback((filePath: string) => {
|
||||
setSelectedFile(filePath);
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
if (!files || files.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No diff data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedFileData = selectedFile
|
||||
? files.find((f: any) => f.file_path === selectedFile) || null
|
||||
: files[0] || null;
|
||||
|
||||
switch (currentView) {
|
||||
case 'side-by-side':
|
||||
return (
|
||||
<SideBySideView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
case 'unified':
|
||||
return (
|
||||
<UnifiedView
|
||||
files={files}
|
||||
selectedFile={selectedFileData}
|
||||
onFileSelect={handleFileSelect}
|
||||
theme={theme}
|
||||
preferences={preferences}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p>Loading diff data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="font-medium">Failed to load diff data</p>
|
||||
<p className="text-sm mt-2">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`diff-viewer ${className}`}>
|
||||
{/* Header with commit info and controls */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<GitCommit className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{commit?.message || 'Diff Viewer'}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant="outline">
|
||||
{commit?.author_name}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{commit?.committed_at ? new Date(commit.committed_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* File type badges */}
|
||||
{statistics && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{statistics.files_by_type.added > 0 && (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
+{statistics.files_by_type.added} added
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.modified > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{statistics.files_by_type.modified} modified
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.deleted > 0 && (
|
||||
<Badge variant="destructive">
|
||||
-{statistics.files_by_type.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
{statistics.files_by_type.renamed > 0 && (
|
||||
<Badge variant="outline">
|
||||
{statistics.files_by_type.renamed} renamed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Main diff content */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Tabs value={currentView} onValueChange={handleViewChange}>
|
||||
<div className="border-b">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="side-by-side" className="flex items-center space-x-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span>Side-by-Side</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="unified" className="flex items-center space-x-2">
|
||||
<Code className="h-4 w-4" />
|
||||
<span>Unified</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] overflow-hidden border rounded-md">
|
||||
{renderView()}
|
||||
</div>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper component with context provider
|
||||
const DiffViewerWithProvider: React.FC<DiffViewerProps> = (props) => {
|
||||
return (
|
||||
<DiffViewerProvider>
|
||||
<DiffViewer {...props} />
|
||||
</DiffViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewerWithProvider;
|
||||
256
src/components/diff-viewer/DiffViewerContext.tsx
Normal file
256
src/components/diff-viewer/DiffViewerContext.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
// components/diff-viewer/DiffViewerContext.tsx
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface DiffFile {
|
||||
file_change_id: string;
|
||||
file_path: string;
|
||||
change_type: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
diff_content_id?: string;
|
||||
diff_header?: string;
|
||||
diff_size_bytes?: number;
|
||||
storage_type?: string;
|
||||
external_storage_path?: string;
|
||||
processing_status?: string;
|
||||
diff_content?: string;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
id: string;
|
||||
commit_sha: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
message: string;
|
||||
url: string;
|
||||
committed_at: string;
|
||||
repository_name: string;
|
||||
owner_name: string;
|
||||
}
|
||||
|
||||
export interface DiffStatistics {
|
||||
total_files: number;
|
||||
total_additions: number;
|
||||
total_deletions: number;
|
||||
total_size_bytes: number;
|
||||
files_by_type: {
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffPreferences {
|
||||
defaultView: 'side-by-side' | 'unified';
|
||||
showLineNumbers: boolean;
|
||||
showWhitespace: boolean;
|
||||
wrapLines: boolean;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
theme: 'light' | 'dark' | 'high-contrast' | 'custom';
|
||||
customTheme?: {
|
||||
background: string;
|
||||
text: string;
|
||||
added: string;
|
||||
removed: string;
|
||||
unchanged: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiffViewerState {
|
||||
commit: Commit | null;
|
||||
files: DiffFile[];
|
||||
statistics: DiffStatistics | null;
|
||||
preferences: DiffPreferences;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Actions
|
||||
type DiffViewerAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_COMMIT'; payload: Commit | null }
|
||||
| { type: 'SET_FILES'; payload: DiffFile[] }
|
||||
| { type: 'SET_STATISTICS'; payload: DiffStatistics | null }
|
||||
| { type: 'SET_PREFERENCES'; payload: DiffPreferences }
|
||||
| { type: 'SET_THEME'; payload: DiffPreferences['theme'] }
|
||||
| { type: 'SET_CUSTOM_THEME'; payload: DiffPreferences['customTheme'] };
|
||||
|
||||
// Initial state
|
||||
const initialState: DiffViewerState = {
|
||||
commit: null,
|
||||
files: [],
|
||||
statistics: null,
|
||||
preferences: {
|
||||
defaultView: 'side-by-side',
|
||||
showLineNumbers: true,
|
||||
showWhitespace: false,
|
||||
wrapLines: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
theme: 'light'
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Reducer
|
||||
function diffViewerReducer(state: DiffViewerState, action: DiffViewerAction): DiffViewerState {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload, isLoading: false };
|
||||
case 'SET_COMMIT':
|
||||
return { ...state, commit: action.payload };
|
||||
case 'SET_FILES':
|
||||
return { ...state, files: action.payload };
|
||||
case 'SET_STATISTICS':
|
||||
return { ...state, statistics: action.payload };
|
||||
case 'SET_PREFERENCES':
|
||||
return { ...state, preferences: action.payload };
|
||||
case 'SET_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, theme: action.payload }
|
||||
};
|
||||
case 'SET_CUSTOM_THEME':
|
||||
return {
|
||||
...state,
|
||||
preferences: { ...state.preferences, customTheme: action.payload }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Context
|
||||
const DiffViewerContext = createContext<{
|
||||
state: DiffViewerState;
|
||||
dispatch: React.Dispatch<DiffViewerAction>;
|
||||
loadCommitDiffs: (commitId: string) => Promise<void>;
|
||||
loadRepositoryCommits: (repositoryId: string) => Promise<void>;
|
||||
setTheme: (theme: DiffPreferences['theme']) => void;
|
||||
setCustomTheme: (customTheme: DiffPreferences['customTheme']) => void;
|
||||
setPreferences: (preferences: DiffPreferences) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Provider component
|
||||
export const DiffViewerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(diffViewerReducer, initialState);
|
||||
|
||||
// API functions
|
||||
const loadCommitDiffs = useCallback(async (commitId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/commits/${commitId}/diffs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load commit diffs');
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_COMMIT', payload: data.data.commit });
|
||||
dispatch({ type: 'SET_FILES', payload: data.data.files });
|
||||
dispatch({ type: 'SET_STATISTICS', payload: data.data.statistics });
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load commit diffs'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRepositoryCommits = useCallback(async (repositoryId: string) => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
dispatch({ type: 'SET_ERROR', payload: null });
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/diffs/repositories/${repositoryId}/commits`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to load repository commits');
|
||||
}
|
||||
|
||||
// Load the first commit's diffs by default
|
||||
if (data.data.commits.length > 0) {
|
||||
await loadCommitDiffs(data.data.commits[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : 'Failed to load repository commits'
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, [loadCommitDiffs]);
|
||||
|
||||
const setTheme = useCallback((theme: DiffPreferences['theme']) => {
|
||||
dispatch({ type: 'SET_THEME', payload: theme });
|
||||
}, []);
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: DiffPreferences['customTheme']) => {
|
||||
dispatch({ type: 'SET_CUSTOM_THEME', payload: customTheme });
|
||||
}, []);
|
||||
|
||||
const setPreferences = useCallback((preferences: DiffPreferences) => {
|
||||
dispatch({ type: 'SET_PREFERENCES', payload: preferences });
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
state,
|
||||
dispatch,
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
|
||||
return (
|
||||
<DiffViewerContext.Provider value={value}>
|
||||
{children}
|
||||
</DiffViewerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use the context
|
||||
export const useDiffViewer = () => {
|
||||
const context = useContext(DiffViewerContext);
|
||||
if (!context) {
|
||||
throw new Error('useDiffViewer must be used within a DiffViewerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Destructured state and actions for convenience
|
||||
export const useDiffViewerState = () => {
|
||||
const { state } = useDiffViewer();
|
||||
return state;
|
||||
};
|
||||
|
||||
export const useDiffViewerActions = () => {
|
||||
const {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
} = useDiffViewer();
|
||||
|
||||
return {
|
||||
loadCommitDiffs,
|
||||
loadRepositoryCommits,
|
||||
setTheme,
|
||||
setCustomTheme,
|
||||
setPreferences
|
||||
};
|
||||
};
|
||||
368
src/components/diff-viewer/SideBySideView.tsx
Normal file
368
src/components/diff-viewer/SideBySideView.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
// components/diff-viewer/SideBySideView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface SideBySideViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
}
|
||||
|
||||
const SideBySideView: React.FC<SideBySideViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldLineNumber = 0;
|
||||
let newLineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
oldLineNumber = parseInt(match[1]) - 1;
|
||||
newLineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
oldLineNumber: undefined,
|
||||
newLineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
oldLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
oldLineNumber++;
|
||||
newLineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
oldLineNumber,
|
||||
newLineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<div className="grid grid-cols-2 h-full">
|
||||
{/* Old version */}
|
||||
<div className="border-r">
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Minus className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium">Old Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.oldLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* New version */}
|
||||
<div>
|
||||
<div className="bg-muted/50 px-4 py-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium">New Version</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex}>
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.newLineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBySideView;
|
||||
134
src/components/diff-viewer/ThemeSelector.tsx
Normal file
134
src/components/diff-viewer/ThemeSelector.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// components/diff-viewer/ThemeSelector.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Palette,
|
||||
Sun,
|
||||
Moon,
|
||||
Contrast,
|
||||
Settings,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useDiffViewer } from './DiffViewerContext';
|
||||
|
||||
const ThemeSelector: React.FC = () => {
|
||||
const { state, setTheme, setCustomTheme } = useDiffViewer();
|
||||
const [showCustomTheme, setShowCustomTheme] = useState(false);
|
||||
|
||||
const themes = [
|
||||
{
|
||||
id: 'light',
|
||||
name: 'Light',
|
||||
icon: Sun,
|
||||
description: 'Clean and bright theme',
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
text: '#333333',
|
||||
added: '#d4edda',
|
||||
removed: '#f8d7da',
|
||||
unchanged: '#f8f9fa',
|
||||
border: '#dee2e6'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: 'Dark',
|
||||
icon: Moon,
|
||||
description: 'Dark theme for low light',
|
||||
colors: {
|
||||
background: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
added: '#0d5016',
|
||||
removed: '#721c24',
|
||||
unchanged: '#2d2d2d',
|
||||
border: '#404040'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'high-contrast',
|
||||
name: 'High Contrast',
|
||||
icon: Contrast,
|
||||
description: 'High contrast for accessibility',
|
||||
colors: {
|
||||
background: '#000000',
|
||||
text: '#ffffff',
|
||||
added: '#00ff00',
|
||||
removed: '#ff0000',
|
||||
unchanged: '#333333',
|
||||
border: '#666666'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handleThemeChange = (themeId: string) => {
|
||||
if (themeId === 'custom') {
|
||||
setShowCustomTheme(true);
|
||||
} else {
|
||||
setTheme(themeId as any);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomTheme = (colors: any) => {
|
||||
setCustomTheme(colors);
|
||||
setTheme('custom');
|
||||
setShowCustomTheme(false);
|
||||
};
|
||||
|
||||
const currentTheme = themes.find(t => t.id === state.preferences.theme) || themes[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="flex items-center space-x-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{currentTheme.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64">
|
||||
<DropdownMenuLabel>Choose Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{themes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
onClick={() => handleThemeChange(theme.id)}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<theme.icon className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{theme.description}</div>
|
||||
</div>
|
||||
{state.preferences.theme === theme.id && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleThemeChange('custom')}
|
||||
className="flex items-center space-x-3 p-3"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom Theme</div>
|
||||
<div className="text-xs text-muted-foreground">Create your own theme</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
323
src/components/diff-viewer/UnifiedView.tsx
Normal file
323
src/components/diff-viewer/UnifiedView.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
// components/diff-viewer/UnifiedView.tsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Minus,
|
||||
Copy,
|
||||
Download,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
||||
|
||||
interface UnifiedViewProps {
|
||||
files: DiffFile[];
|
||||
selectedFile: DiffFile | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
theme: string;
|
||||
preferences: DiffPreferences;
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged' | 'context';
|
||||
content: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
const UnifiedView: React.FC<UnifiedViewProps> = ({
|
||||
files,
|
||||
selectedFile,
|
||||
onFileSelect,
|
||||
theme,
|
||||
preferences
|
||||
}) => {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Parse diff content into structured format
|
||||
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
||||
if (!diffContent) return [];
|
||||
|
||||
const lines = diffContent.split('\n');
|
||||
const diffLines: DiffLine[] = [];
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header
|
||||
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
||||
if (match) {
|
||||
lineNumber = parseInt(match[3]) - 1;
|
||||
}
|
||||
diffLines.push({ type: 'context', content: line });
|
||||
} else if (line.startsWith('+')) {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'added',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
diffLines.push({
|
||||
type: 'removed',
|
||||
content: line.substring(1),
|
||||
lineNumber: undefined
|
||||
});
|
||||
} else {
|
||||
lineNumber++;
|
||||
diffLines.push({
|
||||
type: 'unchanged',
|
||||
content: line.substring(1),
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
// Group diff lines into hunks
|
||||
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
||||
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
||||
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === 'context' && line.content.startsWith('@@')) {
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
currentHunk = { header: line.content, lines: [] };
|
||||
} else if (currentHunk) {
|
||||
currentHunk.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
const diffLines = useMemo(() => {
|
||||
if (!selectedFile?.diff_content) return [];
|
||||
return parseDiffContent(selectedFile.diff_content);
|
||||
}, [selectedFile]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
return groupIntoHunks(diffLines);
|
||||
}, [diffLines]);
|
||||
|
||||
const toggleHunk = (hunkIndex: number) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
setExpandedHunks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(hunkId)) {
|
||||
newSet.delete(hunkId);
|
||||
} else {
|
||||
newSet.add(hunkId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const downloadDiff = () => {
|
||||
if (!selectedFile?.diff_content) return;
|
||||
|
||||
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedFile.file_path}.diff`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getLineClass = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
||||
case 'unchanged':
|
||||
return 'bg-gray-50 dark:bg-gray-800/50';
|
||||
case 'context':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLineIcon = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return <Plus className="h-3 w-3 text-green-600" />;
|
||||
case 'removed':
|
||||
return <Minus className="h-3 w-3 text-red-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLinePrefix = (type: DiffLine['type']) => {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return '+';
|
||||
case 'removed':
|
||||
return '-';
|
||||
case 'unchanged':
|
||||
return ' ';
|
||||
case 'context':
|
||||
return '@';
|
||||
default:
|
||||
return ' ';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File tabs */}
|
||||
<div className="border-b">
|
||||
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
||||
<TabsList className="w-full justify-start">
|
||||
{files.map((file) => (
|
||||
<TabsTrigger
|
||||
key={file.file_path}
|
||||
value={file.file_path}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
||||
<Badge
|
||||
variant={
|
||||
file.change_type === 'added' ? 'default' :
|
||||
file.change_type === 'modified' ? 'secondary' :
|
||||
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
||||
}
|
||||
className="ml-1"
|
||||
>
|
||||
{file.change_type}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* File info and controls */}
|
||||
{selectedFile && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>{selectedFile.change_type}</span>
|
||||
{selectedFile.diff_size_bytes && (
|
||||
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadDiff}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-hidden h-[500px]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="font-mono text-sm">
|
||||
{hunks.map((hunk, hunkIndex) => {
|
||||
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
||||
const isExpanded = expandedHunks.has(hunkId);
|
||||
|
||||
return (
|
||||
<div key={hunkIndex} className="border-b last:border-b-0">
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
||||
onClick={() => toggleHunk(hunkIndex)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm">{hunk.header}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
||||
>
|
||||
<div className="w-8 text-right text-xs text-muted-foreground">
|
||||
{line.lineNumber || ''}
|
||||
</div>
|
||||
<div className="w-4 flex justify-center">
|
||||
{getLineIcon(line.type)}
|
||||
</div>
|
||||
<div className="w-4 text-center font-mono text-xs">
|
||||
{getLinePrefix(line.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<code className="text-sm">{line.content}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedView;
|
||||
@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette } from "lucide-react"
|
||||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette, GitBranch } from "lucide-react"
|
||||
import { useTemplates } from "@/hooks/useTemplates"
|
||||
import { CustomTemplateForm } from "@/components/custom-template-form"
|
||||
import { EditTemplateForm } from "@/components/edit-template-form"
|
||||
@ -23,7 +23,7 @@ import PromptSidePanel from "@/components/prompt-side-panel"
|
||||
import { DualCanvasEditor } from "@/components/dual-canvas-editor"
|
||||
import { getAccessToken } from "@/components/apis/authApiClients"
|
||||
import TechStackSummary from "@/components/tech-stack-summary"
|
||||
import { attachRepository, getGitHubAuthStatus } from "@/lib/api/github"
|
||||
import { attachRepository, getGitHubAuthStatus, AttachRepositoryResponse, connectGitHubWithRepo, initiateGitHubOAuth } from "@/lib/api/github"
|
||||
import ViewUserReposButton from "@/components/github/ViewUserReposButton"
|
||||
import { ErrorBanner } from "@/components/ui/error-banner"
|
||||
|
||||
@ -109,14 +109,28 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
||||
}))
|
||||
} catch {}
|
||||
|
||||
const result = await attachRepository({
|
||||
const result: AttachRepositoryResponse = await attachRepository({
|
||||
repository_url: gitUrl.trim(),
|
||||
branch_name: (gitBranch?.trim() || undefined),
|
||||
})
|
||||
|
||||
// If we reach here without 401, repo is public and attached successfully
|
||||
|
||||
// Debug logging
|
||||
console.log('📦 Full result object:', result)
|
||||
console.log('📦 result.success value:', result?.success)
|
||||
console.log('📦 result.success type:', typeof result?.success)
|
||||
console.log('📦 Strict equality check:', result?.success === true)
|
||||
|
||||
// Check if response is successful
|
||||
if (result?.success !== true) {
|
||||
console.error('❌ Response indicates failure:', result)
|
||||
throw new Error('Repository attachment failed')
|
||||
}
|
||||
|
||||
const isPrivate = result?.data?.is_public === false || result?.data?.requires_auth === true
|
||||
|
||||
console.log('✅ Repository attached successfully:', result)
|
||||
alert('Repository attached successfully! You can now proceed with your project.')
|
||||
const repoType = isPrivate ? 'private' : 'public'
|
||||
alert(`Repository attached successfully! (${repoType}) You can now proceed with your project.`)
|
||||
setShowCreateOptionDialog(false)
|
||||
setShowGitForm(false)
|
||||
setGitProvider('')
|
||||
@ -131,30 +145,57 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data) } catch {}
|
||||
}
|
||||
|
||||
console.log('🔍 Attach repository response:', { status, data })
|
||||
|
||||
|
||||
console.log('❌ Error attaching repository:', {
|
||||
status,
|
||||
data,
|
||||
message: err?.message,
|
||||
code: err?.code,
|
||||
url: gitUrl.trim()
|
||||
})
|
||||
|
||||
if (status === 401 && (data?.requires_auth || data?.auth_url || data?.service_auth_url)) {
|
||||
const url: string = data?.service_auth_url || data?.auth_url
|
||||
if (url) {
|
||||
console.log('🔐 Redirecting to GitHub OAuth:', url)
|
||||
window.location.replace(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
alert('Repository not accessible - you may not have permission to access this repository')
|
||||
console.log('🔐 Private repository detected - initiating GitHub OAuth with repository context')
|
||||
// Reset loading state before redirect
|
||||
setIsGeneratingAuth(false)
|
||||
|
||||
// Use the new OAuth helper that will auto-attach the repo after authentication
|
||||
setTimeout(() => {
|
||||
connectGitHubWithRepo(gitUrl.trim(), gitBranch?.trim() || 'main').catch((oauthError) => {
|
||||
console.error('OAuth initiation failed:', oauthError)
|
||||
alert('Failed to initiate GitHub authentication. Please try again.')
|
||||
})
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (status === 403) {
|
||||
// Reset loading state before showing dialog
|
||||
setIsGeneratingAuth(false)
|
||||
|
||||
// Repository not accessible with current GitHub account - prompt to re-authenticate
|
||||
setTimeout(() => {
|
||||
const confirmReauth = confirm('Repository not accessible with your current GitHub account.\n\nThis could mean:\n- The repository belongs to a different GitHub account\n- Your token expired or lacks permissions\n\nWould you like to re-authenticate with GitHub?')
|
||||
|
||||
if (confirmReauth) {
|
||||
console.log('🔐 Re-authenticating with GitHub for private repository access')
|
||||
connectGitHubWithRepo(gitUrl.trim(), gitBranch?.trim() || 'main').catch((oauthError) => {
|
||||
console.error('OAuth initiation failed:', oauthError)
|
||||
alert('Failed to initiate GitHub authentication. Please try again.')
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
alert('Repository not found - please check the URL and try again')
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Error generating auth URL via attach:', err)
|
||||
alert(data?.message || 'Failed to generate authentication URL. Please try again.')
|
||||
|
||||
console.error('❌ Full error details:', err)
|
||||
const errorMessage = data?.message || err?.message || 'Failed to attach repository. Please check the URL and try again.'
|
||||
alert(errorMessage)
|
||||
} finally {
|
||||
setIsGeneratingAuth(false)
|
||||
}
|
||||
@ -169,11 +210,15 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
||||
throw new Error('OAuth not supported for this provider')
|
||||
}
|
||||
|
||||
// Redirect to OAuth endpoint
|
||||
window.open(providerConfig.oauthEndpoint, '_blank', 'width=600,height=700')
|
||||
// For GitHub, use the new OAuth helper
|
||||
if (provider === 'github') {
|
||||
console.log('Initiating GitHub OAuth flow...')
|
||||
initiateGitHubOAuth()
|
||||
return
|
||||
}
|
||||
|
||||
// In a real implementation, you'd handle the OAuth callback
|
||||
// and store the access token
|
||||
// For other providers, use the old method
|
||||
window.open(providerConfig.oauthEndpoint, '_blank', 'width=600,height=700')
|
||||
alert(`Redirecting to ${providerConfig.name} OAuth...`)
|
||||
} catch (error) {
|
||||
console.error('OAuth error:', error)
|
||||
@ -816,7 +861,7 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
||||
</div>
|
||||
|
||||
{/* Right-aligned quick navigation to user repos */}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<ViewUserReposButton className="bg-orange-500 hover:bg-orange-400 text-black" label="My GitHub Repos" />
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,257 +1,201 @@
|
||||
// components/ui/dropdown-menu.tsx
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
// components/ui/progress.tsx
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
@ -5,27 +6,24 @@ import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
export { Progress }
|
||||
@ -1,3 +1,4 @@
|
||||
// components/ui/slider.tsx
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
@ -25,4 +26,4 @@ const Slider = React.forwardRef<
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
export { Slider }
|
||||
@ -1,31 +1,30 @@
|
||||
// components/ui/switch.tsx
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
export { Switch }
|
||||
@ -1,6 +1,6 @@
|
||||
|
||||
//
|
||||
export const BACKEND_URL = 'https://backend.codenuk.com';
|
||||
export const BACKEND_URL = 'http://localhost:8000';
|
||||
|
||||
export const SOCKET_URL = BACKEND_URL;
|
||||
|
||||
|
||||
@ -126,23 +126,74 @@ export async function resolveRepositoryPath(repositoryId: string, filePath: stri
|
||||
export interface AttachRepositoryResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
data?: T & {
|
||||
is_public?: boolean
|
||||
requires_auth?: boolean
|
||||
repository_name?: string
|
||||
owner_name?: string
|
||||
}
|
||||
requires_auth?: boolean
|
||||
auth_url?: string
|
||||
auth_error?: boolean
|
||||
}
|
||||
|
||||
export async function attachRepository(payload: AttachRepositoryPayload): Promise<AttachRepositoryResponse> {
|
||||
export async function attachRepository(payload: AttachRepositoryPayload, retries = 3): Promise<AttachRepositoryResponse> {
|
||||
// Add user_id as query fallback besides header for gateway caching/proxies
|
||||
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||
const url = userId ? `/api/github/attach-repository?user_id=${encodeURIComponent(userId)}` : '/api/github/attach-repository'
|
||||
const response = await authApiClient.post(url, { ...payload, user_id: userId || payload.user_id }, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
return response.data as AttachRepositoryResponse
|
||||
|
||||
// Retry logic for connection issues
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
// Use authApiClient but with extended timeout for repository operations
|
||||
const response = await authApiClient.post(url, { ...payload, user_id: userId || payload.user_id }, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 60000, // 60 seconds for repository operations
|
||||
})
|
||||
|
||||
console.log('📡 [attachRepository] Raw axios response:', response)
|
||||
console.log('📡 [attachRepository] response.data:', response.data)
|
||||
console.log('📡 [attachRepository] response.data type:', typeof response.data)
|
||||
|
||||
// Normalize response: API gateway may stringify JSON bodies or booleans
|
||||
let parsed: any = response.data
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed)
|
||||
console.log('📡 [attachRepository] Parsed string response to JSON')
|
||||
} catch (e) {
|
||||
console.warn('📡 [attachRepository] Failed to parse string response, returning as-is')
|
||||
}
|
||||
}
|
||||
|
||||
// Coerce success to a real boolean if it comes back as a string
|
||||
const normalized: AttachRepositoryResponse = {
|
||||
...(parsed || {}),
|
||||
success: (parsed?.success === true || parsed?.success === 'true')
|
||||
} as AttachRepositoryResponse
|
||||
|
||||
console.log('📡 [attachRepository] Returning normalized result:', normalized)
|
||||
console.log('📡 [attachRepository] normalized.success:', normalized?.success)
|
||||
|
||||
return normalized
|
||||
} catch (error: any) {
|
||||
// If it's the last retry or not a connection error, throw immediately
|
||||
if (i === retries - 1 || (error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET')) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
const waitTime = Math.min(1000 * Math.pow(2, i), 5000)
|
||||
console.log(`⚠️ Connection failed, retrying in ${waitTime}ms... (attempt ${i + 1}/${retries})`)
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime))
|
||||
}
|
||||
}
|
||||
|
||||
// This should never be reached, but TypeScript needs it
|
||||
throw new Error('Failed to attach repository after retries')
|
||||
}
|
||||
|
||||
export interface GitHubAuthStatusData {
|
||||
@ -193,28 +244,72 @@ export interface GitHubRepoSummary {
|
||||
}
|
||||
|
||||
// Tries backend gateway route first. If backend does not yet provide it, returns an empty list gracefully.
|
||||
export async function getUserRepositories(): Promise<GitHubRepoSummary[]> {
|
||||
export async function getUserRepositories(clearCache = false): Promise<GitHubRepoSummary[]> {
|
||||
try {
|
||||
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||
// Prefer path param route; fallback to legacy query-based if gateway not updated
|
||||
const primaryUrl = userId ? `/api/github/user/${encodeURIComponent(userId)}/repositories` : '/api/github/user/repositories'
|
||||
let res
|
||||
try {
|
||||
res = await authApiClient.get(primaryUrl)
|
||||
} catch (e: any) {
|
||||
const fallbackUrl = userId ? `/api/github/user/repos?user_id=${encodeURIComponent(userId)}` : '/api/github/user/repos'
|
||||
res = await authApiClient.get(fallbackUrl)
|
||||
|
||||
// Clear cache if requested
|
||||
if (clearCache && typeof window !== 'undefined') {
|
||||
try {
|
||||
const cacheKey = `user_repos_cache_${userId || 'anon'}`
|
||||
sessionStorage.removeItem(cacheKey)
|
||||
console.log('🧹 Cleared GitHub repository cache')
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear cache:', e)
|
||||
}
|
||||
}
|
||||
const buildUrl = (base: string) => {
|
||||
const ts = Date.now()
|
||||
const sep = base.includes('?') ? '&' : '?'
|
||||
return `${base}${sep}nc=${ts}`
|
||||
}
|
||||
const data = res?.data?.data || res?.data
|
||||
if (Array.isArray(data)) {
|
||||
// If data already looks like GitHubRepoSummary, return as-is
|
||||
if (data.length === 0) return []
|
||||
const looksLike = (item: any) => item && (item.full_name || (item.name && item.owner))
|
||||
if (looksLike(data[0])) return data as GitHubRepoSummary[]
|
||||
|
||||
// Normalize rows coming from github_repositories table with parsed metadata/codebase_analysis
|
||||
const primaryBase = userId ? `/api/github/user/${encodeURIComponent(userId)}/repositories` : '/api/github/user/repositories'
|
||||
let res: any = await authApiClient.get(buildUrl(primaryBase), {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Accept': 'application/json' },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
// On 304 or empty body, retry once with a different cache-buster and legacy fallback
|
||||
if (res?.status === 304 || res?.data == null || res?.data === '') {
|
||||
try {
|
||||
const fallbackBase = userId ? `/api/github/user/repos?user_id=${encodeURIComponent(userId)}` : '/api/github/user/repos'
|
||||
res = await authApiClient.get(buildUrl(fallbackBase), {
|
||||
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Accept': 'application/json' },
|
||||
validateStatus: () => true,
|
||||
})
|
||||
} catch { /* ignore and handle below */ }
|
||||
}
|
||||
|
||||
// Parse response body if it is a JSON string (gateway may return text)
|
||||
let body: any = res?.data
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
body = JSON.parse(body)
|
||||
console.log('📡 [getUserRepositories] Parsed string response to JSON')
|
||||
} catch (e) {
|
||||
console.warn('📡 [getUserRepositories] Failed to parse string response, returning as-is')
|
||||
}
|
||||
}
|
||||
|
||||
let data = body?.data || body
|
||||
|
||||
// Session cache fallback if still empty
|
||||
if ((!Array.isArray(data) || data.length === 0) && typeof window !== 'undefined') {
|
||||
try {
|
||||
const cacheKey = `user_repos_cache_${userId || 'anon'}`
|
||||
const cached = sessionStorage.getItem(cacheKey)
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached)
|
||||
if (Array.isArray(parsed)) return parsed as GitHubRepoSummary[]
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const normalized = data.map((r: any) => {
|
||||
if (r && (r.full_name || (r.name && r.owner))) return r
|
||||
const md = r?.metadata || {}
|
||||
const owner = r?.owner_name || md?.owner?.login || (typeof md?.full_name === 'string' ? md.full_name.split('/')[0] : undefined)
|
||||
const name = r?.repository_name || md?.name || (typeof md?.full_name === 'string' ? md.full_name.split('/')[1] : undefined) || r?.repo
|
||||
@ -233,14 +328,164 @@ export async function getUserRepositories(): Promise<GitHubRepoSummary[]> {
|
||||
html_url: md?.html_url || (full ? `https://github.com/${full}` : undefined),
|
||||
} as GitHubRepoSummary
|
||||
})
|
||||
try { if (typeof window !== 'undefined') sessionStorage.setItem(`user_repos_cache_${userId || 'anon'}`, JSON.stringify(normalized)) } catch {}
|
||||
return normalized
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (e: any) {
|
||||
// If endpoint not found or unauthorized, surface as empty for now (UI design requirement)
|
||||
const status = e?.response?.status
|
||||
if (status === 404 || status === 401) return []
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all GitHub-related cache from session and local storage
|
||||
*/
|
||||
export function clearGitHubCache(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
// Clear session storage cache
|
||||
const sessionKeys = Object.keys(sessionStorage)
|
||||
const githubSessionKeys = sessionKeys.filter(key => key.startsWith('user_repos_cache_'))
|
||||
githubSessionKeys.forEach(key => sessionStorage.removeItem(key))
|
||||
|
||||
// Clear localStorage GitHub-related data
|
||||
const localKeys = Object.keys(localStorage)
|
||||
const githubLocalKeys = localKeys.filter(key =>
|
||||
key.includes('github') || key.includes('repo') || key.includes('codenuk_user')
|
||||
)
|
||||
githubLocalKeys.forEach(key => localStorage.removeItem(key))
|
||||
|
||||
console.log('🧹 Cleared all GitHub cache')
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear GitHub cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates GitHub OAuth flow
|
||||
* This will redirect the user to GitHub for authentication
|
||||
* After successful authentication, GitHub will redirect back to /project-builder
|
||||
*/
|
||||
export async function initiateGitHubOAuth(): Promise<void> {
|
||||
try {
|
||||
// Get user_id from localStorage
|
||||
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||
|
||||
if (!userId) {
|
||||
console.error('Cannot initiate GitHub OAuth: user_id not found')
|
||||
alert('Please sign in first to connect your GitHub account')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate state for OAuth security
|
||||
const state = Math.random().toString(36).substring(7)
|
||||
|
||||
console.log('Initiating GitHub OAuth for user:', userId)
|
||||
|
||||
// Get the OAuth URL from the backend (without redirect)
|
||||
// Then manually redirect the browser to avoid API gateway interference
|
||||
try {
|
||||
const response = await authApiClient.get(`/api/github/auth/github?user_id=${encodeURIComponent(userId)}&state=${state}`)
|
||||
|
||||
// Handle both normal JSON and double-encoded JSON responses
|
||||
let responseData = response.data
|
||||
if (typeof responseData === 'string') {
|
||||
try {
|
||||
responseData = JSON.parse(responseData)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse response data:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const authUrl = responseData?.data?.auth_url
|
||||
|
||||
if (authUrl) {
|
||||
console.log('Redirecting to GitHub OAuth:', authUrl)
|
||||
window.location.href = authUrl
|
||||
} else {
|
||||
console.error('No auth URL in response:', responseData)
|
||||
throw new Error('No auth URL received from backend')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get OAuth URL:', error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initiating GitHub OAuth:', error)
|
||||
alert('Failed to initiate GitHub authentication. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects GitHub account with optional repository context
|
||||
* If repository details are provided, the repository will be auto-attached after OAuth
|
||||
*/
|
||||
export async function connectGitHubWithRepo(repositoryUrl?: string, branchName?: string): Promise<void> {
|
||||
try {
|
||||
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||
|
||||
if (!userId) {
|
||||
console.error('Cannot initiate GitHub OAuth: user_id not found')
|
||||
alert('Please sign in first to connect your GitHub account')
|
||||
return
|
||||
}
|
||||
|
||||
// Build state with repository context if provided
|
||||
const stateBase = Math.random().toString(36).substring(7)
|
||||
let state = stateBase
|
||||
|
||||
if (repositoryUrl) {
|
||||
const encodedRepoUrl = encodeURIComponent(repositoryUrl)
|
||||
const encodedBranch = encodeURIComponent(branchName || 'main')
|
||||
state = `${stateBase}|uid=${userId}|repo=${encodedRepoUrl}|branch=${encodedBranch}`
|
||||
|
||||
// Store in sessionStorage for recovery
|
||||
try {
|
||||
sessionStorage.setItem('pending_git_attach', JSON.stringify({
|
||||
repository_url: repositoryUrl,
|
||||
branch_name: branchName || 'main'
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('Failed to store pending attach in sessionStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Connecting GitHub account for user:', userId, repositoryUrl ? `with repo: ${repositoryUrl}` : '')
|
||||
|
||||
// Get the OAuth URL from the backend (without redirect parameter)
|
||||
// Then manually redirect the browser to avoid API gateway interference
|
||||
try {
|
||||
const response = await authApiClient.get(`/api/github/auth/github?user_id=${encodeURIComponent(userId)}&state=${encodeURIComponent(state)}`)
|
||||
|
||||
// Handle both normal JSON and double-encoded JSON responses
|
||||
let responseData = response.data
|
||||
if (typeof responseData === 'string') {
|
||||
try {
|
||||
responseData = JSON.parse(responseData)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse response data:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const authUrl = responseData?.data?.auth_url
|
||||
|
||||
if (authUrl) {
|
||||
console.log('Redirecting to GitHub OAuth with repository context:', authUrl)
|
||||
window.location.href = authUrl
|
||||
} else {
|
||||
console.error('No auth URL in response:', responseData)
|
||||
throw new Error('No auth URL received from backend')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get OAuth URL:', error)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting GitHub:', error)
|
||||
alert('Failed to connect GitHub account. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user