diff --git a/frontend/bun.lock b/frontend/bun.lock index b293641..b540c39 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -247,6 +248,8 @@ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], diff --git a/frontend/package.json b/frontend/package.json index 2183d4e..3f704b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontend/src/components/AddRepoForm.tsx b/frontend/src/components/AddRepoForm.tsx index dbcd095..7d8b32e 100644 --- a/frontend/src/components/AddRepoForm.tsx +++ b/frontend/src/components/AddRepoForm.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { Package, Plus, X, Loader2 } from 'lucide-react' +import { Package, Plus, X, Loader2, Search } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { API_URL } from '@/config/api' +import type { AnalyzeResult } from '@/types' // Discriminated union: if isOpen is provided, onOpenChange is required type UncontrolledProps = { @@ -18,21 +20,50 @@ type ControlledProps = { type AddRepoFormProps = { onAdd: (gitUrl: string, branch: string) => Promise + onAnalyzed?: (result: AnalyzeResult, gitUrl: string, branch: string) => void loading: boolean } & (UncontrolledProps | ControlledProps) -export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFormProps) { +export function AddRepoForm({ onAdd, onAnalyzed, loading, isOpen, onOpenChange }: AddRepoFormProps) { const [gitUrl, setGitUrl] = useState('') const [branch, setBranch] = useState('main') + const [analyzing, setAnalyzing] = useState(false) const [internalOpen, setInternalOpen] = useState(false) const isControlled = isOpen !== undefined const showForm = isControlled ? isOpen : internalOpen const setShowForm = isControlled ? onOpenChange : setInternalOpen + const isBusy = loading || analyzing const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!gitUrl) return + + // If onAnalyzed provided and URL is GitHub, analyze first + if (onAnalyzed && gitUrl.includes('github.com')) { + try { + setAnalyzing(true) + const resp = await fetch(`${API_URL}/repos/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ github_url: gitUrl }), + }) + if (resp.ok) { + const result: AnalyzeResult = await resp.json() + if (result.suggestion === 'large_repo') { + onAnalyzed(result, gitUrl, branch) + setShowForm(false) + return + } + } + // Small repo or non-GitHub: proceed directly + } catch { + // Analyze failed -- fall through to direct add + } finally { + setAnalyzing(false) + } + } + await onAdd(gitUrl, branch) setGitUrl('') setBranch('main') @@ -60,7 +91,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-background/80 backdrop-blur-md flex items-center justify-center z-50" - onClick={() => !loading && setShowForm(false)} + onClick={() => !isBusy && setShowForm(false)} > @@ -100,7 +132,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor onChange={(e) => setGitUrl(e.target.value)} placeholder="https://github.com/username/repo" required - disabled={loading} + disabled={isBusy} autoFocus /> @@ -114,7 +146,7 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor onChange={(e) => setBranch(e.target.value)} placeholder="main" required - disabled={loading} + disabled={isBusy} />

Repository will be cloned and automatically indexed

@@ -125,17 +157,22 @@ export function AddRepoForm({ onAdd, loading, isOpen, onOpenChange }: AddRepoFor type="button" variant="outline" onClick={() => { setShowForm(false); setGitUrl(''); setBranch('main') }} - disabled={loading} + disabled={isBusy} className="flex-1" > Cancel + + ) +} + + +function PackageCard({ + dir, + isSelected, + maxFiles, + onToggle, +}: { + dir: DirectoryEntry + isSelected: boolean + maxFiles: number + onToggle: () => void +}) { + // Scale card width: smallest = 120px, largest = 240px + const scale = dir.file_count / maxFiles + const minWidth = Math.round(120 + scale * 120) + + return ( + + ) +} + + +function BudgetBar({ current, limit }: { current: number; limit: number }) { + const pct = Math.min((current / limit) * 100, 100) + const overPct = current > limit ? Math.min(((current - limit) / limit) * 100, 50) : 0 + + // green < 70%, amber 70-100%, red > 100% + const barColor = + current > limit + ? 'bg-destructive' + : pct > 70 + ? 'bg-amber-500' + : 'bg-emerald-500' + + return ( +
+
+ Estimated functions + limit && 'text-destructive font-medium')}> + {current.toLocaleString()} / {limit.toLocaleString()} + +
+
+
+
+ {current > limit && ( +

+ Over your plan limit by {(current - limit).toLocaleString()} functions +

+ )} +
+ ) +} + + +function PickerFooter({ + stats, + loading, + overLimit, + onCancel, + onConfirm, +}: { + stats: { files: number; functions: number; count: number } + loading: boolean + overLimit: boolean + onCancel: () => void + onConfirm: () => void +}) { + const hasSelection = stats.count > 0 + const canConfirm = hasSelection && !overLimit && !loading + + function buttonLabel() { + if (loading) return 'Starting...' + if (!hasSelection) return 'Select packages to continue' + if (overLimit) return 'Over plan limit -- deselect packages' + return `Clone & Index ${stats.count} ${stats.count === 1 ? 'Package' : 'Packages'}` + } + + return ( +
+

+ {hasSelection + ? `Selected: ${stats.files.toLocaleString()} files (~${stats.functions.toLocaleString()} functions) from ${stats.count} ${stats.count === 1 ? 'package' : 'packages'}` + : 'No packages selected'} +

+
+ + +
+
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 15c1a1d..4fdf346 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -12,17 +12,23 @@ import { extractErrorMessage, isUpgradeError } from '../../lib/api-errors' import { RepoListView } from './RepoListView' import { RepoDetailView } from './RepoDetailView' import { AddRepoForm } from '../AddRepoForm' +import { DirectoryPicker } from '../DirectoryPicker' import { GitHubRepoSelector } from '../GitHubRepoSelector' import { IndexingProgressModal } from '../IndexingProgressModal' import { UpgradeLimitModal } from '../UpgradeLimitModal' +import { TIER_FUNCTION_LIMITS, type TierName } from '../../config/api' import type { GitHubRepo } from '../../hooks/useGitHubRepos' -import type { RepoTab } from '../../types' +import type { AnalyzeResult, RepoTab } from '../../types' export function DashboardHome() { const { session } = useAuth() const [searchParams, setSearchParams] = useSearchParams() const { data: repos = [], isLoading: reposLoading, invalidate: refreshRepos } = useRepos(session?.access_token) + // User tier -- validate against known tiers, fall back to free for unknown values + const rawTier = session?.user?.user_metadata?.tier as string + const userTier: TierName = rawTier && rawTier in TIER_FUNCTION_LIMITS ? (rawTier as TierName) : 'free' + const [selectedRepo, setSelectedRepo] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [loading, setLoading] = useState(false) @@ -34,6 +40,12 @@ export function DashboardHome() { const [indexingRepoName, setIndexingRepoName] = useState('') const [showIndexingModal, setShowIndexingModal] = useState(false) + // directory picker (monorepo subset selection) + const [analyzeResult, setAnalyzeResult] = useState(null) + const [pendingGitUrl, setPendingGitUrl] = useState('') + const [pendingBranch, setPendingBranch] = useState('main') + const [showDirectoryPicker, setShowDirectoryPicker] = useState(false) + // upgrade modal const [upgradeModal, setUpgradeModal] = useState<{ show: boolean; message: string; repoName?: string }>({ show: false, message: '', @@ -54,7 +66,9 @@ export function DashboardHome() { } // shared helper: add a repo and start indexing - const addAndIndex = async (name: string, gitUrl: string, branch: string): Promise => { + const addAndIndex = async ( + name: string, gitUrl: string, branch: string, includePaths?: string[], + ): Promise => { const response = await fetch(`${API_URL}/repos`, { method: 'POST', headers: { Authorization: `Bearer ${session?.access_token}`, 'Content-Type': 'application/json' }, @@ -70,9 +84,17 @@ export function DashboardHome() { const data = await response.json() if (!data.repo_id) throw new Error('Missing repo_id in response') + const indexBody = includePaths + ? { include_paths: includePaths, incremental: false } + : undefined + const indexResponse = await fetch(`${API_URL}/repos/${data.repo_id}/index/async`, { method: 'POST', - headers: { Authorization: `Bearer ${session?.access_token}` }, + headers: { + Authorization: `Bearer ${session?.access_token}`, + ...(indexBody ? { 'Content-Type': 'application/json' } : {}), + }, + ...(indexBody ? { body: JSON.stringify(indexBody) } : {}), }) if (!indexResponse.ok) { @@ -160,6 +182,38 @@ export function DashboardHome() { } } + // When AddRepoForm detects a large repo, show the directory picker + const handleAnalyzed = (result: AnalyzeResult, gitUrl: string, branch: string) => { + setAnalyzeResult(result) + setPendingGitUrl(gitUrl) + setPendingBranch(branch) + setShowDirectoryPicker(true) + } + + // User selected directories in the picker -- clone and index with subset + const handleDirectoryConfirm = async (selectedPaths: string[]) => { + if (!analyzeResult) return + try { + setLoading(true) + const name = analyzeResult.repo + const repoId = await addAndIndex(name, pendingGitUrl, pendingBranch, selectedPaths) + setShowDirectoryPicker(false) + setAnalyzeResult(null) + if (repoId) { + setIndexingRepoId(repoId) + setIndexingRepoName(name) + setShowIndexingModal(true) + } + refreshRepos() + } catch (error) { + toast.error('Failed to add repository', { + description: error instanceof Error ? error.message : 'Please try again', + }) + } finally { + setLoading(false) + } + } + const selectedRepoData = repos.find((r) => r.id === selectedRepo) const isRepoView = selectedRepo && selectedRepoData @@ -193,11 +247,24 @@ export function DashboardHome() { + {analyzeResult && ( + { setShowDirectoryPicker(false); setAnalyzeResult(null) }} + repoInfo={analyzeResult} + onConfirm={handleDirectoryConfirm} + loading={loading} + // TODO: replace with actual user tier once GET /users/me returns tier + functionLimit={TIER_FUNCTION_LIMITS[userTier]} + /> + )} + { setTheme(theme === 'dark' ? 'light' : 'dark') @@ -97,7 +101,7 @@ export function TopNav({ onToggleSidebar, sidebarCollapsed, onOpenCommandPalette

{userEmail}

-

Free Plan

+

{tierLabel}

diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..df61a13 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 2fcaae0..e430908 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -60,3 +60,12 @@ export const buildWsUrl = (path: string): string => { // free tier repo limit -- used in dashboard and GitHub import export const MAX_FREE_REPOS = 3 + +// function limits per tier -- used by DirectoryPicker budget bar +export const TIER_FUNCTION_LIMITS = { + free: 2_000, + pro: 20_000, + enterprise: 500_000, +} as const + +export type TierName = keyof typeof TIER_FUNCTION_LIMITS diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7f6de78..f954efc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,3 +19,25 @@ export interface SearchResult { } export type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact' + +// POST /repos/analyze response +export interface DirectoryEntry { + name: string + path: string + file_count: number + estimated_functions: number +} + +export interface AnalyzeResult { + owner: string + repo: string + default_branch: string + total_files: number + total_estimated_functions: number + size_kb: number + stars: number + language: string | null + directories: DirectoryEntry[] + suggestion: string | null + truncated: boolean +}