Initial commit for frontend

This commit is contained in:
Kenil 2025-10-10 08:40:18 +05:30
parent 99107c06c5
commit 117f22e67c
24 changed files with 3113 additions and 547 deletions

View File

@ -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
View File

@ -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": {

View File

@ -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",

View File

@ -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',

View 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 }
);
}
}

View 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 }
);
}
}

View 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;

View File

@ -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>
)
}
}

View File

@ -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;

View File

@ -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

View 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;

View 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;

View 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;

View 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
};
};

View 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;

View 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;

View 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;

View File

@ -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>

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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;

View File

@ -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.')
}
}