// Main Application class RepoDiagram { constructor() { this.repoData = null; this.nodes = new Map(); this.expanded = new Set(); this.maxDepth = 2; this.searchQuery = ''; this.currentRepo = ''; this.currentBranch = 'main'; this.cache = new Map(); this.cacheTimeout = 5 * 60 * 1000; // 5 minutes this.rateLimitRemaining = null; this.rateLimitReset = null; this.focusedNode = null; this.nodeTabIndex = 0; this.availableBranches = []; this.loadingBranches = false; this.initElements(); this.bindEvents(); this.loadDarkModePreference(); } loadDarkModePreference() { const darkMode = localStorage.getItem('darkMode') === 'true'; if (darkMode) { // Trigger dark mode without animation const body = document.body; const controls = this.controlsBg; body.classList.add('dark'); body.classList.remove('from-slate-50', 'via-blue-50', 'to-purple-50'); body.classList.add('from-slate-900', 'via-slate-800', 'to-slate-900'); controls.classList.remove('bg-white', 'border-slate-200'); controls.classList.add('bg-slate-800', 'border-slate-700'); this.darkModeBtn.innerHTML = ` Light Mode `; } } initElements() { this.repoInput = document.getElementById('repoInput'); this.loadBtn = document.getElementById('loadBtn'); this.expandAllBtn = document.getElementById('expandAllBtn'); this.collapseAllBtn = document.getElementById('collapseAllBtn'); this.exportSVGBtn = document.getElementById('exportSVGBtn'); this.exportPNGBtn = document.getElementById('exportPNGBtn'); this.searchInput = document.getElementById('searchInput'); this.depthSelect = document.getElementById('depthSelect'); this.branchInput = document.getElementById('branchInput'); this.branchSelect = document.getElementById('branchSelect'); this.clearCacheBtn = document.getElementById('clearCacheBtn'); this.diagram = document.getElementById('diagram'); this.nodesContainer = document.getElementById('nodes'); this.connectionsSvg = document.getElementById('connections'); this.loading = document.getElementById('loading'); this.status = document.getElementById('status'); this.emptyState = document.getElementById('emptyState'); this.statsBar = document.getElementById('statsBar'); this.darkModeBtn = document.getElementById('darkModeBtn'); this.controlsBg = document.getElementById('controlsBg'); } bindEvents() { this.loadBtn.addEventListener('click', () => this.loadRepo()); this.repoInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.loadRepo(); }); this.searchInput.addEventListener('input', (e) => { this.searchQuery = e.target.value.toLowerCase(); this.render(); }); this.depthSelect.addEventListener('change', (e) => { this.maxDepth = parseInt(e.target.value); this.render(); }); this.branchSelect.addEventListener('change', (e) => { this.currentBranch = e.target.value; this.branchInput.value = this.currentBranch; }); this.branchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.currentBranch = e.target.value.trim() || 'main'; this.loadRepo(); } }); this.clearCacheBtn.addEventListener('click', () => { this.cache.clear(); this.showStatus('Cache cleared!', 'success'); }); this.expandAllBtn.addEventListener('click', () => { if (this.repoData) { this.expandAll(this.repoData); this.render(); } }); this.collapseAllBtn.addEventListener('click', () => { this.expanded.clear(); this.render(); }); this.exportSVGBtn.addEventListener('click', () => this.exportSVG()); this.exportPNGBtn.addEventListener('click', () => this.exportPNG()); this.darkModeBtn.addEventListener('click', () => this.toggleDarkMode()); // Keyboard navigation document.addEventListener('keydown', (e) => this.handleKeyDown(e)); } handleKeyDown(e) { // Only handle keyboard navigation when diagram is loaded if (!this.repoData) return; const nodes = this.nodesContainer.querySelectorAll('.node'); if (nodes.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); this.focusNextNode(nodes, 1); break; case 'ArrowUp': e.preventDefault(); this.focusNextNode(nodes, -1); break; case 'Enter': case ' ': e.preventDefault(); if (this.focusedNode) { this.focusedNode.click(); } break; case 'Escape': e.preventDefault(); this.collapseAllBtn.click(); break; } } focusNextNode(nodes, direction) { const currentIndex = this.focusedNode ? Array.from(nodes).indexOf(this.focusedNode) : -1; let newIndex; if (currentIndex === -1) { newIndex = direction > 0 ? 0 : nodes.length - 1; } else { newIndex = (currentIndex + direction + nodes.length) % nodes.length; } const newNode = nodes[newIndex]; if (newNode) { newNode.focus(); this.focusedNode = newNode; newNode.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } async loadRepo() { const input = this.repoInput.value.trim(); if (!input) { this.showStatus('Please enter a repository', 'error'); return; } // Parse owner/repo format and sanitize let repo = input; if (input.includes('github.com/')) { const parts = input.split('github.com/'); if (parts[1]) { repo = parts[1].replace(/\.git$/, ''); } } // Sanitize: only allow alphanumeric, hyphens, underscores, and slashes if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) { this.showStatus('Invalid repository format. Use "owner/repo"', 'error'); return; } this.currentRepo = repo; this.showLoading(true); this.showStatus('', ''); try { // Fetch branches first if branch is set to 'load' or if we don't have branches yet if (this.branchSelect.value === 'load' || this.availableBranches.length === 0) { await this.fetchBranches(repo); } // Use selected branch or default to main const branch = this.currentBranch || 'main'; const data = await this.fetchRepoStructure(repo, branch); this.repoData = data; this.expanded.clear(); this.expanded.add('root'); this.render(); this.updateStats(data); this.showStatus(`Successfully loaded ${repo} (${branch})`, 'success'); this.emptyState.classList.add('hidden'); this.statsBar.classList.remove('hidden'); } catch (error) { this.showStatus(`Failed to load repository: ${error.message}`, 'error'); console.error(error); } finally { this.showLoading(false); } } async fetchBranches(repo) { if (this.loadingBranches) return; this.loadingBranches = true; try { const [owner, name] = repo.split('/'); const response = await fetch(`https://api.github.com/repos/${owner}/${name}/branches`, { headers: { 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { throw new Error(`Failed to fetch branches: ${response.status}`); } const branches = await response.json(); this.availableBranches = branches.map(b => b.name); // Update branch select this.branchSelect.innerHTML = ''; this.availableBranches.forEach(branch => { const option = document.createElement('option'); option.value = branch; option.textContent = branch; this.branchSelect.appendChild(option); }); // Set current branch if available if (this.availableBranches.includes(this.currentBranch)) { this.branchSelect.value = this.currentBranch; } else if (this.availableBranches.includes('main')) { this.branchSelect.value = 'main'; this.currentBranch = 'main'; } else if (this.availableBranches.length > 0) { this.branchSelect.value = this.availableBranches[0]; this.currentBranch = this.availableBranches[0]; } this.branchInput.value = this.currentBranch; } catch (error) { console.error('Failed to load branches:', error); // Fallback to main this.branchSelect.innerHTML = ''; this.currentBranch = 'main'; } finally { this.loadingBranches = false; } } async fetchRepoStructure(repo, branch) { // Check cache first const cacheKey = `${repo}:${branch}`; const cached = this.getFromCache(cacheKey); if (cached) { console.log('Loading from cache:', cacheKey); return cached; } const [owner, name] = repo.split('/'); // Check rate limit before making request if (this.rateLimitRemaining === 0 && this.rateLimitReset) { const now = Date.now(); const resetTime = this.rateLimitReset * 1000; if (now < resetTime) { const waitSeconds = Math.ceil((resetTime - now) / 1000); throw new Error(`Rate limit exceeded. Please wait ${waitSeconds} seconds or use a GitHub token.`); } } // Try with exponential backoff let lastError; for (let attempt = 0; attempt < 3; attempt++) { try { const response = await this.makeGitHubRequest(owner, name, branch); const data = await response.json(); // Update rate limit info this.updateRateLimitInfo(response); // Convert flat tree to hierarchical structure const treeData = this.buildTree(data.tree, repo); // Cache the result this.setInCache(cacheKey, treeData); return treeData; } catch (error) { lastError = error; // Don't retry on client errors (4xx except rate limit) if (error.status && error.status >= 400 && error.status < 500 && error.status !== 403) { throw error; } // Wait before retry (exponential backoff) if (attempt < 2) { const delay = Math.min(1000 * Math.pow(2, attempt), 5000); await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } async makeGitHubRequest(owner, name, branch) { const url = `https://api.github.com/repos/${owner}/${name}/git/trees/${branch}?recursive=1`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { const error = new Error(`GitHub API error: ${response.status}`); error.status = response.status; // Parse error message from response try { const errorData = await response.json(); if (errorData.message) { error.message = errorData.message; } } catch (e) { // Ignore JSON parse errors } throw error; } return response; } updateRateLimitInfo(response) { const remaining = response.headers.get('X-RateLimit-Remaining'); const reset = response.headers.get('X-RateLimit-Reset'); if (remaining !== null) { this.rateLimitRemaining = parseInt(remaining, 10); } if (reset !== null) { this.rateLimitReset = parseInt(reset, 10); } } getFromCache(key) { const cached = this.cache.get(key); if (!cached) return null; const now = Date.now(); if (now - cached.timestamp > this.cacheTimeout) { this.cache.delete(key); return null; } return cached.data; } setInCache(key, data) { this.cache.set(key, { data, timestamp: Date.now() }); } buildTree(flatTree, repoPath) { const root = { name: repoPath.split('/')[1], type: 'tree', path: '', children: [], size: 0, mode: '040000' }; const nodeMap = { '': root }; for (const item of flatTree) { const pathParts = item.path.split('/'); const fileName = pathParts[pathParts.length - 1]; const dirPath = pathParts.slice(0, -1).join('/'); // Ensure parent exists if (!nodeMap[dirPath]) { let currentPath = ''; for (const part of pathParts.slice(0, -1)) { const parentPath = currentPath; currentPath = currentPath ? `${currentPath}/${part}` : part; if (!nodeMap[currentPath]) { const parent = nodeMap[parentPath]; // Ensure parent has children array if (!parent.children) { parent.children = []; } const newNode = { name: part, type: 'tree', path: currentPath, children: [], size: 0, mode: '040000' }; parent.children.push(newNode); nodeMap[currentPath] = newNode; } } } const parent = nodeMap[dirPath]; // Ensure parent has children array if (!parent.children) { parent.children = []; } const node = { name: fileName, type: item.type === 'tree' ? 'tree' : 'blob', path: item.path, size: item.size || 0, mode: item.mode }; if (item.type === 'blob') { parent.size += item.size; } parent.children.push(node); nodeMap[item.path] = node; } // Calculate total sizes recursively const calculateSize = (node) => { if (node.type === 'blob') return node.size; let total = 0; if (node.children) { for (const child of node.children) { total += calculateSize(child); } } node.size = total; return total; }; calculateSize(root); return root; } render() { this.nodesContainer.innerHTML = ''; this.connectionsSvg.innerHTML = ''; if (!this.repoData) return; const containerWidth = this.diagram.clientWidth; const nodeWidth = 180; const verticalSpacing = 120; const horizontalSpacing = 40; // Generate nodes const nodeElements = new Map(); const layout = this.calculateLayout(this.repoData, containerWidth, nodeWidth, verticalSpacing, horizontalSpacing); // Draw connections first (so they appear behind nodes) this.drawConnections(layout, nodeElements, nodeWidth); // Draw nodes for (const [id, node] of layout) { const element = this.createNodeElement(node, this.repoData); element.style.position = 'absolute'; element.style.left = `${node.x}px`; element.style.top = `${node.y}px`; element.style.width = `${nodeWidth}px`; this.nodesContainer.appendChild(element); nodeElements.set(id, element); } } calculateLayout(root, containerWidth, nodeWidth, verticalSpacing, horizontalSpacing) { const layout = new Map(); const levelHeights = new Map(); const levelNodes = new Map(); // Collect nodes by level with search filter const collectNodes = (node, level, parentId, index) => { const id = node.path || 'root'; // Check if node matches search const matchesSearch = !this.searchQuery || node.name.toLowerCase().includes(this.searchQuery); // Determine if we should show this node const isVisible = matchesSearch || (this.expanded.has(id) && level < this.maxDepth); if (isVisible) { if (!levelNodes.has(level)) { levelNodes.set(level, []); } levelNodes.get(level).push({ node, id, parentId, index }); } // Recursively collect children if expanded if (this.expanded.has(id) && node.children && level < this.maxDepth) { let childIndex = 0; for (const child of node.children) { collectNodes(child, level + 1, id, childIndex); childIndex++; } } }; collectNodes(root, 0, null, 0); // Calculate positions for (const [level, nodes] of levelNodes) { const totalWidth = nodes.length * (nodeWidth + horizontalSpacing) - horizontalSpacing; let startX = (containerWidth - totalWidth) / 2; for (let i = 0; i < nodes.length; i++) { const { node, id } = nodes[i]; const x = startX + i * (nodeWidth + horizontalSpacing); const y = level * verticalSpacing + 50; layout.set(id, { node, x, y, level }); } } return layout; } drawConnections(layout, nodeElements, nodeWidth) { const svg = this.connectionsSvg; svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); for (const [id, { node, x, y }] of layout) { if (node.path && node.path !== '') { // Find parent const pathParts = node.path.split('/'); const parentPath = pathParts.slice(0, -1).join('/'); const parentLayout = layout.get(parentPath || 'root'); if (parentLayout) { const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', parentLayout.x + nodeWidth / 2); line.setAttribute('y1', parentLayout.y + 50); line.setAttribute('x2', x + nodeWidth / 2); line.setAttribute('y2', y); line.setAttribute('class', 'connector'); svg.appendChild(line); } } } } createNodeElement(node, root) { const container = document.createElement('div'); container.className = 'node bg-white rounded-xl shadow-md p-4 border-2'; container.setAttribute('tabindex', '0'); container.setAttribute('role', 'treeitem'); container.setAttribute('aria-label', `${node.type === 'tree' ? 'Folder' : 'File'}: ${node.name}`); container.setAttribute('aria-expanded', node.type === 'tree' ? (this.expanded.has(node.path || 'root') ? 'true' : 'false') : 'null'); container.dataset.path = node.path || 'root'; container.dataset.type = node.type; // Set border color based on type and level const level = node.path ? node.path.split('/').length : 0; let borderColor; if (node.path === '') { borderColor = 'border-blue-500'; } else if (node.type === 'tree') { const colors = ['border-blue-300', 'border-green-300', 'border-yellow-300', 'border-purple-300', 'border-pink-300']; borderColor = colors[(level - 1) % colors.length]; } else { borderColor = 'border-slate-300'; } container.classList.add(borderColor); // Icon based on type const icon = node.type === 'tree' ? '📁' : '📄'; const isDirectory = node.type === 'tree'; // File count for directories let fileCount = ''; if (isDirectory) { const fileCountValue = this.countFiles(node); fileCount = `