cvs-proxy/ui/ui.js
Juan José Gutiérrez de Quevedo Pérez d3b40ae93f feat: Add patchset history and diff support with cvsps integration
- Add cvsps package to Docker dependencies
- Implement patchset retrieval and diff endpoints in API
- Add _run_cvsps_command() helper for cvsps integration
- Enhance file history parsing with log message extraction
- Improve UI with enhanced styling and patchset functionality
2025-11-21 17:18:55 +01:00

567 lines
No EOL
20 KiB
JavaScript

/**
* 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 = '<div class="loading">No history available</div>';
this.showView(this.historyView);
return;
}
const historyHTML = history.map(revision => `
<div class="history-item" data-revision="${revision.revision}">
<div class="history-revision">${revision.revision}</div>
<div class="history-log-section">
<div class="history-log">${revision.log ? this.escapeHtml(revision.log) : ''}</div>
${revision.state && revision.state !== 'Exp' ? `<span class="history-state">${revision.state}</span>` : ''}
</div>
<div class="history-author">${revision.author || 'Unknown'}</div>
<div class="history-date" title="${revision.date || 'N/A'}">${this.getRelativeDate(revision.date) || 'N/A'}</div>
</div>
`).join('');
this.historyContent.innerHTML = historyHTML;
this.showView(this.historyView);
// Add click handlers to history items
this.historyContent.querySelectorAll('.history-item').forEach(item => {
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;
window.app.loadFileAtRevision(this.currentFile, revision);
});
});
}
/**
* Display diff
* @param {string} diffText - Diff content
*/
displayDiff(diffText) {
if (!diffText) {
this.diffContent.innerHTML = '<div class="loading">No differences found</div>';
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 = '<option>No revisions available</option>';
this.rev2Select.innerHTML = '<option>No revisions available</option>';
return;
}
const options = history.map(rev =>
`<option value="${rev.revision}">${rev.revision} - ${rev.date || 'N/A'}</option>`
).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 = `<div class="loading">${message}</div>`;
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
this.fileContent.innerHTML = `<div class="loading" style="color: #991b1b;">${this.escapeHtml(message)}</div>`;
}
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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 = '<div class="loading">No files found</div>';
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 = '<div class="loading">No patchsets available</div>';
this.showView(this.patchsetView);
return;
}
const patchsetHTML = patchsets.map(ps => `
<div class="patchset-item" data-patchset="${ps.patchset}">
<div class="patchset-number">PatchSet #${ps.patchset}</div>
<div class="patchset-log-section">
<div class="patchset-log">${ps.log ? this.escapeHtml(ps.log) : ''}</div>
${ps.tag && ps.tag !== 'N/A' ? `<span class="patchset-tag">${ps.tag}</span>` : ''}
</div>
<div class="patchset-author">${ps.author || 'Unknown'}</div>
<div class="patchset-date" title="${ps.date || 'N/A'}">${this.getRelativeDate(ps.date) || 'N/A'}</div>
</div>
`).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 = '<div class="loading">No diff available</div>';
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();