From 117f22e67cfe1015af32a6c70b4280f5528a5155 Mon Sep 17 00:00:00 2001 From: Kenil Date: Fri, 10 Oct 2025 08:40:18 +0530 Subject: [PATCH] Initial commit for frontend --- next.config.ts | 1 - package-lock.json | 9 +- package.json | 2 +- src/app/api/ai/tech-recommendations/route.ts | 2 +- src/app/api/diffs/[...path]/route.ts | 78 ++++ src/app/api/diffs/repositories/route.ts | 32 ++ src/app/diff-viewer/page.tsx | 324 ++++++++++++++ src/app/github/repo/repo-client.tsx | 173 ++++++-- src/app/github/repos/page.tsx | 419 +++++++++++------- src/app/project-builder/page.tsx | 19 +- src/components/diff-viewer/DiffControls.tsx | 219 +++++++++ src/components/diff-viewer/DiffStats.tsx | 186 ++++++++ src/components/diff-viewer/DiffViewer.tsx | 249 +++++++++++ .../diff-viewer/DiffViewerContext.tsx | 256 +++++++++++ src/components/diff-viewer/SideBySideView.tsx | 368 +++++++++++++++ src/components/diff-viewer/ThemeSelector.tsx | 134 ++++++ src/components/diff-viewer/UnifiedView.tsx | 323 ++++++++++++++ src/components/main-dashboard.tsx | 101 +++-- src/components/ui/dropdown-menu.tsx | 374 +++++++--------- src/components/ui/progress.tsx | 44 +- src/components/ui/slider.tsx | 3 +- src/components/ui/switch.tsx | 43 +- src/config/backend.ts | 2 +- src/lib/api/github.ts | 299 +++++++++++-- 24 files changed, 3113 insertions(+), 547 deletions(-) create mode 100644 src/app/api/diffs/[...path]/route.ts create mode 100644 src/app/api/diffs/repositories/route.ts create mode 100644 src/app/diff-viewer/page.tsx create mode 100644 src/components/diff-viewer/DiffControls.tsx create mode 100644 src/components/diff-viewer/DiffStats.tsx create mode 100644 src/components/diff-viewer/DiffViewer.tsx create mode 100644 src/components/diff-viewer/DiffViewerContext.tsx create mode 100644 src/components/diff-viewer/SideBySideView.tsx create mode 100644 src/components/diff-viewer/ThemeSelector.tsx create mode 100644 src/components/diff-viewer/UnifiedView.tsx diff --git a/next.config.ts b/next.config.ts index 437d8b4..8e1ffc7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 }) => { diff --git a/package-lock.json b/package-lock.json index 321aba5..ddc6691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 237f97e..bd275b0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/ai/tech-recommendations/route.ts b/src/app/api/ai/tech-recommendations/route.ts index 283d4da..4ca041c 100644 --- a/src/app/api/ai/tech-recommendations/route.ts +++ b/src/app/api/ai/tech-recommendations/route.ts @@ -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', diff --git a/src/app/api/diffs/[...path]/route.ts b/src/app/api/diffs/[...path]/route.ts new file mode 100644 index 0000000..593f669 --- /dev/null +++ b/src/app/api/diffs/[...path]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/diffs/repositories/route.ts b/src/app/api/diffs/repositories/route.ts new file mode 100644 index 0000000..2f43c8f --- /dev/null +++ b/src/app/api/diffs/repositories/route.ts @@ -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 } + ); + } +} diff --git a/src/app/diff-viewer/page.tsx b/src/app/diff-viewer/page.tsx new file mode 100644 index 0000000..693b86a --- /dev/null +++ b/src/app/diff-viewer/page.tsx @@ -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([]); + const [commits, setCommits] = useState([]); + const [selectedRepository, setSelectedRepository] = useState(''); + const [selectedCommit, setSelectedCommit] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+

Git Diff Viewer

+

+ View and analyze git diffs from your repositories +

+
+ +
+ + {/* Repository and Commit Selection */} + + + + + Select Repository & Commit + + + + {/* Repository Selection */} +
+ + +
+ + {/* Commit Selection */} + {selectedRepository && ( +
+
+ + + {commits.length} commits + +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + +
+ )} + + {/* Commit Info */} + {selectedCommit && ( +
+
+ + Selected Commit +
+ {(() => { + const commit = commits.find(c => c.id === selectedCommit); + return commit ? ( +
+
+ + {commit.commit_sha.substring(0, 8)} + + by {commit.author_name} +
+

{commit.message}

+
+ {commit.files_changed} files changed + {commit.diffs_processed} diffs processed + {(commit.total_diff_size / 1024).toFixed(1)} KB +
+
+ ) : null; + })()} +
+ )} +
+
+ + {/* Error Display */} + {error && ( + + +
+

Error

+

{error}

+ +
+
+
+ )} + + {/* Diff Viewer */} + {selectedRepository && selectedCommit && ( + + )} + + {/* No Selection State */} + {!selectedRepository && ( + + +
+ +

No Repository Selected

+

+ Please select a repository to view its diffs +

+
+
+
+ )} + + {selectedRepository && !selectedCommit && ( + + +
+ +

No Commit Selected

+

+ Please select a commit to view its diffs +

+
+
+
+ )} +
+ ); +}; + +export default DiffViewerPage; diff --git a/src/app/github/repo/repo-client.tsx b/src/app/github/repo/repo-client.tsx index 366b885..3e8032f 100644 --- a/src/app/github/repo/repo-client.tsx +++ b/src/app/github/repo/repo-client.tsx @@ -14,6 +14,9 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep const [entries, setEntries] = useState([]) const [fileQuery, setFileQuery] = useState("") const [readme, setReadme] = useState(null) + const [selectedFile, setSelectedFile] = useState(null) + const [fileContent, setFileContent] = useState(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 ( -
-
+
+
-

Repository #{repositoryId}

- Attached +
+

Repository #{repositoryId}

+
+
+ + Attached + +
-
- -
- - 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"/> +
+
+ + setFileQuery(e.target.value)} + className="pl-10 bg-white/5 border-white/10 text-white placeholder-white/40" + /> +
- -
- - - {loading && ( -
Loading...
- )} - {!loading && visible.length === 0 && ( -
No entries found.
- )} - {visible.map((e, i) => ( -
e.type === 'directory' ? navigateFolder(e.name) : void 0}> -
- {e.type === 'directory' ? : } +
+ {/* File Tree - Left Side */} +
+ + +
+ Files {path && `- ${path}`}
-
{e.name}
-
- -
-
- ))} - - + {loading && ( +
Loading...
+ )} + {!loading && visible.length === 0 && ( +
No entries found.
+ )} + {visible.map((e, i) => ( +
e.type === 'directory' ? navigateFolder(e.name) : handleFileClick(e.name)}> +
+ {e.type === 'directory' ? : } +
+
{e.name}
+
+ {e.size && `${Math.round(Number(e.size) / 1024)}KB`} +
+
+ ))} + + +
- - -
README
- {!readme ? ( -
- -

No README found

-

Add a README.md to the repository to show it here.

-
- ) : ( -
{readme}
- )} -
-
+ {/* File Content - Right Side */} +
+ + +
+ {selectedFile ? `File: ${selectedFile}` : 'README'} +
+
+ {selectedFile ? ( +
+ {fileLoading ? ( +
+
+ Loading file content... +
+ ) : fileContent ? ( +
{fileContent}
+ ) : ( +
+
+ +

File content not available

+

This file could not be loaded or is binary.

+
+
+ )} +
+ ) : !readme ? ( +
+
+ +

No README found

+

Add a README.md to the repository to show it here.

+
+
+ ) : ( +
+
{readme}
+
+ )} +
+
+
+
+
) -} +} \ No newline at end of file diff --git a/src/app/github/repos/page.tsx b/src/app/github/repos/page.tsx index b838930..eb0acd0 100644 --- a/src/app/github/repos/page.tsx +++ b/src/app/github/repos/page.tsx @@ -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(false) - const [authUrl, setAuthUrl] = useState(null) - const [repos, setRepos] = useState([]) - const [query, setQuery] = useState("") - const [analyzingRepo, setAnalyzingRepo] = useState(null) +const GitHubReposPage: React.FC = () => { + const [repositories, setRepositories] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( -
-
- - - -

My GitHub Repositories

+ +
-
-
- - 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" - /> -
- {!connected && ( - - )} -
+ {/* Search and Filter */} + + +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+
+ + + +
+
+
+
- {(authLoading || loading) && ( -
-
-
- )} - - {!authLoading && !connected && repos.length === 0 && ( - - - - - Connect your GitHub account - - - -

Connect GitHub to view your private repositories and enable one-click attach to projects.

- + {/* Error Display */} + {error && ( + + +
+

Error

+

{error}

+ +
)} - {!loading && filtered.length === 0 && ( -
-

No repositories found{query ? ' for your search.' : '.'}

+ {/* Loading State */} + {isLoading && ( + + +
+
+

Loading repositories...

+
+
+
+ )} + + {/* Repositories Grid */} + {!isLoading && ( +
+ {filteredRepositories.map((repo) => ( + + +
+
+ +
+ + {repo.name || 'Unknown Repository'} + +

+ {repo.full_name || 'Unknown Owner'} +

+
+
+ + {repo.visibility || 'unknown'} + +
+
+ + + {repo.description && ( +

+ {repo.description} +

+ )} + + {/* Repository Stats */} +
+ {repo.language && ( +
+ + {repo.language} +
+ )} +
+ + {repo.stargazers_count || 0} +
+
+ + {repo.forks_count || 0} +
+
+ + {/* Updated Date */} +
+ + Updated {formatDate(repo.updated_at)} +
+ + {/* Action Buttons */} +
+ +
+
+
+ ))}
)} -
- {filtered.map((repo) => ( -
-
-
-
- {repo.html_url ? ( - - {repo.full_name || repo.name} - - ) : ( - {repo.full_name || repo.name} - )} - - {repo.visibility === 'private' ? 'Private' : 'Public'} - -
-

- {repo.description || 'No description provided.'} -

-
- {repo.language && ( - {repo.language} - )} - {repo.stargazers_count ?? 0} - {repo.forks_count ?? 0} - {repo.updated_at && ( - Updated {new Date(repo.updated_at).toLocaleDateString()} - )} -
-
-
- - - - -
+ {/* Empty State */} + {!isLoading && filteredRepositories.length === 0 && !error && ( + + +
+ +

+ {searchQuery || filter !== 'all' ? 'No repositories found' : 'No repositories available'} +

+

+ {searchQuery || filter !== 'all' + ? 'Try adjusting your search or filter criteria' + : 'Make sure you have connected your GitHub account and have repositories' + } +

-
- ))} -
+ + + )}
- ) -} + ); +}; + +export default GitHubReposPage; diff --git a/src/app/project-builder/page.tsx b/src/app/project-builder/page.tsx index 8644437..919654a 100644 --- a/src/app/project-builder/page.tsx +++ b/src/app/project-builder/page.tsx @@ -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 diff --git a/src/components/diff-viewer/DiffControls.tsx b/src/components/diff-viewer/DiffControls.tsx new file mode 100644 index 0000000..587d80d --- /dev/null +++ b/src/components/diff-viewer/DiffControls.tsx @@ -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 = ({ + 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 ( +
+ {/* View selector */} +
+ +
+ + +
+
+ + {/* Statistics */} + {statistics && ( +
+ +
+
+ Files: + {statistics.total_files} +
+
+ Additions: + + +{statistics.total_additions} + +
+
+ Deletions: + + -{statistics.total_deletions} + +
+
+ Size: + + {(statistics.total_size_bytes / 1024).toFixed(1)} KB + +
+
+
+ )} + + {/* Display preferences */} +
+ +
+
+ + + handlePreferenceChange('showLineNumbers', checked) + } + /> +
+ +
+ + + handlePreferenceChange('showWhitespace', checked) + } + /> +
+ +
+ + + handlePreferenceChange('wrapLines', checked) + } + /> +
+
+
+ + {/* Font size */} +
+ +
+ + + +
+
+ + {/* Font family */} +
+ + +
+ + {/* Theme selector */} +
+ +
+ + + +
+
+
+ ); +}; + +export default DiffControls; diff --git a/src/components/diff-viewer/DiffStats.tsx b/src/components/diff-viewer/DiffStats.tsx new file mode 100644 index 0000000..c2d236c --- /dev/null +++ b/src/components/diff-viewer/DiffStats.tsx @@ -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 = ({ 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 ( + + + + + Diff Statistics + + + + {/* Overview stats */} +
+
+
+ + {statistics.total_files} +
+

Files Changed

+
+ +
+
+ + + +{statistics.total_additions} + +
+

Additions

+
+ +
+
+ + + -{statistics.total_deletions} + +
+

Deletions

+
+ +
+
+ + + {totalChanges} + +
+

Total Changes

+
+
+ + {/* Change distribution */} +
+

Change Distribution

+
+
+ Additions +
+ + + {additionPercentage.toFixed(1)}% + +
+
+
+ Deletions +
+ + + {deletionPercentage.toFixed(1)}% + +
+
+
+
+ + {/* File types breakdown */} +
+

File Types

+
+ {Object.entries(statistics.files_by_type).map(([type, count]) => ( + + {type}: {count} + + ))} +
+
+ + {/* Size information */} +
+

Size Information

+
+
+ Total Size: + + {(statistics.total_size_bytes / 1024).toFixed(1)} KB + +
+
+ Avg per File: + + {statistics.total_files > 0 + ? (statistics.total_size_bytes / statistics.total_files / 1024).toFixed(1) + : 0} KB + +
+
+
+ + {/* Net change indicator */} +
+
+ {statistics.total_additions > statistics.total_deletions ? ( + <> + + + Net Addition: +{statistics.total_additions - statistics.total_deletions} lines + + + ) : statistics.total_deletions > statistics.total_additions ? ( + <> + + + Net Deletion: -{statistics.total_deletions - statistics.total_additions} lines + + + ) : ( + <> + + + Balanced: {statistics.total_additions} additions, {statistics.total_deletions} deletions + + + )} +
+
+
+
+ ); +}; + +export default DiffStats; diff --git a/src/components/diff-viewer/DiffViewer.tsx b/src/components/diff-viewer/DiffViewer.tsx new file mode 100644 index 0000000..7e703ff --- /dev/null +++ b/src/components/diff-viewer/DiffViewer.tsx @@ -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 = ({ + repositoryId, + commitId, + initialView = 'side-by-side', + className = '' +}) => { + const [currentView, setCurrentView] = useState(initialView); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(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 ( +
+
+ +

No diff data available

+
+
+ ); + } + + const selectedFileData = selectedFile + ? files.find((f: any) => f.file_path === selectedFile) || null + : files[0] || null; + + switch (currentView) { + case 'side-by-side': + return ( + + ); + case 'unified': + return ( + + ); + default: + return null; + } + }; + + if (isLoading) { + return ( +
+
+
+

Loading diff data...

+
+
+ ); + } + + if (error) { + return ( + + +
+

Failed to load diff data

+

{error}

+ +
+
+
+ ); + } + + return ( +
+ {/* Header with commit info and controls */} + + +
+
+ +
+ + {commit?.message || 'Diff Viewer'} + +
+ + {commit?.author_name} + + + {commit?.committed_at ? new Date(commit.committed_at).toLocaleString() : ''} + +
+
+
+ +
+ {/* File type badges */} + {statistics && ( +
+ {statistics.files_by_type.added > 0 && ( + + +{statistics.files_by_type.added} added + + )} + {statistics.files_by_type.modified > 0 && ( + + {statistics.files_by_type.modified} modified + + )} + {statistics.files_by_type.deleted > 0 && ( + + -{statistics.files_by_type.deleted} deleted + + )} + {statistics.files_by_type.renamed > 0 && ( + + {statistics.files_by_type.renamed} renamed + + )} +
+ )} + +
+
+
+
+ + + {/* Main diff content */} + + + +
+ + + + Side-by-Side + + + + Unified + + +
+ +
+ {renderView()} +
+
+
+
+
+ ); +}; + +// Wrapper component with context provider +const DiffViewerWithProvider: React.FC = (props) => { + return ( + + + + ); +}; + +export default DiffViewerWithProvider; diff --git a/src/components/diff-viewer/DiffViewerContext.tsx b/src/components/diff-viewer/DiffViewerContext.tsx new file mode 100644 index 0000000..8c7dc16 --- /dev/null +++ b/src/components/diff-viewer/DiffViewerContext.tsx @@ -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; + loadCommitDiffs: (commitId: string) => Promise; + loadRepositoryCommits: (repositoryId: string) => Promise; + 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 ( + + {children} + + ); +}; + +// 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 + }; +}; diff --git a/src/components/diff-viewer/SideBySideView.tsx b/src/components/diff-viewer/SideBySideView.tsx new file mode 100644 index 0000000..5e64bc0 --- /dev/null +++ b/src/components/diff-viewer/SideBySideView.tsx @@ -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 = ({ + files, + selectedFile, + onFileSelect, + theme, + preferences +}) => { + const [expandedHunks, setExpandedHunks] = useState>(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 ; + case 'removed': + return ; + default: + return null; + } + }; + + return ( +
+ {/* File tabs */} +
+ + + {files.map((file) => ( + + + {file.file_path.split('/').pop()} + + {file.change_type} + + + ))} + + +
+ + {/* File info and controls */} + {selectedFile && ( +
+
+
+
+

{selectedFile.file_path}

+
+ {selectedFile.change_type} + {selectedFile.diff_size_bytes && ( + โ€ข {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB + )} +
+
+
+ +
+ + +
+
+
+ )} + + {/* Diff content */} +
+
+ {/* Old version */} +
+
+
+ + Old Version +
+
+ +
+ {hunks.map((hunk, hunkIndex) => { + const hunkId = `${selectedFile?.file_path}-${hunkIndex}`; + const isExpanded = expandedHunks.has(hunkId); + + return ( +
+
toggleHunk(hunkIndex)} + > +
+ {hunk.header} + +
+
+ + {isExpanded && ( +
+ {hunk.lines.map((line, lineIndex) => ( +
+
+ {line.oldLineNumber || ''} +
+
+ {getLineIcon(line.type)} +
+
+ {line.content} +
+
+ ))} +
+ )} +
+ ); + })} +
+
+
+ + {/* New version */} +
+
+
+ + New Version +
+
+ +
+ {hunks.map((hunk, hunkIndex) => { + const hunkId = `${selectedFile?.file_path}-${hunkIndex}`; + const isExpanded = expandedHunks.has(hunkId); + + return ( +
+
toggleHunk(hunkIndex)} + > +
+ {hunk.header} + +
+
+ + {isExpanded && ( +
+ {hunk.lines.map((line, lineIndex) => ( +
+
+ {line.newLineNumber || ''} +
+
+ {getLineIcon(line.type)} +
+
+ {line.content} +
+
+ ))} +
+ )} +
+ ); + })} +
+
+
+
+
+
+ ); +}; + +export default SideBySideView; diff --git a/src/components/diff-viewer/ThemeSelector.tsx b/src/components/diff-viewer/ThemeSelector.tsx new file mode 100644 index 0000000..f49aa03 --- /dev/null +++ b/src/components/diff-viewer/ThemeSelector.tsx @@ -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 ( + + + + + + Choose Theme + + + {themes.map((theme) => ( + handleThemeChange(theme.id)} + className="flex items-center space-x-3 p-3" + > + +
+
{theme.name}
+
{theme.description}
+
+ {state.preferences.theme === theme.id && ( + + )} +
+ ))} + + + + handleThemeChange('custom')} + className="flex items-center space-x-3 p-3" + > + +
+
Custom Theme
+
Create your own theme
+
+
+
+
+ ); +}; + +export default ThemeSelector; diff --git a/src/components/diff-viewer/UnifiedView.tsx b/src/components/diff-viewer/UnifiedView.tsx new file mode 100644 index 0000000..c2ed4ad --- /dev/null +++ b/src/components/diff-viewer/UnifiedView.tsx @@ -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 = ({ + files, + selectedFile, + onFileSelect, + theme, + preferences +}) => { + const [expandedHunks, setExpandedHunks] = useState>(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 ; + case 'removed': + return ; + 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 ( +
+ {/* File tabs */} +
+ + + {files.map((file) => ( + + + {file.file_path.split('/').pop()} + + {file.change_type} + + + ))} + + +
+ + {/* File info and controls */} + {selectedFile && ( +
+
+
+
+

{selectedFile.file_path}

+
+ {selectedFile.change_type} + {selectedFile.diff_size_bytes && ( + โ€ข {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB + )} +
+
+
+ +
+ + +
+
+
+ )} + + {/* Diff content */} +
+ +
+ {hunks.map((hunk, hunkIndex) => { + const hunkId = `${selectedFile?.file_path}-${hunkIndex}`; + const isExpanded = expandedHunks.has(hunkId); + + return ( +
+
toggleHunk(hunkIndex)} + > +
+ {hunk.header} +
+ +
+
+
+ + {isExpanded && ( +
+ {hunk.lines.map((line, lineIndex) => ( +
+
+ {line.lineNumber || ''} +
+
+ {getLineIcon(line.type)} +
+
+ {getLinePrefix(line.type)} +
+
+ {line.content} +
+
+ ))} +
+ )} +
+ ); + })} +
+
+
+
+ ); +}; + +export default UnifiedView; diff --git a/src/components/main-dashboard.tsx b/src/components/main-dashboard.tsx index 4ef307f..28fc369 100644 --- a/src/components/main-dashboard.tsx +++ b/src/components/main-dashboard.tsx @@ -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
{/* Right-aligned quick navigation to user repos */} -
+
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ec51e9c..70a7006 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -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) { - return -} +const DropdownMenu = DropdownMenuPrimitive.Root -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} +const DropdownMenuGroup = DropdownMenuPrimitive.Group -function DropdownMenuContent({ - className, - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} +const DropdownMenuPortal = DropdownMenuPrimitive.Portal -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} +const DropdownMenuSub = DropdownMenuPrimitive.Sub -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - , + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + - ) -} + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName -function DropdownMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - ) -} +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName -function DropdownMenuSeparator({ +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ className, ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { +}: React.HTMLAttributes) => { return ( - ) -} - -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - ) } +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" export { DropdownMenu, - DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, DropdownMenuSub, - DropdownMenuSubTrigger, DropdownMenuSubContent, -} + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} \ No newline at end of file diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index e7a416c..f8b3f4d 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -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) { - return ( - - - - ) -} +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName -export { Progress } +export { Progress } \ No newline at end of file diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index c31c2b3..c3c1dac 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -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 } \ No newline at end of file diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index 6a2b524..563ca13 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -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) { - return ( - , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + - - - ) -} + /> + +)) +Switch.displayName = SwitchPrimitives.Root.displayName -export { Switch } +export { Switch } \ No newline at end of file diff --git a/src/config/backend.ts b/src/config/backend.ts index f3113c3..9891df8 100644 --- a/src/config/backend.ts +++ b/src/config/backend.ts @@ -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; diff --git a/src/lib/api/github.ts b/src/lib/api/github.ts index e85423b..aec19e4 100644 --- a/src/lib/api/github.ts +++ b/src/lib/api/github.ts @@ -126,23 +126,74 @@ export async function resolveRepositoryPath(repositoryId: string, filePath: stri export interface AttachRepositoryResponse { 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 { +export async function attachRepository(payload: AttachRepositoryPayload, retries = 3): Promise { // 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 { +export async function getUserRepositories(clearCache = false): Promise { 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 { 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 { + 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 { + 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.') + } +}