Spaces:
Sleeping
Sleeping
| // 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 = ` | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/> | |
| </svg> | |
| 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 = '<option value="main">main</option>'; | |
| 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 = `<div class="text-xs text-slate-500 mt-1">${fileCountValue} items</div>`; | |
| } | |
| // Size display | |
| const size = node.size > 0 ? this.formatSize(node.size) : ''; | |
| const sizeDisplay = size ? `<div class="text-xs text-slate-500">${size}</div>` : ''; | |
| // 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 = `<button class="expand-btn absolute -left-2 top-1/2 transform -translate-y-1/2 w-6 h-6 bg-white border border-slate-300 rounded-full flex items-center justify-center text-xs hover:bg-slate-50">${chevron}</button>`; | |
| } | |
| // 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, '<span class="search-highlight">$1</span>'); | |
| } | |
| container.innerHTML = ` | |
| ${expandBtn} | |
| <div class="node-content"> | |
| <div class="node-icon">${icon}</div> | |
| <div class="node-name">${displayName}</div> | |
| ${fileCount} | |
| ${sizeDisplay} | |
| </div> | |
| `; | |
| // 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 = ` | |
| <div class="bg-white rounded-xl shadow-2xl p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-bold text-slate-800">File Information</h3> | |
| <button id="closeModal" class="text-slate-500 hover:text-slate-700 text-2xl">×</button> | |
| </div> | |
| <div class="flex-1 overflow-y-auto"> | |
| <div id="modalContent" class="text-slate-700 space-y-3"></div> | |
| <div id="filePreview" class="mt-4 hidden"> | |
| <h4 class="font-semibold mb-2 text-slate-800">Preview:</h4> | |
| <pre id="previewContent" class="bg-slate-50 border border-slate-200 rounded-lg p-4 overflow-x-auto text-sm font-mono max-h-96"></pre> | |
| </div> | |
| </div> | |
| <div class="mt-6 flex justify-end gap-2 flex-shrink-0"> | |
| <button id="viewFullFile" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 hidden">View Full File</button> | |
| <button id="copyPath" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Copy Path</button> | |
| <button id="closeModalBtn" class="px-4 py-2 bg-slate-200 text-slate-800 rounded-lg hover:bg-slate-300">Close</button> | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="grid grid-cols-2 gap-3"> | |
| <div><strong>Name:</strong> <span class="font-mono">${this.escapeHtml(file.name)}</span></div> | |
| <div><strong>Type:</strong> ${file.type === 'tree' ? '📁 Directory' : '📄 File'}</div> | |
| <div class="col-span-2"><strong>Path:</strong> <span class="font-mono text-sm break-all">${this.escapeHtml(file.path)}</span></div> | |
| <div><strong>Size:</strong> ${this.formatSize(file.size)}</div> | |
| <div><strong>Mode:</strong> <span class="font-mono text-sm">${file.mode}</span></div> | |
| </div> | |
| `; | |
| // 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 = ` | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/> | |
| </svg> | |
| 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 = ` | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/> | |
| </svg> | |
| Dark Mode | |
| `; | |
| } | |
| } | |
| } | |
| // Initialize app when DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| window.app = new RepoDiagram(); | |
| }); |