diff --git a/backend/routes/repos.py b/backend/routes/repos.py index 4348282..7b60b05 100644 --- a/backend/routes/repos.py +++ b/backend/routes/repos.py @@ -140,12 +140,43 @@ async def add_repository( "analysis": analysis.to_dict(), "message": "Repository added successfully. Ready for indexing." } + except HTTPException: + raise # Re-raise HTTPExceptions (like REPO_TOO_LARGE) as-is except Exception as e: logger.error("Failed to add repository", error=str(e), user_id=user_id) capture_exception(e) raise HTTPException(status_code=400, detail=str(e)) +@router.delete("/{repo_id}") +async def delete_repository( + repo_id: str, + auth: AuthContext = Depends(require_auth) +): + """Delete a repository and all its indexed data.""" + user_id = auth.user_id + + if not user_id: + raise HTTPException(status_code=401, detail="User ID required") + + # Verify ownership + repo = get_repo_or_404(repo_id, user_id) + + try: + success = repo_manager.delete_repo(repo_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete repository") + + logger.info("Repository deleted", repo_id=repo_id, user_id=user_id) + return {"message": "Repository deleted successfully"} + except HTTPException: + raise + except Exception as e: + logger.error("Failed to delete repository", repo_id=repo_id, error=str(e)) + capture_exception(e) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/{repo_id}/index") async def index_repository( repo_id: str, diff --git a/backend/services/repo_manager.py b/backend/services/repo_manager.py index 7430968..292439d 100644 --- a/backend/services/repo_manager.py +++ b/backend/services/repo_manager.py @@ -156,3 +156,26 @@ def get_last_indexed_commit(self, repo_id: str) -> str: def update_last_commit(self, repo_id: str, commit_sha: str, function_count: int = 0): """Update last indexed commit""" self.db.update_last_indexed(repo_id, commit_sha, function_count) + + def delete_repo(self, repo_id: str) -> bool: + """Delete repository and clean up local files""" + import shutil + + repo = self.get_repo(repo_id) + if not repo: + return False + + # Clean up local clone first (before DB delete) + local_path = repo.get("local_path") + if local_path and Path(local_path).exists(): + try: + shutil.rmtree(local_path) + logger.info("Deleted local repo files", repo_id=repo_id, path=local_path) + except Exception as e: + logger.warning("Failed to delete local files", repo_id=repo_id, error=str(e)) + + # Delete from database (cascades to embeddings, dependencies, etc.) + self.db.delete_repository(repo_id) + + logger.info("Deleted repository", repo_id=repo_id) + return True diff --git a/backend/services/repo_validator.py b/backend/services/repo_validator.py index a115d85..2a32eaa 100644 --- a/backend/services/repo_validator.py +++ b/backend/services/repo_validator.py @@ -100,6 +100,7 @@ class RepoValidator: '.ruff_cache', 'egg-info', '.eggs', + 'repos', # Cloned repositories (CodeIntel internal) } # Average functions per file by language (rough estimates) @@ -245,8 +246,10 @@ def _find_code_files(self, repo_path: str) -> tuple[list[Path], Optional[str]]: if file_path.is_symlink(): continue - # Skip files in excluded directories - if any(skip_dir in file_path.parts for skip_dir in self.SKIP_DIRS): + # Skip files in excluded directories (use repo-relative path to avoid + # false positives when repo is stored in a folder named 'repos', etc.) + rel_parts = file_path.relative_to(repo_root).parts + if any(skip_dir in rel_parts for skip_dir in self.SKIP_DIRS): continue # Check extension diff --git a/frontend/src/components/UpgradeLimitModal.tsx b/frontend/src/components/UpgradeLimitModal.tsx new file mode 100644 index 0000000..30c052d --- /dev/null +++ b/frontend/src/components/UpgradeLimitModal.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Loader2, CheckCircle2, Rocket, AlertTriangle, Zap } from 'lucide-react' +import { Button } from '@/components/ui/button' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + +interface UpgradeLimitModalProps { + isOpen: boolean + onClose: () => void + errorMessage: string + repoName?: string +} + +export function UpgradeLimitModal({ isOpen, onClose, errorMessage, repoName }: UpgradeLimitModalProps) { + const [email, setEmail] = useState('') + const [sending, setSending] = useState(false) + const [sent, setSent] = useState(false) + const [error, setError] = useState('') + + const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!isValidEmail || sending) return + + setSending(true) + setError('') + + try { + const response = await fetch(`${API_URL}/api/v1/feedback/waitlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, plan: 'pro' }), + }) + if (!response.ok) throw new Error('Failed to submit') + setSent(true) + } catch { + setError('Something went wrong. Please try again.') + } finally { + setSending(false) + } + } + + const handleClose = () => { + if (!sending) { + onClose() + setEmail('') + setError('') + setSent(false) + } + } + + return ( + + {isOpen && ( + + e.stopPropagation()} + className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden" + > + {/* Header with warning */} +
+ + +
+
+ +
+
+

+ {repoName ? `Can't import ${repoName}` : 'Repository Limit Reached'} +

+

+ {errorMessage} +

+
+
+
+ + {/* Limit comparison */} +
+

Plan Comparison

+
+
+

Free (Current)

+

2,000 functions

+

500 files per repo

+
+
+

Pro

+

20,000 functions

+

5,000 files per repo

+
+
+
+ + {/* Waitlist form */} +
+ {sent ? ( + + +

You're on the list!

+

+ We'll notify you when Pro is available +

+ +
+ ) : ( + <> +

+ Join the Pro waitlist to unlock higher limits +

+
+
+ setEmail(e.target.value)} + placeholder="your@email.com" + className="flex-1 px-4 py-2.5 bg-muted border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent/50" + disabled={sending} + /> + +
+ {error &&

{error}

} +
+ +
+ +

+ Early members get 30% off for the first year +

+
+ + + + )} +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 4e346de..8385a78 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -26,12 +26,47 @@ import { StyleInsights } from '../StyleInsights' import { ImpactAnalyzer } from '../ImpactAnalyzer' import { DashboardStats } from './DashboardStats' import { IndexingProgressModal } from '../IndexingProgressModal' +import { UpgradeLimitModal } from '../UpgradeLimitModal' import type { Repository } from '../../types' import type { GitHubRepo } from '../../hooks/useGitHubRepos' import { API_URL } from '../../config/api' const MAX_FREE_REPOS = 3 +// Safe stringify that won't crash on circular refs +function safeStringify(obj: unknown, maxLen = 200): string { + try { + return JSON.stringify(obj).slice(0, maxLen) + } catch { + return String(obj).slice(0, maxLen) + } +} + +// Extract error message from API response (handles nested detail objects) +function extractErrorMessage(err: any, fallback: string): string { + // FastAPI wraps in detail, but handle both cases + const detail = err?.detail || err + + if (typeof detail === 'string') return detail + if (typeof detail?.message === 'string') return detail.message + if (typeof err?.message === 'string') return err.message + + // Last resort: stringify (but keep it short, safe from circular refs) + if (detail && typeof detail === 'object') { + const msg = detail.message || detail.error + if (msg) return String(msg) + return safeStringify(detail) + } + return fallback +} + +// Check if error is a limit/upgrade error (handles both wrapped and unwrapped) +function isUpgradeError(err: any): boolean { + const detail = err?.detail || err + const code = detail?.error || detail?.error_code + return ['REPO_TOO_LARGE', 'REPO_LIMIT_REACHED'].includes(code) +} + type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact' export function DashboardHome() { @@ -49,6 +84,14 @@ export function DashboardHome() { const [indexingRepoId, setIndexingRepoId] = useState(null) const [indexingRepoName, setIndexingRepoName] = useState('') const [showIndexingModal, setShowIndexingModal] = useState(false) + + // Upgrade prompt modal state + const [upgradeModal, setUpgradeModal] = useState<{ show: boolean; message: string; repoName?: string }>({ show: false, message: '' }) + + // Helper to show upgrade modal with context + const showUpgradeModal = (err: any, repoName?: string) => { + setUpgradeModal({ show: true, message: extractErrorMessage(err, 'Repository exceeds free tier limits'), repoName }) + } // Auto-open GitHub import modal if redirected from OAuth callback useEffect(() => { @@ -96,7 +139,11 @@ export function DashboardHome() { if (!response.ok) { const err = await response.json().catch(() => ({})) - throw new Error(err.detail || 'Failed to add repository') + if (isUpgradeError(err)) { + showUpgradeModal(err, name) + return + } + throw new Error(extractErrorMessage(err, 'Failed to add repository')) } const data = await response.json() @@ -110,7 +157,11 @@ export function DashboardHome() { if (!indexResponse.ok) { const err = await indexResponse.json().catch(() => ({})) - throw new Error(err.detail?.message || err.detail || 'Failed to start indexing') + if (isUpgradeError(err)) { + showUpgradeModal(err, name) + return + } + throw new Error(extractErrorMessage(err, 'Failed to start indexing')) } // Show indexing progress modal @@ -150,7 +201,11 @@ export function DashboardHome() { if (!response.ok) { const err = await response.json().catch(() => ({})) - throw new Error(err.detail || `Failed to add ${repo.name}`) + if (isUpgradeError(err)) { + showUpgradeModal(err, repo.name) + continue + } + throw new Error(extractErrorMessage(err, `Failed to add ${repo.name}`)) } const data = await response.json() @@ -164,7 +219,11 @@ export function DashboardHome() { if (!indexResponse.ok) { const err = await indexResponse.json().catch(() => ({})) - const errMsg = err.detail?.message || err.detail || 'Indexing failed to start' + if (isUpgradeError(err)) { + showUpgradeModal(err, repo.name) + continue + } + const errMsg = extractErrorMessage(err, 'Indexing failed to start') console.error(`Failed to start indexing for ${repo.name}:`, err) toast.warning(`${repo.name} added but indexing failed`, { description: errMsg @@ -439,6 +498,14 @@ export function DashboardHome() { maxSelectable={MAX_FREE_REPOS} currentRepoCount={repos.length} /> + + {/* Upgrade Limit Modal */} + setUpgradeModal({ show: false, message: '' })} + errorMessage={upgradeModal.message} + repoName={upgradeModal.repoName} + /> ) }