- Modified cvs_client.py to skip tags with value '(none)' using case-insensitive comparison - Updated ui.js to also check for '(none)' tags when displaying patchsets - Patchsets without tags no longer show any tag badge in the UI
567 lines
No EOL
20 KiB
JavaScript
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 = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
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' && ps.tag.toLowerCase() !== '(none)' ? `<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(); |