Initial commit

This commit is contained in:
Juan José Gutiérrez de Quevedo Pérez 2025-11-20 20:38:29 +01:00
commit f2817bc1f1
17 changed files with 2785 additions and 0 deletions

157
ui/README.md Normal file
View file

@ -0,0 +1,157 @@
# CVS Repository Browser UI
A modern, responsive web-based interface for browsing CVS repositories. Built with vanilla HTML, CSS, and JavaScript.
## Features
- **Repository Tree Navigation**: Browse the complete repository structure with an intuitive file tree
- **File Viewing**: Display file contents with syntax highlighting support
- **Revision History**: View complete revision history for any file with author, date, and change information
- **Diff Viewer**: Compare different versions of files with color-coded diff output
- **Real-time Status**: Connection status indicator showing API health
- **Responsive Design**: Works on desktop and mobile devices
## Architecture
### File Structure
```
ui/
├── index.html # Main HTML structure
├── styles.css # Complete styling and responsive design
├── api.js # API client for backend communication
├── ui.js # UI management and DOM manipulation
├── app.js # Main application logic and orchestration
└── README.md # This file
```
### Components
#### `api.js` - API Client
- Handles all communication with the backend `/v1` endpoints
- Methods:
- `getTree(module)` - Get repository structure
- `getFileContent(filePath, revision)` - Get file contents
- `getFileHistory(filePath)` - Get revision history
- `getDiff(filePath, rev1, rev2)` - Get diff between revisions
- `getHealth()` - Check API health
#### `ui.js` - UI Manager
- Manages DOM elements and view switching
- Handles tree rendering and navigation
- Displays file content, history, and diffs
- Provides utility functions for HTML escaping and formatting
#### `app.js` - Application Logic
- Orchestrates API calls and UI updates
- Manages application state
- Handles user interactions and event listeners
- Initializes the application on page load
#### `styles.css` - Styling
- Modern, clean design with CSS variables for theming
- Responsive layout that adapts to different screen sizes
- Color-coded diff display
- Smooth transitions and hover effects
## Usage
### Starting the Application
The UI is automatically served by the Flask application when you start the CVS Proxy:
```bash
python -m cvs_proxy.app
```
Then navigate to `http://localhost:5000` in your browser.
### Navigation
1. **Browse Repository**: The left sidebar shows the repository tree. Click on any file to view its contents.
2. **View File**: When a file is selected, its contents are displayed in the main area with action buttons.
3. **View History**: Click the "📋 History" button to see all revisions of the current file. Click on any revision to view that version.
4. **Compare Versions**: Click the "🔀 Diff" button to compare two revisions. Select the revisions from the dropdowns and click "Generate Diff".
5. **Refresh**: Click the refresh button (🔄) in the sidebar to reload the repository tree.
## Styling
The UI uses CSS custom properties (variables) for easy theming:
```css
--primary-color: #2563eb
--secondary-color: #6b7280
--success-color: #10b981
--danger-color: #ef4444
--bg-color: #f9fafb
--surface-color: #ffffff
--border-color: #e5e7eb
--text-primary: #111827
--text-secondary: #6b7280
```
## Responsive Design
The UI is fully responsive and adapts to different screen sizes:
- **Desktop**: Sidebar on the left, main content on the right
- **Tablet/Mobile**: Sidebar collapses to a smaller height, content stacks vertically
## Error Handling
The application includes comprehensive error handling:
- Connection errors are displayed in the status indicator
- API errors are shown in the main content area
- Loading states provide user feedback during operations
- Graceful fallbacks for missing data
## Browser Compatibility
- Chrome/Chromium (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
## Development
### Adding New Features
1. **New API Endpoints**: Add methods to the `CVSProxyAPI` class in `api.js`
2. **UI Changes**: Update the HTML in `index.html` and add styles to `styles.css`
3. **Logic Changes**: Modify the `CVSRepositoryBrowser` class in `app.js`
4. **UI Utilities**: Add helper methods to the `UIManager` class in `ui.js`
### Debugging
Open the browser's Developer Console (F12) to see:
- API requests and responses
- JavaScript errors
- Application state logs
## Performance Considerations
- File content is loaded on-demand
- Tree structure is built once and cached
- Diff generation is performed server-side
- Minimal DOM manipulation for smooth interactions
## Security
- HTML content is properly escaped to prevent XSS attacks
- API calls use standard HTTP methods
- No sensitive data is stored in the browser
- CORS headers are handled by the Flask backend
## Future Enhancements
- Search functionality for files
- Syntax highlighting for code files
- Blame view showing who changed each line
- Branch/tag support
- Download file functionality
- Keyboard shortcuts for navigation

112
ui/api.js Normal file
View file

@ -0,0 +1,112 @@
/**
* CVS Proxy API Client
* Handles all API calls to the backend
*/
class CVSProxyAPI {
constructor(baseURL = '') {
this.baseURL = baseURL || '';
}
/**
* Make a fetch request to the API
* @param {string} endpoint - API endpoint
* @param {object} options - Fetch options
* @returns {Promise} Response data
*/
async request(endpoint, options = {}) {
const url = `${this.baseURL}/v1${endpoint}`;
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `HTTP ${response.status}`);
}
// Handle different content types
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Get repository tree structure
* @param {string} module - Optional module to list
* @returns {Promise<Array>} List of files and directories
*/
async getTree(module = null) {
const params = new URLSearchParams();
if (module) {
params.append('module', module);
}
const query = params.toString() ? `?${params.toString()}` : '';
return this.request(`/tree${query}`);
}
/**
* Get file content
* @param {string} filePath - Path to the file
* @param {string} revision - Optional specific revision
* @returns {Promise<string>} File content
*/
async getFileContent(filePath, revision = null) {
const params = new URLSearchParams();
params.append('file', filePath);
if (revision) {
params.append('revision', revision);
}
return this.request(`/file?${params.toString()}`);
}
/**
* Get file revision history
* @param {string} filePath - Path to the file
* @returns {Promise<Array>} Array of revision objects
*/
async getFileHistory(filePath) {
const params = new URLSearchParams();
params.append('file', filePath);
return this.request(`/history?${params.toString()}`);
}
/**
* Get diff between two revisions
* @param {string} filePath - Path to the file
* @param {string} rev1 - First revision
* @param {string} rev2 - Second revision
* @returns {Promise<object>} Diff object with diff property
*/
async getDiff(filePath, rev1, rev2) {
const params = new URLSearchParams();
params.append('file', filePath);
params.append('rev1', rev1);
params.append('rev2', rev2);
return this.request(`/diff?${params.toString()}`);
}
/**
* Check API health
* @returns {Promise<object>} Health status
*/
async getHealth() {
return this.request('/health');
}
}
// Create global API instance
const api = new CVSProxyAPI();

188
ui/app.js Normal file
View file

@ -0,0 +1,188 @@
/**
* Main Application Logic
* Orchestrates API calls and UI updates
*/
class CVSRepositoryBrowser {
constructor() {
this.currentFile = null;
this.currentHistory = null;
this.initialize();
}
/**
* Initialize the application
*/
async initialize() {
try {
// Check API health
await this.checkHealth();
// Load initial repository tree
await this.loadTree();
// Setup event listeners
this.setupEventListeners();
ui.updateStatus('Connected', 'connected');
} catch (error) {
console.error('Initialization error:', error);
ui.updateStatus('Connection Error', 'error');
}
}
/**
* Check API health
*/
async checkHealth() {
try {
const health = await api.getHealth();
console.log('API Health:', health);
return health;
} catch (error) {
throw new Error('Failed to connect to API');
}
}
/**
* Load repository tree
*/
async loadTree() {
try {
ui.treeContainer.innerHTML = '<div class="loading">Loading repository...</div>';
const files = await api.getTree();
ui.buildTree(files, (filePath) => this.loadFile(filePath));
} catch (error) {
console.error('Error loading tree:', error);
ui.treeContainer.innerHTML = `<div class="loading" style="color: #991b1b;">Error loading repository: ${error.message}</div>`;
}
}
/**
* Load file content
* @param {string} filePath - Path to the file
* @param {string} revision - Optional specific revision
*/
async loadFile(filePath, revision = null) {
try {
this.currentFile = filePath;
ui.showLoading('Loading file...');
const content = await api.getFileContent(filePath, revision);
ui.displayFile(filePath, content);
// Load history for this file
await this.loadFileHistory(filePath);
} catch (error) {
console.error('Error loading file:', error);
ui.showError(`Error loading file: ${error.message}`);
}
}
/**
* Load file at specific revision
* @param {string} filePath - Path to the file
* @param {string} revision - Specific revision
*/
async loadFileAtRevision(filePath, revision) {
try {
await this.loadFile(filePath, revision);
ui.showView(ui.fileView);
} catch (error) {
console.error('Error loading file at revision:', error);
ui.showError(`Error loading file: ${error.message}`);
}
}
/**
* Load file history
* @param {string} filePath - Path to the file
*/
async loadFileHistory(filePath) {
try {
const history = await api.getFileHistory(filePath);
this.currentHistory = history;
ui.populateRevisionSelectors(history);
} catch (error) {
console.error('Error loading history:', error);
}
}
/**
* Show file history view
*/
async showHistory() {
if (!this.currentFile) return;
try {
ui.showLoading('Loading history...');
const history = await api.getFileHistory(this.currentFile);
ui.displayHistory(history);
} catch (error) {
console.error('Error showing history:', error);
ui.showError(`Error loading history: ${error.message}`);
}
}
/**
* Show diff view
*/
showDiffView() {
if (!this.currentFile || !this.currentHistory) return;
ui.populateRevisionSelectors(this.currentHistory);
ui.showView(ui.diffView);
}
/**
* Generate and display diff
*/
async generateDiff() {
if (!this.currentFile) return;
const rev1 = ui.rev1Select.value;
const rev2 = ui.rev2Select.value;
if (!rev1 || !rev2) {
ui.showError('Please select two revisions');
return;
}
try {
ui.diffContent.innerHTML = '<div class="loading">Generating diff...</div>';
const diffResult = await api.getDiff(this.currentFile, rev1, rev2);
const diffText = diffResult.diff || diffResult;
ui.displayDiff(diffText);
} catch (error) {
console.error('Error generating diff:', error);
ui.showError(`Error generating diff: ${error.message}`);
}
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Refresh button
ui.refreshBtn.addEventListener('click', () => this.loadTree());
// File view buttons
ui.historyBtn.addEventListener('click', () => this.showHistory());
ui.diffBtn.addEventListener('click', () => this.showDiffView());
// History view back button
ui.backFromHistoryBtn.addEventListener('click', () => {
ui.showView(ui.fileView);
});
// Diff view buttons
ui.generateDiffBtn.addEventListener('click', () => this.generateDiff());
ui.backFromDiffBtn.addEventListener('click', () => {
ui.showView(ui.fileView);
});
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.app = new CVSRepositoryBrowser();
});

112
ui/index.html Normal file
View file

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CVS Proxy - Repository Browser</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>CVS Repository Browser</h1>
<div class="header-info">
<span id="status" class="status">Connecting...</span>
</div>
</header>
<main>
<div class="layout">
<!-- Sidebar: Tree Navigation -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Repository</h2>
<button id="refreshBtn" class="btn-icon" title="Refresh">🔄</button>
</div>
<div id="treeContainer" class="tree-container">
<div class="loading">Loading repository...</div>
</div>
</aside>
<!-- Main Content Area -->
<section class="main-content">
<!-- File View -->
<div id="fileView" class="view hidden">
<div class="file-header">
<div class="file-info">
<h2 id="fileName">Select a file</h2>
<p id="filePath" class="file-path"></p>
</div>
<div class="file-actions">
<button id="historyBtn" class="btn btn-primary" title="View file history">
📋 History
</button>
<button id="diffBtn" class="btn btn-primary" title="Compare revisions">
🔀 Diff
</button>
</div>
</div>
<div id="fileContent" class="file-content">
<pre><code>Loading file content...</code></pre>
</div>
</div>
<!-- History View -->
<div id="historyView" class="view hidden">
<div class="view-header">
<button id="backFromHistoryBtn" class="btn btn-secondary">← Back</button>
<h2>Revision History</h2>
</div>
<div id="historyContent" class="history-content">
<div class="loading">Loading history...</div>
</div>
</div>
<!-- Diff View -->
<div id="diffView" class="view hidden">
<div class="view-header">
<button id="backFromDiffBtn" class="btn btn-secondary">← Back</button>
<h2>Compare Revisions</h2>
</div>
<div class="diff-controls">
<div class="revision-selector">
<label for="rev1Select">From Revision:</label>
<select id="rev1Select"></select>
</div>
<div class="revision-selector">
<label for="rev2Select">To Revision:</label>
<select id="rev2Select"></select>
</div>
<button id="generateDiffBtn" class="btn btn-primary">Generate Diff</button>
</div>
<div id="diffContent" class="diff-content">
<div class="loading">Select revisions and click "Generate Diff"</div>
</div>
</div>
<!-- Welcome View -->
<div id="welcomeView" class="view">
<div class="welcome-content">
<h2>Welcome to CVS Repository Browser</h2>
<p>Select a file from the repository tree on the left to view its contents.</p>
<div class="features">
<h3>Features:</h3>
<ul>
<li>📁 Browse repository structure</li>
<li>📄 View file contents</li>
<li>📋 Check revision history</li>
<li>🔀 Compare file versions</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="api.js"></script>
<script src="ui.js"></script>
<script src="app.js"></script>
</body>
</html>

536
ui/styles.css Normal file
View file

@ -0,0 +1,536 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #6b7280;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--bg-color: #f9fafb;
--surface-color: #ffffff;
--border-color: #e5e7eb;
--text-primary: #111827;
--text-secondary: #6b7280;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
line-height: 1.6;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
header {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: 1.5rem 2rem;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.875rem;
font-weight: 700;
color: var(--primary-color);
}
.header-info {
display: flex;
align-items: center;
gap: 1rem;
}
.status {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
background-color: var(--bg-color);
color: var(--text-secondary);
}
.status.connected {
background-color: #dcfce7;
color: #166534;
}
.status.error {
background-color: #fee2e2;
color: #991b1b;
}
/* Main Layout */
main {
flex: 1;
overflow: hidden;
}
.layout {
display: flex;
height: 100%;
gap: 0;
}
/* Sidebar */
.sidebar {
width: 300px;
background-color: var(--surface-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 1.125rem;
font-weight: 600;
}
.btn-icon {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.375rem;
transition: background-color 0.2s;
}
.btn-icon:hover {
background-color: var(--bg-color);
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.tree-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 0.375rem;
transition: background-color 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
user-select: none;
}
.tree-item:hover {
background-color: var(--bg-color);
}
.tree-item.active {
background-color: var(--primary-color);
color: white;
}
.tree-item-icon {
font-size: 1rem;
min-width: 1.25rem;
}
.tree-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
padding: 2rem 1rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
}
.view:not(.hidden) {
display: flex;
}
.view.hidden {
display: none;
}
/* File View */
.file-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
background-color: var(--surface-color);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.file-info {
flex: 1;
}
.file-info h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.file-path {
color: var(--text-secondary);
font-size: 0.875rem;
font-family: 'Monaco', 'Courier New', monospace;
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.file-content {
flex: 1;
overflow: auto;
padding: 1.5rem;
background-color: var(--bg-color);
}
.file-content pre {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.file-content code {
color: var(--text-primary);
}
/* History View */
.history-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.history-item {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.history-item:hover {
box-shadow: var(--shadow-lg);
border-color: var(--primary-color);
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.history-revision {
font-weight: 600;
color: var(--primary-color);
font-family: 'Monaco', 'Courier New', monospace;
}
.history-date {
color: var(--text-secondary);
font-size: 0.875rem;
}
.history-author {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.history-state {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
background-color: var(--bg-color);
color: var(--text-secondary);
}
.history-lines {
color: var(--text-secondary);
font-size: 0.875rem;
margin-top: 0.5rem;
}
/* Diff View */
.view-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
background-color: var(--surface-color);
display: flex;
align-items: center;
gap: 1rem;
}
.view-header h2 {
flex: 1;
}
.diff-controls {
padding: 1.5rem;
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 1rem;
align-items: flex-end;
}
.revision-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.revision-selector label {
font-size: 0.875rem;
font-weight: 500;
}
.revision-selector select {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
font-size: 0.875rem;
background-color: var(--surface-color);
cursor: pointer;
}
.diff-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background-color: var(--bg-color);
}
.diff-content pre {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.diff-line {
margin: 0;
}
.diff-line.added {
background-color: #dcfce7;
color: #166534;
}
.diff-line.removed {
background-color: #fee2e2;
color: #991b1b;
}
.diff-line.context {
color: var(--text-secondary);
}
.diff-line.header {
background-color: var(--bg-color);
color: var(--text-secondary);
font-weight: 600;
}
/* Welcome View */
.welcome-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 3rem;
}
.welcome-content h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--primary-color);
}
.welcome-content p {
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 1.125rem;
}
.features {
text-align: left;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 2rem;
max-width: 400px;
}
.features h3 {
margin-bottom: 1rem;
color: var(--primary-color);
}
.features ul {
list-style: none;
}
.features li {
padding: 0.5rem 0;
color: var(--text-secondary);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #4b5563;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary-color);
}
/* Responsive */
@media (max-width: 768px) {
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 200px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.file-header {
flex-direction: column;
}
.file-actions {
width: 100%;
}
.file-actions .btn {
flex: 1;
}
.diff-controls {
flex-direction: column;
align-items: stretch;
}
.revision-selector select {
width: 100%;
}
header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}

296
ui/ui.js Normal file
View file

@ -0,0 +1,296 @@
/**
* UI Management Module
* Handles DOM manipulation and view switching
*/
class UIManager {
constructor() {
this.currentFile = null;
this.currentHistory = null;
this.initializeElements();
}
initializeElements() {
// Views
this.welcomeView = document.getElementById('welcomeView');
this.fileView = document.getElementById('fileView');
this.historyView = document.getElementById('historyView');
this.diffView = document.getElementById('diffView');
// 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');
// Tree elements
this.treeContainer = document.getElementById('treeContainer');
this.refreshBtn = document.getElementById('refreshBtn');
// Status
this.status = document.getElementById('status');
}
/**
* Show a specific view and hide others
* @param {HTMLElement} view - View to show
*/
showView(view) {
[this.welcomeView, this.fileView, this.historyView, this.diffView].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;
// Escape HTML and display content
const escapedContent = this.escapeHtml(content);
this.fileContent.innerHTML = `<pre><code>${escapedContent}</code></pre>`;
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-item-header">
<span class="history-revision">${revision.revision}</span>
<span class="history-date">${revision.date || 'N/A'}</span>
</div>
<div class="history-author">Author: <strong>${revision.author || 'Unknown'}</strong></div>
<span class="history-state">${revision.state || 'Exp'}</span>
<div class="history-lines">${revision.lines_changed || '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', () => {
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;
}
// Parse and colorize diff
const lines = diffText.split('\n');
const colorizedLines = lines.map(line => {
let className = '';
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) {
className = 'diff-line header';
} else if (line.startsWith('+')) {
className = 'diff-line added';
} else if (line.startsWith('-')) {
className = 'diff-line removed';
} else {
className = 'diff-line context';
}
const escapedLine = this.escapeHtml(line);
return `<div class="${className}">${escapedLine}</div>`;
}).join('');
this.diffContent.innerHTML = `<pre><code>${colorizedLines}</code></pre>`;
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
* @param {object} node - Tree node
* @param {string} path - Current path
* @param {HTMLElement} container - Container element
* @param {Function} onFileClick - Callback when file is clicked
*/
renderTreeNode(node, path, container, onFileClick) {
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';
const icon = document.createElement('span');
icon.className = 'tree-item-icon';
icon.textContent = isFile ? '📄' : '📁';
const name = document.createElement('span');
name.className = 'tree-item-name';
name.textContent = key;
item.appendChild(icon);
item.appendChild(name);
if (isFile) {
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);
});
}
container.appendChild(item);
// Recursively render subdirectories
if (!isFile) {
this.renderTreeNode(node[key], fullPath, container, onFileClick);
}
});
}
/**
* Clear active selection in tree
*/
clearTreeSelection() {
this.treeContainer.querySelectorAll('.tree-item').forEach(item => {
item.classList.remove('active');
});
}
}
// Create global UI manager instance
const ui = new UIManager();