diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5617641..89baff7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@supabase/supabase-js": "^2.39.0", + "@tanstack/react-query": "^5.90.12", "@types/dagre": "^0.7.53", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", @@ -1981,6 +1982,30 @@ "node": ">=20.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 40ea0b0..a54747e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@supabase/supabase-js": "^2.39.0", + "@tanstack/react-query": "^5.90.12", "@types/dagre": "^0.7.53", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/components/DependencyGraph.tsx b/frontend/src/components/DependencyGraph.tsx index 68fcb78..b9743aa 100644 --- a/frontend/src/components/DependencyGraph.tsx +++ b/frontend/src/components/DependencyGraph.tsx @@ -11,6 +11,7 @@ import ReactFlow, { } from 'reactflow' import dagre from 'dagre' import 'reactflow/dist/style.css' +import { useDependencyGraph } from '../hooks/useCachedQuery' interface DependencyGraphProps { repoId: string @@ -47,7 +48,6 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps) { const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) - const [loading, setLoading] = useState(true) const [metrics, setMetrics] = useState(null) const [filterCritical, setFilterCritical] = useState(false) const [minDeps, setMinDeps] = useState(0) @@ -55,9 +55,18 @@ export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps const [allNodes, setAllNodes] = useState([]) const [allEdges, setAllEdges] = useState([]) + // Use cached query for dependencies + const { data, isLoading: loading, isFetching } = useDependencyGraph({ + repoId, + apiKey + }) + + // Process data when it arrives useEffect(() => { - loadGraph() - }, [repoId]) + if (data) { + processGraphData(data) + } + }, [data]) useEffect(() => { if (allNodes.length > 0) { @@ -89,83 +98,67 @@ export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps setEdges(layoutedEdges) } - const loadGraph = async () => { - setLoading(true) - try { - const response = await fetch(`${apiUrl}/api/repos/${repoId}/dependencies`, { - headers: { - 'Authorization': `Bearer ${apiKey}` - } - }) + const processGraphData = (data: any) => { + const flowNodes: Node[] = data.nodes.map((node: any) => { + const fileName = node.label || node.id.split('/').pop() + const fullPath = node.id + const importCount = node.import_count || node.imports || 0 - const data = await response.json() - - const flowNodes: Node[] = data.nodes.map((node: any) => { - const fileName = node.label || node.id.split('/').pop() - const fullPath = node.id - const importCount = node.import_count || node.imports || 0 - - return { - id: node.id, - type: 'default', - data: { - label: ( -
-
- {fileName} -
- {importCount > 0 && ( -
- {importCount} imports -
- )} + return { + id: node.id, + type: 'default', + data: { + label: ( +
+
+ {fileName}
- ), - language: node.language, - imports: importCount - }, - position: { x: 0, y: 0 }, - style: { - background: getLanguageColor(node.language), - color: 'white', - border: '2px solid #3b82f6', - borderRadius: '8px', - padding: '8px 12px', - fontSize: '11px', - fontFamily: 'monospace', - width: 180, - height: 60 - } - } - }) - - const flowEdges: Edge[] = data.edges.map((edge: any) => ({ - id: `${edge.source}-${edge.target}`, - source: edge.source, - target: edge.target, - animated: false, - style: { stroke: '#4b5563', strokeWidth: 1.5 }, - markerEnd: { - type: MarkerType.ArrowClosed, - color: '#4b5563', + {importCount > 0 && ( +
+ {importCount} imports +
+ )} +
+ ), + language: node.language, + imports: importCount }, - })) - - setAllNodes(flowNodes) - setAllEdges(flowEdges) - setMetrics(data.metrics) - - const { nodes: layoutedNodes, edges: layoutedEdges } = - getLayoutedElements(flowNodes, flowEdges) - - setNodes(layoutedNodes) - setEdges(layoutedEdges) - - } catch (error) { - console.error('Error loading graph:', error) - } finally { - setLoading(false) - } + position: { x: 0, y: 0 }, + style: { + background: getLanguageColor(node.language), + color: 'white', + border: '2px solid #3b82f6', + borderRadius: '8px', + padding: '8px 12px', + fontSize: '11px', + fontFamily: 'monospace', + width: 180, + height: 60 + } + } + }) + + const flowEdges: Edge[] = data.edges.map((edge: any) => ({ + id: `${edge.source}-${edge.target}`, + source: edge.source, + target: edge.target, + animated: false, + style: { stroke: '#4b5563', strokeWidth: 1.5 }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: '#4b5563', + }, + })) + + setAllNodes(flowNodes) + setAllEdges(flowEdges) + setMetrics(data.metrics) + + const { nodes: layoutedNodes, edges: layoutedEdges } = + getLayoutedElements(flowNodes, flowEdges) + + setNodes(layoutedNodes) + setEdges(layoutedEdges) } const handleNodeClick = useCallback((event: any, node: Node) => { diff --git a/frontend/src/components/RepoList.tsx b/frontend/src/components/RepoList.tsx index 146bb7f..2b1b650 100644 --- a/frontend/src/components/RepoList.tsx +++ b/frontend/src/components/RepoList.tsx @@ -1,9 +1,11 @@ import type { Repository } from '../types' +import { RepoGridSkeleton } from './ui/Skeleton' interface RepoListProps { repos: Repository[] selectedRepo: string | null onSelect: (repoId: string) => void + loading?: boolean } // Status indicator with glow effect @@ -40,7 +42,11 @@ const StatusIndicator = ({ status }: { status: string }) => { ) } -export function RepoList({ repos, selectedRepo, onSelect }: RepoListProps) { +export function RepoList({ repos, selectedRepo, onSelect, loading }: RepoListProps) { + if (loading) { + return + } + if (repos.length === 0) { return (
@@ -57,7 +63,7 @@ export function RepoList({ repos, selectedRepo, onSelect }: RepoListProps) { return (
- {repos.map((repo) => { + {repos.map((repo, index) => { const isSelected = selectedRepo === repo.id return ( @@ -65,11 +71,12 @@ export function RepoList({ repos, selectedRepo, onSelect }: RepoListProps) { key={repo.id} onClick={() => onSelect(repo.id)} className={`group relative text-left rounded-2xl p-5 transition-all duration-300 - bg-[#111113] border overflow-hidden + bg-[#111113] border overflow-hidden opacity-0 animate-fade-in focus-ring ${isSelected ? 'border-blue-500/50 shadow-lg shadow-blue-500/10' : 'border-white/5 hover:border-white/10 hover:bg-[#151518]' }`} + style={{ animationDelay: `${index * 0.05}s` }} > {/* Subtle gradient overlay on hover */}
diff --git a/frontend/src/components/RepoOverview.tsx b/frontend/src/components/RepoOverview.tsx index 933a435..24348c2 100644 --- a/frontend/src/components/RepoOverview.tsx +++ b/frontend/src/components/RepoOverview.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { Progress } from '@/components/ui/progress' import type { Repository } from '../types' import { WS_URL } from '../config/api' +import { useInvalidateRepoCache } from '../hooks/useCachedQuery' interface RepoOverviewProps { repo: Repository @@ -23,6 +24,9 @@ export function RepoOverview({ repo, onReindex, apiUrl, apiKey }: RepoOverviewPr const [progress, setProgress] = useState(null) const wsRef = useRef(null) const completedRef = useRef(false) + + // Cache invalidation hook + const invalidateCache = useInvalidateRepoCache() useEffect(() => { return () => { @@ -62,6 +66,8 @@ export function RepoOverview({ repo, onReindex, apiUrl, apiKey }: RepoOverviewPr toast.success(`Indexing complete! ${data.total_functions} functions indexed.`, { id: 'reindex' }) setIndexing(false) setProgress(null) + // Invalidate caches after re-index + invalidateCache(repo.id) onReindex() } else if (data.type === 'error') { completedRef.current = true diff --git a/frontend/src/components/StyleInsights.tsx b/frontend/src/components/StyleInsights.tsx index 514e12e..81ca84f 100644 --- a/frontend/src/components/StyleInsights.tsx +++ b/frontend/src/components/StyleInsights.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useStyleAnalysis } from '../hooks/useCachedQuery' interface StyleInsightsProps { repoId: string @@ -7,27 +7,8 @@ interface StyleInsightsProps { } export function StyleInsights({ repoId, apiUrl, apiKey }: StyleInsightsProps) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - loadStyleData() - }, [repoId]) - - const loadStyleData = async () => { - setLoading(true) - try { - const response = await fetch(`${apiUrl}/api/repos/${repoId}/style-analysis`, { - headers: { 'Authorization': `Bearer ${apiKey}` } - }) - const result = await response.json() - setData(result) - } catch (error) { - console.error('Error loading style data:', error) - } finally { - setLoading(false) - } - } + // Use cached query for style analysis + const { data, isLoading: loading } = useStyleAnalysis({ repoId, apiKey }) if (loading) { return ( diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 47ba2be..181e9e2 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -20,6 +20,7 @@ export function DashboardHome() { const [selectedRepo, setSelectedRepo] = useState(null) const [activeTab, setActiveTab] = useState('overview') const [loading, setLoading] = useState(false) + const [reposLoading, setReposLoading] = useState(true) const [showPerformance, setShowPerformance] = useState(false) const fetchRepos = async () => { @@ -33,6 +34,8 @@ export function DashboardHome() { setRepos(data.repositories || []) } catch (error) { console.error('Error fetching repos:', error) + } finally { + setReposLoading(false) } } @@ -150,6 +153,7 @@ export function DashboardHome() { { setSelectedRepo(id) setActiveTab('overview') diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..aababfc --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,86 @@ +import { cn } from '@/lib/utils' + +interface SkeletonProps { + className?: string +} + +export function Skeleton({ className }: SkeletonProps) { + return ( +
+ ) +} + +// Preset skeleton components +export function RepoCardSkeleton() { + return ( +
+
+ +
+ + +
+ +
+
+
+ + +
+
+
+ ) +} + +export function RepoGridSkeleton({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ) +} + +export function StatCardSkeleton() { + return ( +
+ + +
+ ) +} + +export function SearchResultSkeleton() { + return ( +
+
+
+ + +
+ +
+ + +
+ ) +} + +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + +
+ ))} +
+ ) +} diff --git a/frontend/src/hooks/useCachedQuery.ts b/frontend/src/hooks/useCachedQuery.ts new file mode 100644 index 0000000..946cb8b --- /dev/null +++ b/frontend/src/hooks/useCachedQuery.ts @@ -0,0 +1,141 @@ +/** + * Cached API Hooks + * + * Dual-layer caching strategy: + * 1. React Query (memory) - Fast tab navigation, request deduplication + * 2. localStorage (persist) - Survives refresh, instant initial load + * + * Cache invalidation happens on re-index. + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { getFromCache, saveToCache, invalidateRepoCache } from '../lib/cache' +import { API_URL } from '../config/api' + +// Stale time: 5 minutes (data considered fresh) +const STALE_TIME = 5 * 60 * 1000 + +// Cache time: 30 minutes (keep in memory) +const CACHE_TIME = 30 * 60 * 1000 + +interface UseCachedQueryOptions { + repoId: string + apiKey: string + enabled?: boolean +} + +/** + * Fetch with authorization header + */ +async function fetchWithAuth(url: string, apiKey: string) { + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${apiKey}` } + }) + + if (!response.ok) { + throw new Error(`API error: ${response.status}`) + } + + return response.json() +} + +/** + * Hook for fetching dependency graph with caching + */ +export function useDependencyGraph({ repoId, apiKey, enabled = true }: UseCachedQueryOptions) { + const queryClient = useQueryClient() + + return useQuery({ + queryKey: ['dependencies', repoId], + queryFn: async () => { + const data = await fetchWithAuth( + `${API_URL}/api/repos/${repoId}/dependencies`, + apiKey + ) + // Save to localStorage on successful fetch + saveToCache('dependencies', repoId, data) + return data + }, + enabled: enabled && !!repoId && !!apiKey, + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + // Use localStorage as initial data for instant load + initialData: () => getFromCache('dependencies', repoId), + // Don't refetch if we have fresh localStorage data + initialDataUpdatedAt: () => { + const cached = getFromCache('dependencies', repoId) + return cached ? Date.now() - STALE_TIME + 1000 : 0 + } + }) +} + +/** + * Hook for fetching code style analysis with caching + */ +export function useStyleAnalysis({ repoId, apiKey, enabled = true }: UseCachedQueryOptions) { + return useQuery({ + queryKey: ['style-analysis', repoId], + queryFn: async () => { + const data = await fetchWithAuth( + `${API_URL}/api/repos/${repoId}/style-analysis`, + apiKey + ) + saveToCache('style-analysis', repoId, data) + return data + }, + enabled: enabled && !!repoId && !!apiKey, + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + initialData: () => getFromCache('style-analysis', repoId), + initialDataUpdatedAt: () => { + const cached = getFromCache('style-analysis', repoId) + return cached ? Date.now() - STALE_TIME + 1000 : 0 + } + }) +} + +/** + * Hook for fetching impact analysis with caching + */ +export function useImpactAnalysis({ + repoId, + apiKey, + filePath, + enabled = true +}: UseCachedQueryOptions & { filePath: string }) { + const cacheKey = `impact:${filePath}` + + return useQuery({ + queryKey: ['impact', repoId, filePath], + queryFn: async () => { + const data = await fetchWithAuth( + `${API_URL}/api/repos/${repoId}/impact?file_path=${encodeURIComponent(filePath)}`, + apiKey + ) + saveToCache(cacheKey, repoId, data) + return data + }, + enabled: enabled && !!repoId && !!apiKey && !!filePath, + staleTime: STALE_TIME, + gcTime: CACHE_TIME, + initialData: () => getFromCache(cacheKey, repoId), + }) +} + +/** + * Hook to invalidate all caches for a repo + * Call this after re-indexing + */ +export function useInvalidateRepoCache() { + const queryClient = useQueryClient() + + return (repoId: string) => { + // Invalidate React Query cache + queryClient.invalidateQueries({ queryKey: ['dependencies', repoId] }) + queryClient.invalidateQueries({ queryKey: ['style-analysis', repoId] }) + queryClient.invalidateQueries({ queryKey: ['impact', repoId] }) + + // Invalidate localStorage cache + invalidateRepoCache(repoId) + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 051ffce..cb55e21 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -130,7 +130,54 @@ background: rgba(59, 130, 246, 0.2); } +/* Page transitions and animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +.animate-slide-in { + animation: slideIn 0.3s ease-out forwards; +} +.animate-scale-in { + animation: scaleIn 0.2s ease-out forwards; +} + +/* Staggered animations for lists */ +.stagger-1 { animation-delay: 0.05s; } +.stagger-2 { animation-delay: 0.1s; } +.stagger-3 { animation-delay: 0.15s; } +.stagger-4 { animation-delay: 0.2s; } +.stagger-5 { animation-delay: 0.25s; } + +/* Focus visible states */ +.focus-ring { + @apply focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]; +} + +/* Smooth hover transitions */ +.hover-lift { + @apply transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg; +} + +.hover-glow { + @apply transition-all duration-200 hover:shadow-[0_0_20px_rgba(59,130,246,0.3)]; +} @layer base { * { diff --git a/frontend/src/lib/cache.ts b/frontend/src/lib/cache.ts new file mode 100644 index 0000000..6d26915 --- /dev/null +++ b/frontend/src/lib/cache.ts @@ -0,0 +1,130 @@ +/** + * LocalStorage Cache Utility + * + * Provides persistent caching with expiration support. + * Used alongside React Query for dual-layer caching strategy. + */ + +interface CacheEntry { + data: T + timestamp: number + version: string +} + +// Cache version - bump this when data structure changes +const CACHE_VERSION = '1.0.0' + +// Default TTL: 30 minutes +const DEFAULT_TTL = 30 * 60 * 1000 + +/** + * Generate cache key for a specific resource + */ +export function getCacheKey(resource: string, repoId: string): string { + return `codeintel:${resource}:${repoId}` +} + +/** + * Get data from localStorage cache + * Returns null if expired or not found + */ +export function getFromCache( + resource: string, + repoId: string, + ttl: number = DEFAULT_TTL +): T | null { + try { + const key = getCacheKey(resource, repoId) + const cached = localStorage.getItem(key) + + if (!cached) return null + + const entry: CacheEntry = JSON.parse(cached) + + // Check version + if (entry.version !== CACHE_VERSION) { + localStorage.removeItem(key) + return null + } + + // Check expiration + const isExpired = Date.now() - entry.timestamp > ttl + if (isExpired) { + localStorage.removeItem(key) + return null + } + + return entry.data + } catch (error) { + console.warn('Cache read error:', error) + return null + } +} + +/** + * Save data to localStorage cache + */ +export function saveToCache( + resource: string, + repoId: string, + data: T +): void { + try { + const key = getCacheKey(resource, repoId) + const entry: CacheEntry = { + data, + timestamp: Date.now(), + version: CACHE_VERSION + } + localStorage.setItem(key, JSON.stringify(entry)) + } catch (error) { + // localStorage might be full or disabled + console.warn('Cache write error:', error) + } +} + +/** + * Invalidate cache for a specific repo + * Call this after re-indexing + */ +export function invalidateRepoCache(repoId: string): void { + const resources = ['dependencies', 'style-analysis', 'search-results'] + resources.forEach(resource => { + const key = getCacheKey(resource, repoId) + localStorage.removeItem(key) + }) +} + +/** + * Invalidate all CodeIntel caches + */ +export function invalidateAllCache(): void { + const keysToRemove: string[] = [] + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith('codeintel:')) { + keysToRemove.push(key) + } + } + + keysToRemove.forEach(key => localStorage.removeItem(key)) +} + +/** + * Get cache stats for debugging + */ +export function getCacheStats(): { count: number; totalSize: number } { + let count = 0 + let totalSize = 0 + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key?.startsWith('codeintel:')) { + count++ + totalSize += localStorage.getItem(key)?.length || 0 + } + } + + return { count, totalSize } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5663088..074b75f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,27 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import { App } from './App.tsx' +// Create a client with default options +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Don't refetch on window focus by default + refetchOnWindowFocus: false, + // Retry failed requests once + retry: 1, + // Consider data stale after 5 minutes + staleTime: 5 * 60 * 1000, + }, + }, +}) + createRoot(document.getElementById('root')!).render( - + + + , )