Add dark mode UI and update components

This commit is contained in:
Juan José Gutiérrez de Quevedo Pérez 2025-11-21 13:09:47 +01:00
parent f2817bc1f1
commit c263092c10
9 changed files with 488 additions and 87 deletions

View file

@ -1,52 +1,29 @@
FROM python:3.13-slim AS builder FROM python:3-bookworm
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Install system dependencies for CVS # Install runtime dependencies (minimal)
RUN apt-get update && \ RUN apt update && apt install cvs rsh-client
apt-get install -y cvs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy only requirements first to leverage docker cache COPY . .
COPY requirements.txt .
# Install Python dependencies # Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application
COPY . .
# Install the package in editable mode
RUN pip install --no-cache-dir -e . RUN pip install --no-cache-dir -e .
# Final stage
FROM python:3.13-slim
# Set working directory
WORKDIR /app
# Install CVS
RUN apt-get update && \
apt-get install -y cvs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy only necessary files from builder
COPY --from=builder /app /app
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# Set environment variables for configuration (with defaults) # Set environment variables for configuration (with defaults)
ENV CVS_URL="" \ ENV CVS_URL="" \
REPO_CHECKOUTS=/tmp/cvs_checkouts \ REPO_CHECKOUTS=/tmp/cvs_checkouts \
CVS_MODULE="" \ CVS_MODULE="" \
BASEPATH="" \
FLASK_HOST=0.0.0.0 \ FLASK_HOST=0.0.0.0 \
FLASK_PORT=5000 \ FLASK_PORT=5000 \
FLASK_DEBUG=false FLASK_DEBUG=false \
CVS_RSH=rsh
# Expose the application port # Expose the application port
EXPOSE 5000 EXPOSE 5000
# Set the entrypoint to run the application with environment variables as command-line arguments # Set the entrypoint to run the application with environment variables as command-line arguments
ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"] ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} ${BASEPATH:+--basepath \"$BASEPATH\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"]

View file

@ -1,4 +1,4 @@
from flask import Flask, request, jsonify, send_from_directory from flask import Flask, request, jsonify, send_from_directory, Blueprint
from .cvs_client import CVSClient from .cvs_client import CVSClient
import os import os
import sys import sys
@ -17,6 +17,9 @@ app = Flask(__name__, static_folder=ui_dir, static_url_path='')
cvs_client = None cvs_client = None
app_config = {} app_config = {}
# Create API Blueprint (will be registered with basepath as url_prefix)
api_bp = Blueprint('api', __name__)
# Initialize CVS Client using provided parameters # Initialize CVS Client using provided parameters
def create_cvs_client(cvs_url, repos_checkout=None, cvs_module=None): def create_cvs_client(cvs_url, repos_checkout=None, cvs_module=None):
""" """
@ -37,7 +40,17 @@ def create_cvs_client(cvs_url, repos_checkout=None, cvs_module=None):
return CVSClient(cvs_url, repos_checkout=repos_checkout, cvs_module=cvs_module) return CVSClient(cvs_url, repos_checkout=repos_checkout, cvs_module=cvs_module)
@app.route('/v1/tree', methods=['GET']) @api_bp.route('/ui/config.js', methods=['GET'])
def serve_config():
"""
Serve dynamic configuration as JavaScript
This allows the frontend to access the basepath configuration
"""
basepath = app_config.get('basepath', '')
config_js = f"window.APP_CONFIG = {{ basepath: '{basepath}' }};"
return config_js, 200, {'Content-Type': 'application/javascript'}
@api_bp.route('/api/v1/tree', methods=['GET'])
def get_repository_tree(): def get_repository_tree():
""" """
Get repository tree structure Get repository tree structure
@ -53,7 +66,7 @@ def get_repository_tree():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/v1/diff', methods=['GET']) @api_bp.route('/api/v1/diff', methods=['GET'])
def get_file_diff(): def get_file_diff():
""" """
Get diff between two revisions of a file Get diff between two revisions of a file
@ -75,7 +88,7 @@ def get_file_diff():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/v1/history', methods=['GET']) @api_bp.route('/api/v1/history', methods=['GET'])
def get_file_history(): def get_file_history():
""" """
Get revision history for a file Get revision history for a file
@ -95,7 +108,7 @@ def get_file_history():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/v1/file', methods=['GET']) @api_bp.route('/api/v1/file', methods=['GET'])
def get_file_content(): def get_file_content():
""" """
Get raw file content at a specific revision Get raw file content at a specific revision
@ -117,7 +130,7 @@ def get_file_content():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/v1/health', methods=['GET']) @api_bp.route('/api/v1/health', methods=['GET'])
def health_check(): def health_check():
""" """
Simple health check endpoint Simple health check endpoint
@ -127,14 +140,14 @@ def health_check():
"cvs_client": "initialized" if cvs_client else "not initialized" "cvs_client": "initialized" if cvs_client else "not initialized"
}) })
@app.route('/', methods=['GET']) @api_bp.route('/ui/', methods=['GET'])
def index(): def index():
""" """
Serve the UI index.html Serve the UI index.html
""" """
return send_from_directory(ui_dir, 'index.html') return send_from_directory(ui_dir, 'index.html')
@app.route('/<path:filename>', methods=['GET']) @api_bp.route('/ui/<path:filename>', methods=['GET'])
def serve_static(filename): def serve_static(filename):
""" """
Serve static files (CSS, JS, etc.) Serve static files (CSS, JS, etc.)
@ -180,6 +193,11 @@ def main():
default=5000, default=5000,
help='Flask port to bind to (default: 5000)' help='Flask port to bind to (default: 5000)'
) )
parser.add_argument(
'--basepath',
default='',
help='Base path for API requests (e.g., /api/cvs) (default: empty string for root)'
)
parser.add_argument( parser.add_argument(
'--debug', '--debug',
action='store_true', action='store_true',
@ -195,9 +213,14 @@ def main():
'cvs_module': args.cvs_module, 'cvs_module': args.cvs_module,
'host': args.host, 'host': args.host,
'port': args.port, 'port': args.port,
'basepath': args.basepath,
'debug': args.debug 'debug': args.debug
} }
# Register the API Blueprint with basepath as url_prefix
basepath = args.basepath if args.basepath else ''
app.register_blueprint(api_bp, url_prefix=basepath)
# Attempt to create CVS client at startup # Attempt to create CVS client at startup
try: try:
cvs_client = create_cvs_client( cvs_client = create_cvs_client(
@ -209,7 +232,7 @@ def main():
print(f"Error initializing CVS Client: {e}", file=sys.stderr) print(f"Error initializing CVS Client: {e}", file=sys.stderr)
cvs_client = None cvs_client = None
print(f"Starting CVS Proxy on {args.host}:{args.port}") print(f"Starting CVS Proxy on {args.host}:{args.port} with basepath: '{basepath}'")
app.run(host=args.host, port=args.port, debug=args.debug) app.run(host=args.host, port=args.port, debug=args.debug)
if __name__ == '__main__': if __name__ == '__main__':

107
ui/DARK_MODE_README.md Normal file
View file

@ -0,0 +1,107 @@
# Dark Mode Implementation
## Overview
The CVS Repository Browser now includes a fully functional dark mode with system theme detection and user preference persistence.
## Features
### 1. **System Theme Detection**
- On first load, the application automatically detects the user's system theme preference using `prefers-color-scheme` media query
- If the system prefers dark mode, the app starts in dark mode
- If the system prefers light mode, the app starts in light mode
- If no system preference is detected, defaults to dark mode
### 2. **User Preference Persistence**
- User's theme choice is saved to browser's localStorage
- On subsequent visits, the saved preference is used instead of system preference
- Users can override system preference by toggling the theme
### 3. **Theme Toggle Button**
- Located in the header next to the status indicator
- Shows 🌙 (moon) icon in light mode
- Shows ☀️ (sun) icon in dark mode
- Click to toggle between light and dark themes
### 4. **Comprehensive Dark Mode Styling**
- All UI elements are properly styled for both light and dark modes
- Uses CSS custom properties (variables) for easy theme switching
- Maintains excellent contrast ratios for accessibility
- Smooth transitions between themes
## Implementation Details
### CSS Variables
The application uses CSS custom properties defined in `:root` and `[data-theme="dark"]` selectors:
**Light Mode (default):**
- Background: `#f9fafb` (light gray)
- Surface: `#ffffff` (white)
- Text Primary: `#111827` (dark gray)
- Text Secondary: `#6b7280` (medium gray)
- Border: `#e5e7eb` (light border)
**Dark Mode:**
- Background: `#1a1a1a` (very dark)
- Surface: `#2d2d2d` (dark gray)
- Text Primary: `#f3f4f6` (light gray)
- Text Secondary: `#d1d5db` (medium light gray)
- Border: `#404040` (dark border)
### JavaScript Implementation
The `UIManager` class in `ui.js` handles theme management:
- `initializeTheme()`: Initializes theme on page load
- `setTheme(theme)`: Sets the theme and updates localStorage
- `toggleTheme()`: Toggles between light and dark modes
- `updateThemeButton(icon)`: Updates the toggle button icon
## Files Modified
1. **styles.css**
- Added `[data-theme="dark"]` selector with dark mode CSS variables
- All color references use CSS variables for dynamic theming
2. **index.html**
- Added theme toggle button in header: `<button id="themeToggleBtn" class="btn-icon">🌙</button>`
3. **ui.js**
- Added `initializeTheme()` method to detect and apply system theme
- Added `setTheme()` method to apply theme and save preference
- Added `toggleTheme()` method to switch between themes
- Added `updateThemeButton()` method to update button icon
- Added theme toggle button event listener
## Usage
### For Users
1. The app automatically uses your system theme preference on first visit
2. Click the moon/sun icon in the header to toggle between light and dark modes
3. Your preference is saved and will be remembered on future visits
### For Developers
To modify the dark mode colors, edit the CSS variables in `styles.css`:
```css
[data-theme="dark"] {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--secondary-color: #9ca3af;
/* ... other variables ... */
}
```
## Browser Compatibility
- Works in all modern browsers that support:
- CSS Custom Properties (CSS Variables)
- `prefers-color-scheme` media query
- localStorage API
- ES6 JavaScript
## Testing
A test file `test-dark-mode.html` is included for testing the dark mode functionality independently.
To test:
1. Open `test-dark-mode.html` in a browser
2. Click the moon/sun button to toggle themes
3. Refresh the page to verify persistence
4. Check browser DevTools to see localStorage values

View file

@ -15,7 +15,7 @@ class CVSProxyAPI {
* @returns {Promise} Response data * @returns {Promise} Response data
*/ */
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
const url = `${this.baseURL}/v1${endpoint}`; const url = `${this.baseURL}/api/v1${endpoint}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@ -108,5 +108,5 @@ class CVSProxyAPI {
} }
} }
// Create global API instance // Create global API instance with basepath from config
const api = new CVSProxyAPI(); const api = new CVSProxyAPI(window.APP_CONFIG?.basepath || '');

View file

@ -162,9 +162,6 @@ class CVSRepositoryBrowser {
* Setup event listeners * Setup event listeners
*/ */
setupEventListeners() { setupEventListeners() {
// Refresh button
ui.refreshBtn.addEventListener('click', () => this.loadTree());
// File view buttons // File view buttons
ui.historyBtn.addEventListener('click', () => this.showHistory()); ui.historyBtn.addEventListener('click', () => this.showHistory());
ui.diffBtn.addEventListener('click', () => this.showDiffView()); ui.diffBtn.addEventListener('click', () => this.showDiffView());

View file

@ -5,12 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CVS Proxy - Repository Browser</title> <title>CVS Proxy - Repository Browser</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<!-- Highlight.js for syntax highlighting -->
<link id="highlightTheme" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>CVS Repository Browser</h1> <h1>CVS Repository Browser</h1>
<div class="header-info"> <div class="header-info">
<button id="themeToggleBtn" class="btn-icon" title="Toggle dark mode">🌙</button>
<span id="status" class="status">Connecting...</span> <span id="status" class="status">Connecting...</span>
</div> </div>
</header> </header>
@ -21,7 +25,6 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2>Repository</h2> <h2>Repository</h2>
<button id="refreshBtn" class="btn-icon" title="Refresh">🔄</button>
</div> </div>
<div id="treeContainer" class="tree-container"> <div id="treeContainer" class="tree-container">
<div class="loading">Loading repository...</div> <div class="loading">Loading repository...</div>
@ -105,6 +108,7 @@
</main> </main>
</div> </div>
<script src="config.js"></script>
<script src="api.js"></script> <script src="api.js"></script>
<script src="ui.js"></script> <script src="ui.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

View file

@ -20,6 +20,23 @@
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
} }
/* Dark mode theme */
[data-theme="dark"] {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--secondary-color: #9ca3af;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--bg-color: #1a1a1a;
--surface-color: #2d2d2d;
--border-color: #404040;
--text-primary: #f3f4f6;
--text-secondary: #d1d5db;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-color); background-color: var(--bg-color);
@ -163,6 +180,16 @@ main {
white-space: nowrap; white-space: nowrap;
} }
.tree-nested {
display: block;
}
.tree-item-toggle {
display: inline-block;
min-width: 1rem;
text-align: center;
}
.loading { .loading {
padding: 2rem 1rem; padding: 2rem 1rem;
text-align: center; text-align: center;

106
ui/test-dark-mode.html Normal file
View file

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark Mode Test</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Dark Mode Test</h1>
<div class="header-info">
<button id="themeToggleBtn" class="btn-icon" title="Toggle dark mode">🌙</button>
<span id="status" class="status connected">Theme: Light</span>
</div>
</header>
<main>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<h2>Test Panel</h2>
</div>
<div class="tree-container">
<div class="tree-item">
<span class="tree-item-icon">📁</span>
<span class="tree-item-name">Sample Folder</span>
</div>
<div class="tree-item">
<span class="tree-item-icon">📄</span>
<span class="tree-item-name">Sample File</span>
</div>
</div>
</aside>
<section class="main-content">
<div class="view">
<div class="file-header">
<div class="file-info">
<h2>Dark Mode Test</h2>
<p class="file-path">This page tests the dark mode implementation</p>
</div>
</div>
<div class="file-content">
<pre><code>// Test code block
function testDarkMode() {
console.log('Dark mode is working!');
return true;
}</code></pre>
</div>
</div>
</section>
</div>
</main>
</div>
<script>
class ThemeManager {
constructor() {
this.themeToggleBtn = document.getElementById('themeToggleBtn');
this.status = document.getElementById('status');
this.initializeTheme();
}
initializeTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
this.setTheme(savedTheme);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.setTheme(prefersDark ? 'dark' : 'light');
}
if (this.themeToggleBtn) {
this.themeToggleBtn.addEventListener('click', () => this.toggleTheme());
}
}
setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
this.themeToggleBtn.textContent = '☀️';
this.status.textContent = 'Theme: Dark';
} else {
document.documentElement.removeAttribute('data-theme');
this.themeToggleBtn.textContent = '🌙';
this.status.textContent = 'Theme: Light';
}
localStorage.setItem('theme', theme);
}
toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
this.setTheme(newTheme);
}
}
document.addEventListener('DOMContentLoaded', () => {
new ThemeManager();
});
</script>
</body>
</html>

240
ui/ui.js
View file

@ -8,6 +8,7 @@ class UIManager {
this.currentFile = null; this.currentFile = null;
this.currentHistory = null; this.currentHistory = null;
this.initializeElements(); this.initializeElements();
this.initializeTheme();
} }
initializeElements() { initializeElements() {
@ -37,10 +38,83 @@ class UIManager {
// Tree elements // Tree elements
this.treeContainer = document.getElementById('treeContainer'); this.treeContainer = document.getElementById('treeContainer');
this.refreshBtn = document.getElementById('refreshBtn');
// Status // Status
this.status = document.getElementById('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;
}
} }
/** /**
@ -64,9 +138,51 @@ class UIManager {
this.fileName.textContent = path.split('/').pop(); this.fileName.textContent = path.split('/').pop();
this.filePath.textContent = path; this.filePath.textContent = path;
// Escape HTML and display content // Detect language from file extension
const escapedContent = this.escapeHtml(content); const extension = path.split('.').pop().toLowerCase();
this.fileContent.innerHTML = `<pre><code>${escapedContent}</code></pre>`; 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); this.showView(this.fileView);
} }
@ -119,24 +235,22 @@ class UIManager {
return; return;
} }
// Parse and colorize diff // Create code element with diff language class
const lines = diffText.split('\n'); const codeElement = document.createElement('code');
const colorizedLines = lines.map(line => { codeElement.className = 'language-diff';
let className = ''; codeElement.textContent = diffText;
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) {
className = 'diff-line header'; const preElement = document.createElement('pre');
} else if (line.startsWith('+')) { preElement.appendChild(codeElement);
className = 'diff-line added';
} else if (line.startsWith('-')) { this.diffContent.innerHTML = '';
className = 'diff-line removed'; this.diffContent.appendChild(preElement);
} else {
className = 'diff-line context'; // Apply syntax highlighting
} if (window.hljs) {
const escapedLine = this.escapeHtml(line); hljs.highlightElement(codeElement);
return `<div class="${className}">${escapedLine}</div>`; }
}).join('');
this.diffContent.innerHTML = `<pre><code>${colorizedLines}</code></pre>`;
this.showView(this.diffView); this.showView(this.diffView);
} }
@ -237,13 +351,14 @@ class UIManager {
} }
/** /**
* Recursively render tree nodes * Recursively render tree nodes with collapsible folders
* @param {object} node - Tree node * @param {object} node - Tree node
* @param {string} path - Current path * @param {string} path - Current path
* @param {HTMLElement} container - Container element * @param {HTMLElement} container - Container element
* @param {Function} onFileClick - Callback when file is clicked * @param {Function} onFileClick - Callback when file is clicked
* @param {number} depth - Current depth level for indentation
*/ */
renderTreeNode(node, path, container, onFileClick) { renderTreeNode(node, path, container, onFileClick, depth = 0) {
const keys = Object.keys(node).sort(); const keys = Object.keys(node).sort();
keys.forEach(key => { keys.forEach(key => {
@ -252,33 +367,78 @@ class UIManager {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'tree-item'; item.className = 'tree-item';
item.style.paddingLeft = `${0.5 + depth * 1.25}rem`;
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) { 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', () => { item.addEventListener('click', () => {
// Remove active class from all items // Remove active class from all items
container.querySelectorAll('.tree-item').forEach(i => i.classList.remove('active')); container.querySelectorAll('.tree-item').forEach(i => i.classList.remove('active'));
item.classList.add('active'); item.classList.add('active');
onFileClick(fullPath); 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); container.appendChild(item);
// Recursively render subdirectories
if (!isFile) {
this.renderTreeNode(node[key], fullPath, container, onFileClick);
}
}); });
} }