Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions frontend/src/components/playground/IndexingProgress.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="inline-flex" aria-hidden="true">
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
</span>
);
}

/**
* 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 (
<div
className="rounded-xl bg-zinc-900/80 border border-zinc-800 overflow-hidden"
role="status"
aria-label={`Indexing ${repoName || 'repository'}: ${percent}% complete`}
>
{/* Header */}
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg" aria-hidden="true">⚡</span>
<span className="text-white font-medium">
Indexing {repoName || 'repository'}
<AnimatedDots />
</span>
</div>
<span className="text-2xl font-bold text-indigo-400">
{percent}%
</span>
</div>
</div>

{/* Progress bar */}
<div className="px-5 py-4">
<Progress
value={percent}
className="h-2 bg-zinc-800"
aria-label={`${percent}% complete`}
/>
</div>

{/* Stats grid */}
<div className="px-5 py-3 bg-zinc-900/50 border-t border-zinc-800">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-zinc-500">Files</div>
<div className="text-white font-medium">
{filesProcessed} / {filesTotal}
</div>
</div>
<div>
<div className="text-zinc-500">Functions</div>
<div className="text-white font-medium">
{functionsFound.toLocaleString()}
</div>
</div>
<div>
<div className="text-zinc-500">Remaining</div>
<div className="text-white font-medium">
{estimatedRemaining !== null ? `~${estimatedRemaining}s` : '—'}
</div>
</div>
</div>
</div>

{/* Current file */}
{currentFile && (
<div className="px-5 py-3 border-t border-zinc-800">
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-500" aria-hidden="true">📄</span>
<span className="text-zinc-400 font-mono truncate" title={currentFile}>
{currentFile}
</span>
</div>
</div>
)}

{/* Cancel button */}
{onCancel && (
<div className="px-5 py-3 border-t border-zinc-800">
<button
type="button"
onClick={onCancel}
className={cn(
'w-full py-2 px-4 rounded-lg text-sm',
'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200',
'transition-colors duration-200'
)}
>
Cancel
</button>
</div>
)}
</div>
);
}
65 changes: 65 additions & 0 deletions frontend/src/components/playground/RepoModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="inline-flex items-center rounded-lg bg-zinc-900 p-1 border border-zinc-800"
role="tablist"
aria-label="Repository source"
>
<button
type="button"
role="tab"
aria-selected={mode === 'demo'}
aria-controls="demo-panel"
onClick={() => onModeChange('demo')}
disabled={disabled}
className={cn(
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
mode === 'demo'
? 'bg-zinc-800 text-white shadow-sm'
: 'text-zinc-400 hover:text-zinc-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
Demo Repos
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'custom'}
aria-controls="custom-panel"
onClick={() => onModeChange('custom')}
disabled={disabled}
className={cn(
'px-4 py-2 text-sm font-medium rounded-md transition-all duration-200',
mode === 'custom'
? 'bg-indigo-600 text-white shadow-sm'
: 'text-zinc-400 hover:text-zinc-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
Your Repo
</button>
</div>
);
}
142 changes: 142 additions & 0 deletions frontend/src/components/playground/RepoUrlInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
);
}

function ClearIcon() {
return (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}

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<HTMLInputElement>) => {
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 (
<div className="relative">
{/* GitHub icon */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none">
<GitHubIcon />
</div>

{/* Input */}
<input
type="url"
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={placeholder}
aria-label="GitHub repository URL"
className={cn(
'w-full pl-12 pr-12 py-4 text-base rounded-xl',
'bg-zinc-900/50 border border-zinc-800',
'text-white placeholder-zinc-500',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500',
'transition-all duration-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>

{/* Clear button */}
{localValue && !disabled && (
<button
type="button"
onClick={handleClear}
aria-label="Clear input"
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors p-1"
>
<ClearIcon />
</button>
)}
</div>
);
}
Loading