Initial commit
This commit is contained in:
commit
f2817bc1f1
17 changed files with 2785 additions and 0 deletions
157
ui/README.md
Normal file
157
ui/README.md
Normal 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
112
ui/api.js
Normal 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
188
ui/app.js
Normal 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
112
ui/index.html
Normal 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
536
ui/styles.css
Normal 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
296
ui/ui.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue