/** * UI Management Module * Handles DOM manipulation and view switching */ class UIManager { constructor() { this.currentFile = null; this.currentHistory = null; this.initializeElements(); this.initializeTheme(); } /** * Convert a date string to relative format (e.g., "2 hours ago") * @param {string} dateStr - Date string in format "YYYY/MM/DD HH:MM:SS" * @returns {string} Relative date string */ getRelativeDate(dateStr) { try { // Parse the date string (format: "YYYY/MM/DD HH:MM:SS") const parts = dateStr.match(/(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/); if (!parts) return dateStr; const date = new Date(parts[1], parts[2] - 1, parts[3], parts[4], parts[5], parts[6]); const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)} minute${Math.floor(seconds / 60) > 1 ? 's' : ''} ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)} hour${Math.floor(seconds / 3600) > 1 ? 's' : ''} ago`; if (seconds < 604800) return `${Math.floor(seconds / 86400)} day${Math.floor(seconds / 86400) > 1 ? 's' : ''} ago`; if (seconds < 2592000) return `${Math.floor(seconds / 604800)} week${Math.floor(seconds / 604800) > 1 ? 's' : ''} ago`; if (seconds < 31536000) return `${Math.floor(seconds / 2592000)} month${Math.floor(seconds / 2592000) > 1 ? 's' : ''} ago`; return `${Math.floor(seconds / 31536000)} year${Math.floor(seconds / 31536000) > 1 ? 's' : ''} ago`; } catch (e) { return dateStr; } } initializeElements() { // Views this.welcomeView = document.getElementById('welcomeView'); this.fileView = document.getElementById('fileView'); this.historyView = document.getElementById('historyView'); this.diffView = document.getElementById('diffView'); this.patchsetView = document.getElementById('patchsetView'); this.patchsetDiffView = document.getElementById('patchsetDiffView'); // File view elements this.fileName = document.getElementById('fileName'); this.filePath = document.getElementById('filePath'); this.fileContent = document.getElementById('fileContent'); this.historyBtn = document.getElementById('historyBtn'); this.diffBtn = document.getElementById('diffBtn'); // History view elements this.historyContent = document.getElementById('historyContent'); this.backFromHistoryBtn = document.getElementById('backFromHistoryBtn'); // Diff view elements this.rev1Select = document.getElementById('rev1Select'); this.rev2Select = document.getElementById('rev2Select'); this.generateDiffBtn = document.getElementById('generateDiffBtn'); this.diffContent = document.getElementById('diffContent'); this.backFromDiffBtn = document.getElementById('backFromDiffBtn'); // Patchset view elements this.patchsetContent = document.getElementById('patchsetContent'); this.backFromPatchsetBtn = document.getElementById('backFromPatchsetBtn'); this.patchsetsBtn = document.getElementById('patchsetsBtn'); // Patchset diff view elements this.patchsetDiffContent = document.getElementById('patchsetDiffContent'); this.backFromPatchsetDiffBtn = document.getElementById('backFromPatchsetDiffBtn'); // Tree elements this.treeContainer = document.getElementById('treeContainer'); // Status this.status = document.getElementById('status'); // Theme toggle button this.themeToggleBtn = document.getElementById('themeToggleBtn'); } /** * Initialize theme based on system preference or saved preference */ initializeTheme() { // Check for saved theme preference const savedTheme = localStorage.getItem('theme'); if (savedTheme) { // Use saved preference this.setTheme(savedTheme); } else { // Check system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; this.setTheme(prefersDark ? 'dark' : 'light'); } // Setup theme toggle button listener if (this.themeToggleBtn) { this.themeToggleBtn.addEventListener('click', () => this.toggleTheme()); } } /** * Set the theme * @param {string} theme - 'light' or 'dark' */ setTheme(theme) { if (theme === 'dark') { document.documentElement.setAttribute('data-theme', 'dark'); this.updateThemeButton('☀️'); this.updateHighlightTheme('dark'); } else { document.documentElement.removeAttribute('data-theme'); this.updateThemeButton('🌙'); this.updateHighlightTheme('light'); } localStorage.setItem('theme', theme); } /** * Update highlight.js theme based on current theme * @param {string} theme - 'light' or 'dark' */ updateHighlightTheme(theme) { const highlightLink = document.getElementById('highlightTheme'); if (highlightLink) { const baseUrl = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/'; const stylesheet = theme === 'dark' ? 'atom-one-dark.min.css' : 'atom-one-light.min.css'; highlightLink.href = baseUrl + stylesheet; } } /** * Toggle between light and dark themes */ toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; this.setTheme(newTheme); } /** * Update theme toggle button icon * @param {string} icon - Icon emoji */ updateThemeButton(icon) { if (this.themeToggleBtn) { this.themeToggleBtn.textContent = icon; } } /** * Show a specific view and hide others * @param {HTMLElement} view - View to show */ showView(view) { [this.welcomeView, this.fileView, this.historyView, this.diffView, this.patchsetView, this.patchsetDiffView].forEach(v => { if (v) v.classList.add('hidden'); }); if (view) view.classList.remove('hidden'); } /** * Display file content * @param {string} path - File path * @param {string} content - File content */ displayFile(path, content) { this.currentFile = path; this.fileName.textContent = path.split('/').pop(); this.filePath.textContent = path; // Detect language from file extension const extension = path.split('.').pop().toLowerCase(); const languageMap = { 'js': 'javascript', 'py': 'python', 'java': 'java', 'cpp': 'cpp', 'c': 'c', 'h': 'c', 'hpp': 'cpp', 'cs': 'csharp', 'rb': 'ruby', 'go': 'go', 'rs': 'rust', 'php': 'php', 'html': 'html', 'xml': 'xml', 'css': 'css', 'json': 'json', 'yaml': 'yaml', 'yml': 'yaml', 'sh': 'bash', 'bash': 'bash', 'sql': 'sql', 'md': 'markdown', 'txt': 'plaintext' }; const language = languageMap[extension] || 'plaintext'; // Create code element with language class const codeElement = document.createElement('code'); codeElement.className = `language-${language}`; codeElement.textContent = content; const preElement = document.createElement('pre'); preElement.appendChild(codeElement); this.fileContent.innerHTML = ''; this.fileContent.appendChild(preElement); // Apply syntax highlighting if (window.hljs) { hljs.highlightElement(codeElement); } this.showView(this.fileView); } /** * Display file history * @param {Array} history - Array of revision objects */ displayHistory(history) { this.currentHistory = history; if (!history || history.length === 0) { this.historyContent.innerHTML = '
No history available
'; this.showView(this.historyView); return; } const historyHTML = history.map(revision => `
${revision.revision}
${revision.log ? this.escapeHtml(revision.log) : ''}
${revision.state && revision.state !== 'Exp' ? `${revision.state}` : ''}
${revision.author || 'Unknown'}
${this.getRelativeDate(revision.date) || 'N/A'}
`).join(''); this.historyContent.innerHTML = historyHTML; this.showView(this.historyView); // Add click handlers to history items this.historyContent.querySelectorAll('.history-item').forEach((item, index) => { item.addEventListener('click', () => { // Remove active class from all items this.historyContent.querySelectorAll('.history-item').forEach(i => i.classList.remove('active')); // Add active class to clicked item item.classList.add('active'); const revision = item.dataset.revision; // Get the previous revision if it exists const previousRevision = index < history.length - 1 ? history[index + 1].revision : null; // Show diff between current and previous revision window.app.showHistoryItemDiff(this.currentFile, revision, previousRevision); }); }); } /** * Display diff * @param {string} diffText - Diff content */ displayDiff(diffText) { if (!diffText) { this.diffContent.innerHTML = '
No differences found
'; this.showView(this.diffView); return; } // Create code element with diff language class const codeElement = document.createElement('code'); codeElement.className = 'language-diff'; codeElement.textContent = diffText; const preElement = document.createElement('pre'); preElement.appendChild(codeElement); this.diffContent.innerHTML = ''; this.diffContent.appendChild(preElement); // Apply syntax highlighting if (window.hljs) { hljs.highlightElement(codeElement); } this.showView(this.diffView); } /** * Populate revision selectors * @param {Array} history - Array of revision objects */ populateRevisionSelectors(history) { if (!history || history.length === 0) { this.rev1Select.innerHTML = ''; this.rev2Select.innerHTML = ''; return; } const options = history.map(rev => `` ).join(''); this.rev1Select.innerHTML = options; this.rev2Select.innerHTML = options; // Set default selections if (history.length > 1) { this.rev1Select.selectedIndex = history.length - 1; this.rev2Select.selectedIndex = 0; } } /** * Update status indicator * @param {string} message - Status message * @param {string} type - Status type: 'connecting', 'connected', 'error' */ updateStatus(message, type = 'connecting') { this.status.textContent = message; this.status.className = `status ${type}`; } /** * Show loading state * @param {string} message - Loading message */ showLoading(message = 'Loading...') { this.fileContent.innerHTML = `
${message}
`; } /** * Show error message * @param {string} message - Error message */ showError(message) { this.fileContent.innerHTML = `
${this.escapeHtml(message)}
`; } /** * Escape HTML special characters * @param {string} text - Text to escape * @returns {string} Escaped text */ escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } /** * Build tree view from file list * @param {Array} files - Array of file paths * @param {Function} onFileClick - Callback when file is clicked */ buildTree(files, onFileClick) { if (!files || files.length === 0) { this.treeContainer.innerHTML = '
No files found
'; return; } // Build hierarchical tree structure const tree = {}; files.forEach(file => { const parts = file.split('/'); let current = tree; parts.forEach((part, index) => { if (!current[part]) { current[part] = {}; } current = current[part]; }); }); // Render tree this.treeContainer.innerHTML = ''; this.renderTreeNode(tree, '', this.treeContainer, onFileClick); } /** * Recursively render tree nodes with collapsible folders * @param {object} node - Tree node * @param {string} path - Current path * @param {HTMLElement} container - Container element * @param {Function} onFileClick - Callback when file is clicked * @param {number} depth - Current depth level for indentation */ renderTreeNode(node, path, container, onFileClick, depth = 0) { const keys = Object.keys(node).sort(); keys.forEach(key => { const fullPath = path ? `${path}/${key}` : key; const isFile = Object.keys(node[key]).length === 0; const item = document.createElement('div'); item.className = 'tree-item'; item.style.paddingLeft = `${0.5 + depth * 1.25}rem`; if (isFile) { // File item const icon = document.createElement('span'); icon.className = 'tree-item-icon'; icon.textContent = '📄'; const name = document.createElement('span'); name.className = 'tree-item-name'; name.textContent = key; item.appendChild(icon); item.appendChild(name); item.addEventListener('click', () => { // Remove active class from all items container.querySelectorAll('.tree-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); onFileClick(fullPath); }); } else { // Folder item with toggle const toggleBtn = document.createElement('span'); toggleBtn.className = 'tree-item-toggle'; toggleBtn.textContent = '▼'; toggleBtn.style.cursor = 'pointer'; toggleBtn.style.marginRight = '0.25rem'; const icon = document.createElement('span'); icon.className = 'tree-item-icon'; icon.textContent = '📁'; const name = document.createElement('span'); name.className = 'tree-item-name'; name.textContent = key; item.appendChild(toggleBtn); item.appendChild(icon); item.appendChild(name); // Create container for nested items const nestedContainer = document.createElement('div'); nestedContainer.className = 'tree-nested'; nestedContainer.style.display = 'block'; // Add toggle functionality const toggleFolder = () => { const isExpanded = nestedContainer.style.display !== 'none'; nestedContainer.style.display = isExpanded ? 'none' : 'block'; toggleBtn.textContent = isExpanded ? '▶' : '▼'; }; toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleFolder(); }); item.addEventListener('click', (e) => { e.stopPropagation(); toggleFolder(); }); container.appendChild(item); container.appendChild(nestedContainer); // Recursively render subdirectories this.renderTreeNode(node[key], fullPath, nestedContainer, onFileClick, depth + 1); return; } container.appendChild(item); }); } /** * Clear active selection in tree */ clearTreeSelection() { this.treeContainer.querySelectorAll('.tree-item').forEach(item => { item.classList.remove('active'); }); } /** * Display patchsets * @param {Array} patchsets - Array of patchset objects */ displayPatchsets(patchsets) { if (!patchsets || patchsets.length === 0) { this.patchsetContent.innerHTML = '
No patchsets available
'; this.showView(this.patchsetView); return; } const patchsetHTML = patchsets.map(ps => `
PatchSet #${ps.patchset}
${ps.log ? this.escapeHtml(ps.log) : ''}
${ps.tag && ps.tag !== 'N/A' && ps.tag.toLowerCase() !== '(none)' ? `${ps.tag}` : ''}
${ps.author || 'Unknown'}
${this.getRelativeDate(ps.date) || 'N/A'}
`).join(''); this.patchsetContent.innerHTML = patchsetHTML; this.showView(this.patchsetView); // Add click handlers to patchset items this.patchsetContent.querySelectorAll('.patchset-item').forEach(item => { item.addEventListener('click', () => { // Remove active class from all items this.patchsetContent.querySelectorAll('.patchset-item').forEach(i => i.classList.remove('active')); // Add active class to clicked item item.classList.add('active'); const patchset = item.dataset.patchset; window.app.showPatchsetDiff(patchset); }); }); } /** * Display patchset diff * @param {string} diffText - Diff content */ displayPatchsetDiff(diffText) { if (!diffText) { this.patchsetDiffContent.innerHTML = '
No diff available
'; this.showView(this.patchsetDiffView); return; } // Create code element with diff language class const codeElement = document.createElement('code'); codeElement.className = 'language-diff'; codeElement.textContent = diffText; const preElement = document.createElement('pre'); preElement.appendChild(codeElement); this.patchsetDiffContent.innerHTML = ''; this.patchsetDiffContent.appendChild(preElement); // Apply syntax highlighting if (window.hljs) { hljs.highlightElement(codeElement); } this.showView(this.patchsetDiffView); } } // Create global UI manager instance const ui = new UIManager();