diff --git a/frontend/src/components/playground/IndexingProgress.tsx b/frontend/src/components/playground/IndexingProgress.tsx new file mode 100644 index 0000000..05e7e3d --- /dev/null +++ b/frontend/src/components/playground/IndexingProgress.tsx @@ -0,0 +1,136 @@ +/** + * IndexingProgress + * + * Displays real-time progress during repository indexing. + * Shows progress bar, file stats, and current file being processed. + */ + +import { cn } from '@/lib/utils'; +import { Progress } from '@/components/ui/progress'; + +export interface ProgressData { + percent: number; + filesProcessed: number; + filesTotal: number; + currentFile?: string; + functionsFound: number; +} + +interface IndexingProgressProps { + progress: ProgressData; + repoName?: string; + onCancel?: () => void; +} + +function AnimatedDots() { + return ( + + ); +} + +/** + * Estimate remaining time based on current progress. + * Returns null if not enough data to estimate. + */ +function estimateRemainingSeconds(percent: number, filesProcessed: number): number | null { + if (percent <= 0 || filesProcessed <= 0) return null; + + // Rough estimate: assume ~0.15s per file on average + const remainingFiles = Math.ceil((filesProcessed / percent) * (100 - percent)); + return Math.max(1, Math.ceil(remainingFiles * 0.15)); +} + +export function IndexingProgress({ progress, repoName, onCancel }: IndexingProgressProps) { + const { percent, filesProcessed, filesTotal, currentFile, functionsFound } = progress; + const estimatedRemaining = estimateRemainingSeconds(percent, filesProcessed); + + return ( +
+ {/* Header */} +
+
+
+ + + Indexing {repoName || 'repository'} + + +
+ + {percent}% + +
+
+ + {/* Progress bar */} +
+ +
+ + {/* Stats grid */} +
+
+
+
Files
+
+ {filesProcessed} / {filesTotal} +
+
+
+
Functions
+
+ {functionsFound.toLocaleString()} +
+
+
+
Remaining
+
+ {estimatedRemaining !== null ? `~${estimatedRemaining}s` : '—'} +
+
+
+
+ + {/* Current file */} + {currentFile && ( +
+
+ + + {currentFile} + +
+
+ )} + + {/* Cancel button */} + {onCancel && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/playground/RepoModeSelector.tsx b/frontend/src/components/playground/RepoModeSelector.tsx new file mode 100644 index 0000000..d5aba68 --- /dev/null +++ b/frontend/src/components/playground/RepoModeSelector.tsx @@ -0,0 +1,65 @@ +/** + * RepoModeSelector + * + * Tab toggle between Demo repos and User's custom repo. + * Used at the top of the Playground to switch input modes. + */ + +import { cn } from '@/lib/utils'; + +export type RepoMode = 'demo' | 'custom'; + +interface RepoModeSelectorProps { + mode: RepoMode; + onModeChange: (mode: RepoMode) => void; + disabled?: boolean; +} + +export function RepoModeSelector({ + mode, + onModeChange, + disabled = false +}: RepoModeSelectorProps) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/playground/RepoUrlInput.tsx b/frontend/src/components/playground/RepoUrlInput.tsx new file mode 100644 index 0000000..929d867 --- /dev/null +++ b/frontend/src/components/playground/RepoUrlInput.tsx @@ -0,0 +1,142 @@ +/** + * RepoUrlInput + * + * URL input field for GitHub repository URLs. + * Features debounced validation, GitHub icon, and clear button. + */ + +import { useState, useEffect } from 'react'; +import { cn } from '@/lib/utils'; + +interface RepoUrlInputProps { + value: string; + onChange: (url: string) => void; + onValidate: (url: string) => void; + disabled?: boolean; + placeholder?: string; +} + +// Icons as named components for better readability +function GitHubIcon() { + return ( + + ); +} + +function ClearIcon() { + return ( + + ); +} + +const DEBOUNCE_MS = 500; + +export function RepoUrlInput({ + value, + onChange, + onValidate, + disabled = false, + placeholder = "https://github.com/owner/repo" +}: RepoUrlInputProps) { + const [localValue, setLocalValue] = useState(value); + + // Sync with external value changes + useEffect(() => { + setLocalValue(value); + }, [value]); + + // Debounced validation trigger + useEffect(() => { + if (!localValue.trim() || localValue === value) { + return; + } + + const timer = setTimeout(() => { + onChange(localValue); + onValidate(localValue); + }, DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [localValue, value, onChange, onValidate]); + + const handleChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + }; + + const handleClear = () => { + setLocalValue(''); + onChange(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && localValue.trim()) { + e.preventDefault(); + onChange(localValue); + onValidate(localValue); + } + }; + + return ( +
+ {/* GitHub icon */} +
+ +
+ + {/* Input */} + + + {/* Clear button */} + {localValue && !disabled && ( + + )} +
+ ); +} diff --git a/frontend/src/components/playground/ValidationStatus.tsx b/frontend/src/components/playground/ValidationStatus.tsx new file mode 100644 index 0000000..a54c066 --- /dev/null +++ b/frontend/src/components/playground/ValidationStatus.tsx @@ -0,0 +1,170 @@ +/** + * ValidationStatus + * + * Displays repository validation result. + * Shows loading, valid (with stats), or invalid states. + */ + +import { cn } from '@/lib/utils'; +import type { ValidationResult } from '@/services/playground-api'; + +// ============ State Types ============ + +type ValidationState = + | { type: 'idle' } + | { type: 'validating' } + | { type: 'valid'; validation: ValidationResult } + | { type: 'invalid'; error: string; reason?: string }; + +interface ValidationStatusProps { + state: ValidationState; + onStartIndexing?: () => void; +} + +// ============ Icons ============ + +function CheckIcon() { + return ( + + ); +} + +function XIcon() { + return ( + + ); +} + +function LockIcon() { + return ( + + ); +} + +function SpinnerIcon() { + return ( + + ); +} + +function StarIcon() { + return ( + + ); +} + +// ============ Helpers ============ + +function formatNumber(num: number): string { + if (num >= 1000) { + return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + } + return num.toString(); +} + +// ============ Component ============ + +export function ValidationStatus({ state, onStartIndexing }: ValidationStatusProps) { + if (state.type === 'idle') { + return null; + } + + if (state.type === 'validating') { + return ( +
+ + Checking repository... +
+ ); + } + + if (state.type === 'invalid') { + const icon = state.reason === 'private' ? : ; + + return ( +
+ {icon} + {state.error} +
+ ); + } + + // Valid state + const { validation } = state; + + return ( +
+ {/* Header */} +
+ + + + Ready to index +
+ + {/* Stats */} +
+
+ + {validation.file_count.toLocaleString()} files +
+ + {validation.stars > 0 && ( +
+ + {formatNumber(validation.stars)} +
+ )} + + {validation.language && ( +
+ + {validation.language} +
+ )} + +
+ + ~{validation.estimated_time_seconds}s +
+
+ + {/* Action */} + {onStartIndexing && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/playground/index.ts b/frontend/src/components/playground/index.ts new file mode 100644 index 0000000..097a740 --- /dev/null +++ b/frontend/src/components/playground/index.ts @@ -0,0 +1,11 @@ +/** + * Playground Components + * + * Anonymous repo indexing UI components. + * @see useAnonymousSession hook for state management + */ + +export { RepoModeSelector, type RepoMode } from './RepoModeSelector'; +export { RepoUrlInput } from './RepoUrlInput'; +export { ValidationStatus } from './ValidationStatus'; +export { IndexingProgress, type ProgressData } from './IndexingProgress'; diff --git a/frontend/src/hooks/useAnonymousSession.ts b/frontend/src/hooks/useAnonymousSession.ts new file mode 100644 index 0000000..80ab1b2 --- /dev/null +++ b/frontend/src/hooks/useAnonymousSession.ts @@ -0,0 +1,345 @@ +/** + * useAnonymousSession Hook + * + * State machine for anonymous repo indexing flow. + * Manages validation, indexing progress, and session state. + * + * @example + * ```tsx + * const { state, validateUrl, startIndexing, reset } = useAnonymousSession(); + * + * // State machine: idle → validating → valid/invalid → indexing → ready/error + * ``` + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { + playgroundAPI, + type ValidationResult, + type SessionData +} from '../services/playground-api'; + +// ============ State Types ============ + +interface IdleState { + status: 'idle'; +} + +interface ValidatingState { + status: 'validating'; + url: string; +} + +interface ValidState { + status: 'valid'; + url: string; + validation: ValidationResult; +} + +interface InvalidState { + status: 'invalid'; + url: string; + error: string; + reason?: 'private' | 'too_large' | 'invalid_url' | 'rate_limited'; +} + +interface IndexingState { + status: 'indexing'; + url: string; + jobId: string; + progress: { + percent: number; + filesProcessed: number; + filesTotal: number; + currentFile?: string; + functionsFound: number; + }; +} + +interface ReadyState { + status: 'ready'; + repoId: string; + repoName: string; + owner: string; + fileCount: number; + functionsFound: number; + expiresAt: string; +} + +interface ErrorState { + status: 'error'; + message: string; + canRetry: boolean; +} + +export type PlaygroundState = + | IdleState + | ValidatingState + | ValidState + | InvalidState + | IndexingState + | ReadyState + | ErrorState; + +// ============ Hook Return Type ============ + +export interface UseAnonymousSessionReturn { + state: PlaygroundState; + session: SessionData | null; + validateUrl: (url: string) => Promise; + startIndexing: () => Promise; + reset: () => void; + isLoading: boolean; +} + +// ============ Constants ============ + +const POLLING_INTERVAL_MS = 2000; + +// ============ Hook Implementation ============ + +export function useAnonymousSession(): UseAnonymousSessionReturn { + const [state, setState] = useState({ status: 'idle' }); + const [session, setSession] = useState(null); + + // Refs for cleanup and race condition handling + const pollingRef = useRef | null>(null); + const currentUrlRef = useRef(''); + const abortControllerRef = useRef(null); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + /** + * Check if user already has an indexed repo in session + */ + const checkExistingSession = useCallback(async () => { + try { + const sessionData = await playgroundAPI.getSession(); + setSession(sessionData); + + if (sessionData.indexed_repo) { + setState({ + status: 'ready', + repoId: sessionData.indexed_repo.repo_id, + repoName: sessionData.indexed_repo.name, + owner: sessionData.indexed_repo.owner || '', + fileCount: sessionData.indexed_repo.file_count, + functionsFound: 0, + expiresAt: sessionData.indexed_repo.expires_at, + }); + } + } catch { + // No session yet - this is expected for new users + } + }, []); + + // Check for existing session on mount + useEffect(() => { + checkExistingSession(); + }, [checkExistingSession]); + + /** + * Poll for indexing job status + */ + const startPolling = useCallback((jobId: string, url: string) => { + // Clear any existing polling + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + + const poll = async () => { + try { + const status = await playgroundAPI.getIndexingStatus(jobId); + + if (status.status === 'completed') { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + const sessionData = await playgroundAPI.getSession(); + setSession(sessionData); + + setState({ + status: 'ready', + repoId: status.repo_id!, + repoName: status.repository?.name || '', + owner: status.repository?.owner || '', + fileCount: status.stats?.files_indexed || 0, + functionsFound: status.stats?.functions_found || 0, + expiresAt: sessionData.indexed_repo?.expires_at || '', + }); + } else if (status.status === 'failed') { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + + setState({ + status: 'error', + message: status.error || 'Indexing failed', + canRetry: true, + }); + } else { + // Update progress + setState({ + status: 'indexing', + url, + jobId, + progress: { + percent: status.progress?.percent_complete || 0, + filesProcessed: status.progress?.files_processed || 0, + filesTotal: status.progress?.files_total || 0, + currentFile: status.progress?.current_file, + functionsFound: status.progress?.functions_found || 0, + }, + }); + } + } catch (error) { + // Log but don't stop polling on transient errors + console.error('Polling error:', error); + } + }; + + // Poll immediately, then at interval + poll(); + pollingRef.current = setInterval(poll, POLLING_INTERVAL_MS); + }, []); + + /** + * Validate a GitHub URL + */ + const validateUrl = useCallback(async (url: string) => { + if (!url.trim()) { + setState({ status: 'idle' }); + return; + } + + // Cancel any in-flight request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + currentUrlRef.current = url; + setState({ status: 'validating', url }); + + try { + const validation = await playgroundAPI.validateRepo(url); + + // Ignore if URL changed during validation + if (currentUrlRef.current !== url) return; + + if (validation.can_index) { + setState({ status: 'valid', url, validation }); + } else { + setState({ + status: 'invalid', + url, + error: getErrorMessage(validation.reason), + reason: validation.reason, + }); + } + } catch (error) { + // Ignore abort errors + if (error instanceof Error && error.name === 'AbortError') return; + if (currentUrlRef.current !== url) return; + + setState({ + status: 'invalid', + url, + error: error instanceof Error ? error.message : 'Validation failed', + }); + } + }, []); + + /** + * Start indexing the validated repo + */ + const startIndexing = useCallback(async () => { + if (state.status !== 'valid') { + console.error('Cannot start indexing: not in valid state'); + return; + } + + const { url, validation } = state; + + try { + const job = await playgroundAPI.startIndexing(url); + + setState({ + status: 'indexing', + url, + jobId: job.job_id, + progress: { + percent: 0, + filesProcessed: 0, + filesTotal: validation.file_count, + functionsFound: 0, + }, + }); + + startPolling(job.job_id, url); + } catch (error) { + setState({ + status: 'error', + message: error instanceof Error ? error.message : 'Failed to start indexing', + canRetry: true, + }); + } + }, [state, startPolling]); + + /** + * Reset to idle state + */ + const reset = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + currentUrlRef.current = ''; + setState({ status: 'idle' }); + }, []); + + const isLoading = state.status === 'validating' || state.status === 'indexing'; + + return { + state, + session, + validateUrl, + startIndexing, + reset, + isLoading, + }; +} + +// ============ Helpers ============ + +function getErrorMessage(reason?: string): string { + switch (reason) { + case 'private': + return 'This repository is private. Sign up to index private repos.'; + case 'too_large': + return 'This repository is too large for anonymous indexing. Sign up for full access.'; + case 'invalid_url': + return 'Please enter a valid GitHub repository URL.'; + case 'rate_limited': + return 'Rate limit reached. Please try again later.'; + default: + return 'Unable to validate this repository.'; + } +} diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts new file mode 100644 index 0000000..b95a468 --- /dev/null +++ b/frontend/src/services/index.ts @@ -0,0 +1,12 @@ +/** + * Services barrel export + */ + +export { playgroundAPI, APIError } from './playground-api'; +export type { + ValidationResult, + IndexingJob, + SessionData, + SearchResult, + SearchResponse +} from './playground-api'; diff --git a/frontend/src/services/playground-api.ts b/frontend/src/services/playground-api.ts new file mode 100644 index 0000000..e3f3f51 --- /dev/null +++ b/frontend/src/services/playground-api.ts @@ -0,0 +1,320 @@ +/** + * Playground API Service + * + * Handles all anonymous indexing API calls. + * + * Endpoints: + * - POST /playground/validate-repo (BLOCKED by #134 - using mock) + * - POST /playground/index + * - GET /playground/index/{job_id} + * - GET /playground/session + * - POST /playground/search + */ + +import { API_URL } from '../config/api'; + +// ============ Types ============ + +export interface ValidationResult { + can_index: boolean; + reason?: 'private' | 'too_large' | 'invalid_url' | 'rate_limited'; + repo_name: string; + owner: string; + file_count: number; + stars: number; + language: string; + default_branch: string; + estimated_time_seconds: number; +} + +export interface IndexingJob { + job_id: string; + status: 'queued' | 'cloning' | 'processing' | 'completed' | 'failed'; + repo_id?: string; + repository?: { + owner: string; + name: string; + branch: string; + github_url: string; + }; + progress?: { + files_processed: number; + files_total: number; + percent_complete: number; + current_file?: string; + functions_found: number; + }; + stats?: { + files_indexed: number; + functions_found: number; + time_taken_seconds: number; + }; + error?: string; + estimated_time_seconds?: number; + message?: string; +} + +export interface SessionData { + session_id: string; + indexed_repo: { + repo_id: string; + github_url: string; + name: string; + owner?: string; + file_count: number; + indexed_at: string; + expires_at: string; + } | null; + searches: { + used: number; + limit: number; + remaining: number; + }; +} + +export interface SearchResult { + name: string; + type: string; + file_path: string; + code: string; + language: string; + line_start: number; + line_end: number; + score: number; +} + +export interface SearchResponse { + results: SearchResult[]; + search_time_ms: number; + remaining_searches: number; + limit: number; +} + +// ============ Error Classes ============ + +export class APIError extends Error { + constructor( + message: string, + public status: number, + public code?: string + ) { + super(message); + this.name = 'APIError'; + } +} + +// ============ Helpers ============ + +async function parseErrorResponse(response: Response): Promise { + try { + const data = await response.json(); + return data.detail?.message || data.detail || data.message || 'Request failed'; + } catch { + return `Request failed with status ${response.status}`; + } +} + +// ============ API Client ============ + +class PlaygroundAPI { + private baseUrl: string; + + constructor() { + this.baseUrl = `${API_URL}/playground`; + } + + /** + * Validate a GitHub repo URL before indexing. + * + * @param githubUrl - Full GitHub URL (e.g., https://github.com/owner/repo) + * @returns Validation result with repo metadata + * @throws APIError on failure + * + * NOTE: Currently mocked due to Bug #134 (CacheService missing get/set) + */ + async validateRepo(githubUrl: string): Promise { + // TODO: Remove mock when #134 is fixed + const USE_MOCK = true; + + if (USE_MOCK) { + return this.mockValidateRepo(githubUrl); + } + + const response = await fetch(`${this.baseUrl}/validate-repo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ github_url: githubUrl }), + }); + + if (!response.ok) { + const message = await parseErrorResponse(response); + throw new APIError(message, response.status); + } + + return response.json(); + } + + /** + * Mock validation for development until Bug #134 is fixed. + * Simulates realistic validation responses. + */ + private async mockValidateRepo(githubUrl: string): Promise { + // Simulate network latency + await new Promise(resolve => setTimeout(resolve, 600 + Math.random() * 400)); + + // Parse URL + const match = githubUrl.match(/github\.com\/([^/]+)\/([^/\s?#]+)/); + if (!match) { + return { + can_index: false, + reason: 'invalid_url', + repo_name: '', + owner: '', + file_count: 0, + stars: 0, + language: '', + default_branch: '', + estimated_time_seconds: 0, + }; + } + + const [, owner, repo] = match; + const repoName = repo.replace(/\.git$/, ''); + + // Detect language from common repo names + const language = this.detectLanguage(repoName, owner); + const fileCount = 50 + Math.floor(Math.random() * 250); + + return { + can_index: true, + repo_name: repoName, + owner, + file_count: fileCount, + stars: Math.floor(Math.random() * 50000), + language, + default_branch: 'main', + estimated_time_seconds: Math.ceil(fileCount / 10), + }; + } + + /** + * Simple language detection based on repo/owner name patterns. + */ + private detectLanguage(repo: string, owner: string): string { + const name = `${owner}/${repo}`.toLowerCase(); + + if (name.includes('flask') || name.includes('django') || name.includes('fastapi')) { + return 'Python'; + } + if (name.includes('express') || name.includes('next') || name.includes('react')) { + return 'TypeScript'; + } + if (name.includes('rails') || name.includes('ruby')) { + return 'Ruby'; + } + if (name.includes('spring') || name.includes('java')) { + return 'Java'; + } + if (name.includes('gin') || name.includes('echo')) { + return 'Go'; + } + + // Random fallback + const languages = ['Python', 'TypeScript', 'JavaScript', 'Go', 'Rust']; + return languages[Math.floor(Math.random() * languages.length)]; + } + + /** + * Start indexing a repository. + * + * @param githubUrl - Full GitHub URL + * @returns Job info with job_id for polling + * @throws APIError on failure (409 if already indexed) + */ + async startIndexing(githubUrl: string): Promise { + const response = await fetch(`${this.baseUrl}/index`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ github_url: githubUrl }), + }); + + if (!response.ok) { + const message = await parseErrorResponse(response); + throw new APIError(message, response.status, response.status === 409 ? 'ALREADY_INDEXED' : undefined); + } + + return response.json(); + } + + /** + * Poll indexing job status. + * + * @param jobId - Job ID from startIndexing + * @returns Current job status with progress + */ + async getIndexingStatus(jobId: string): Promise { + const response = await fetch(`${this.baseUrl}/index/${jobId}`, { + credentials: 'include', + }); + + if (!response.ok) { + const message = await parseErrorResponse(response); + throw new APIError(message, response.status); + } + + return response.json(); + } + + /** + * Get current session data including indexed repo and search limits. + */ + async getSession(): Promise { + const response = await fetch(`${this.baseUrl}/session`, { + credentials: 'include', + }); + + if (!response.ok) { + const message = await parseErrorResponse(response); + throw new APIError(message, response.status); + } + + return response.json(); + } + + /** + * Search in indexed repository. + * + * @param query - Natural language search query + * @param repoId - Repository ID from session + * @param maxResults - Maximum results to return (default: 10) + * @throws APIError on failure (429 if rate limited) + */ + async search(query: string, repoId: string, maxResults = 10): Promise { + const response = await fetch(`${this.baseUrl}/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + query, + repo_id: repoId, + max_results: maxResults, + }), + }); + + if (!response.ok) { + const message = await parseErrorResponse(response); + throw new APIError( + response.status === 429 ? 'Daily search limit reached' : message, + response.status, + response.status === 429 ? 'RATE_LIMITED' : undefined + ); + } + + return response.json(); + } +} + +// Export singleton instance +export const playgroundAPI = new PlaygroundAPI();