// 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 = `
${fileCountValue} items
`; } // Size display const size = node.size > 0 ? this.formatSize(node.size) : ''; const sizeDisplay = size ? `
${size}
` : ''; // Expand/collapse button for directories let expandBtn = ''; if (isDirectory && node.children.length > 0) { const isExpanded = this.expanded.has(node.path || 'root'); const chevron = isExpanded ? '▼' : '▶'; expandBtn = ``; } // Search highlight let displayName = node.name; if (this.searchQuery && node.name.toLowerCase().includes(this.searchQuery)) { const regex = new RegExp(`(${this.searchQuery})`, 'gi'); displayName = node.name.replace(regex, '$1'); } container.innerHTML = ` ${expandBtn}
${icon}
${displayName}
${fileCount} ${sizeDisplay}
`; // Event listeners if (isDirectory) { container.addEventListener('click', (e) => { // Don't toggle if clicking expand button directly if (e.target.classList.contains('expand-btn')) { e.stopPropagation(); } this.toggleDirectory(node); }); const expandBtnEl = container.querySelector('.expand-btn'); if (expandBtnEl) { expandBtnEl.addEventListener('click', (e) => { e.stopPropagation(); this.toggleDirectory(node); }); } } else { container.addEventListener('click', () => { this.showFileInfo(node); }); } // Focus event for keyboard navigation container.addEventListener('focus', () => { this.focusedNode = container; container.classList.add('ring-2', 'ring-blue-500'); }); container.addEventListener('blur', () => { container.classList.remove('ring-2', 'ring-blue-500'); }); return container; } toggleDirectory(node) { const path = node.path || 'root'; if (this.expanded.has(path)) { this.expanded.delete(path); } else { this.expanded.add(path); } this.render(); } expandAll(node) { this.expanded.add(node.path || 'root'); if (node.children) { for (const child of node.children) { if (child.type === 'tree') { this.expandAll(child); } } } } countFiles(node) { if (node.type === 'blob') return 1; let count = 0; for (const child of node.children) { count += this.countFiles(child); } return count; } formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'; } showStatus(message, type) { this.status.textContent = message; this.status.className = `p-4 rounded-lg mb-6 ${type ? `status-${type}` : 'hidden'}`; if (type) { this.status.classList.remove('hidden'); } else { this.status.classList.add('hidden'); } } showLoading(show) { this.loading.classList.toggle('hidden', !show); this.loadBtn.disabled = show; } updateStats(data) { const stats = this.collectStats(data); document.getElementById('totalFiles').textContent = stats.files; document.getElementById('totalDirs').textContent = stats.dirs; document.getElementById('totalLines').textContent = '~' + stats.lines.toLocaleString(); document.getElementById('repoSize').textContent = this.formatSize(stats.size); } collectStats(node) { let files = 0; let dirs = 0; let lines = 0; let size = 0; const traverse = (n) => { if (n.type === 'blob') { files++; size += n.size; lines += Math.floor(n.size / 50); // rough estimate } else { dirs++; for (const child of n.children) { traverse(child); } } }; traverse(node); return { files, dirs, lines, size }; } showFileInfo(file) { // Create or reuse modal let modal = document.getElementById('fileInfoModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'fileInfoModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50'; modal.innerHTML = `

File Information

`; document.body.appendChild(modal); // Event listeners modal.querySelector('#closeModal').addEventListener('click', () => this.hideModal()); modal.querySelector('#closeModalBtn').addEventListener('click', () => this.hideModal()); modal.querySelector('#copyPath').addEventListener('click', () => this.copyPathToClipboard(file.path)); modal.querySelector('#viewFullFile').addEventListener('click', () => { window.open(`https://github.com/${this.currentRepo}/blob/${this.currentBranch}/${file.path}`, '_blank'); }); modal.addEventListener('click', (e) => { if (e.target === modal) this.hideModal(); }); } // Update modal content const content = modal.querySelector('#modalContent'); const filePreview = modal.querySelector('#filePreview'); const previewContent = modal.querySelector('#previewContent'); const viewFullFileBtn = modal.querySelector('#viewFullFile'); content.innerHTML = `
Name: ${this.escapeHtml(file.name)}
Type: ${file.type === 'tree' ? '📁 Directory' : '📄 File'}
Path: ${this.escapeHtml(file.path)}
Size: ${this.formatSize(file.size)}
Mode: ${file.mode}
`; // If it's a file and likely text, try to fetch and show preview if (file.type === 'blob' && this.isTextFile(file.name)) { filePreview.classList.remove('hidden'); viewFullFileBtn.classList.remove('hidden'); this.fetchFilePreview(file.path, previewContent); } else { filePreview.classList.add('hidden'); viewFullFileBtn.classList.add('hidden'); } modal.classList.remove('hidden'); modal.classList.add('flex'); } hideModal() { const modal = document.getElementById('fileInfoModal'); if (modal) { modal.classList.add('hidden'); modal.classList.remove('flex'); } } copyPathToClipboard(path) { navigator.clipboard.writeText(path).then(() => { this.showStatus('Path copied to clipboard!', 'success'); setTimeout(() => this.hideModal(), 1000); }).catch(() => { this.showStatus('Failed to copy path', 'error'); }); } isTextFile(filename) { const textExtensions = [ '.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.config', '.properties', '.go', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.hpp', '.cs', '.php', '.rb', '.rs', '.swift', '.kt', '.scala', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.html', '.htm', '.css', '.scss', '.sass', '.less', '.sql', '.graphql', '.gql', '.r', '.m', '.mat', '.ml', '.lisp', '.scheme', '.clj', '.hs', '.purs', '.elm', '.vue', '.svelte', '.jsx', '.tsx', '.dockerfile', '.makefile', '.mk', '.cmake', '.gitignore', '.gitattributes', '.gitmodules', '.env', '.example', '.sample', '.log', '.csv', '.tsv', '.diff', '.patch' ]; const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')); return textExtensions.includes(ext) || filename.toLowerCase().startsWith('readme'); } async fetchFilePreview(path, container) { const [owner, name] = this.currentRepo.split('/'); const url = `https://api.github.com/repos/${owner}/${name}/contents/${path}?ref=${this.currentBranch}`; try { const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { throw new Error('Failed to fetch file content'); } const data = await response.json(); if (data.content) { // Decode base64 content const decoded = atob(data.content); // Truncate if too long const maxLength = 5000; const content = decoded.length > maxLength ? decoded.substring(0, maxLength) + '\n... (truncated)' : decoded; container.textContent = content; } else { container.textContent = 'No content available'; } } catch (error) { container.textContent = `Failed to load preview: ${error.message}`; } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } exportSVG() { const svg = document.getElementById('connections'); const nodes = document.getElementById('nodes'); if (!svg || !nodes) { this.showStatus('No diagram to export', 'error'); return; } // Create a combined SVG const exportSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); exportSvg.setAttribute('width', this.diagram.clientWidth); exportSvg.setAttribute('height', this.diagram.clientHeight); exportSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); // Clone connections const clonedConnections = svg.cloneNode(true); exportSvg.appendChild(clonedConnections); // Convert HTML nodes to SVG groups (simplified) // In a production version, we'd convert each node to SVG elements // For now, we'll just export the connections const svgData = new XMLSerializer().serializeToString(exportSvg); const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.currentRepo.replace('/', '-')}-diagram.svg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showStatus('SVG exported!', 'success'); } exportPNG() { // Simple PNG export using canvas const diagram = this.diagram; const nodes = this.nodesContainer; const connections = this.connectionsSvg; if (!this.repoData) { this.showStatus('No diagram to export', 'error'); return; } // Create a canvas const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const width = diagram.clientWidth; const height = diagram.clientHeight; canvas.width = width; canvas.height = height; // White background ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); // Draw connections const connLines = connections.querySelectorAll('line'); ctx.strokeStyle = '#94a3b8'; ctx.lineWidth = 2; connLines.forEach(line => { const x1 = parseFloat(line.getAttribute('x1')); const y1 = parseFloat(line.getAttribute('y1')); const x2 = parseFloat(line.getAttribute('x2')); const y2 = parseFloat(line.getAttribute('y2')); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); }); // Draw nodes const nodeElements = nodes.querySelectorAll('.node'); ctx.font = '14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; nodeElements.forEach(node => { const x = parseFloat(node.style.left) + 90; // center (180/2) const y = parseFloat(node.style.top) + 40; // center (80/2) const type = node.dataset.type; const name = node.querySelector('.node-name').textContent; const rect = node.querySelector('div:first-child'); // the inner content div // Node background ctx.fillStyle = '#ffffff'; ctx.strokeStyle = type === 'tree' ? '#3b82f6' : '#94a3b8'; ctx.lineWidth = 2; ctx.beginPath(); ctx.roundRect(x - 90, y - 40, 180, 80, 8); ctx.fill(); ctx.stroke(); // Icon const icon = type === 'tree' ? '📁' : '📄'; ctx.font = '24px sans-serif'; ctx.fillText(icon, x, y - 15); // Name ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#1e293b'; ctx.fillText(name, x, y + 10); }); // Convert to PNG and download canvas.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.currentRepo.replace('/', '-')}-diagram.png`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showStatus('PNG exported!', 'success'); }, 'image/png'); } toggleDarkMode() { const body = document.body; const controls = this.controlsBg; const isDark = body.classList.toggle('dark'); // Save preference to localStorage localStorage.setItem('darkMode', isDark); if (isDark) { // Remove light gradients body.classList.remove('from-slate-50', 'via-blue-50', 'to-purple-50'); // Add dark gradients body.classList.add('from-slate-900', 'via-slate-800', 'to-slate-900'); // Controls controls.classList.remove('bg-white', 'border-slate-200'); controls.classList.add('bg-slate-800', 'border-slate-700'); // Update button this.darkModeBtn.innerHTML = ` Light Mode `; } else { // Remove dark gradients body.classList.remove('from-slate-900', 'via-slate-800', 'to-slate-900'); // Add light gradients body.classList.add('from-slate-50', 'via-blue-50', 'to-purple-50'); // Controls controls.classList.add('bg-white', 'border-slate-200'); controls.classList.remove('bg-slate-800', 'border-slate-700'); // Update button this.darkModeBtn.innerHTML = ` Dark Mode `; } } } // Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.app = new RepoDiagram(); });