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

77
.gitignore vendored Normal file
View file

@ -0,0 +1,77 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environments
venv/
env/
ENV/
env.bak/
venv.bak/
.venv
# IDE and Editor
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
*.sublime-project
*.sublime-workspace
# Environment Variables
.env
.env.local
.env.*.local
# Testing and Coverage
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.tox/
.hypothesis/
*.cover
.nose*
# Node/npm (for UI development)
node_modules/
npm-debug.log
yarn-error.log
package-lock.json
# OS-specific
.DS_Store
Thumbs.db
*.log
# Docker
.dockerignore
# Misc
.cache/
*.bak
*.tmp

52
Dockerfile Normal file
View file

@ -0,0 +1,52 @@
FROM python:3.13-slim AS builder
# Set working directory
WORKDIR /app
# Install system dependencies for CVS
RUN apt-get update && \
apt-get install -y cvs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Copy only requirements first to leverage docker cache
COPY requirements.txt .
# Install Python dependencies
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 .
# 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)
ENV CVS_URL="" \
REPO_CHECKOUTS=/tmp/cvs_checkouts \
CVS_MODULE="" \
FLASK_HOST=0.0.0.0 \
FLASK_PORT=5000 \
FLASK_DEBUG=false
# Expose the application port
EXPOSE 5000
# 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}"]

223
README.md Normal file
View file

@ -0,0 +1,223 @@
# CVS Proxy REST API
## Overview
CVS Proxy is a Flask-based REST API that provides a proxy for CVS (Concurrent Versions System) repository operations. It allows you to interact with CVS repositories through a RESTful interface, enabling programmatic access to repository information and file contents.
## Features
- List repository tree structure
- Compare file differences between revisions
- Retrieve file revision history
- Fetch raw file content at specific revisions
- Health check endpoint
- Docker containerization support
## Prerequisites
- Docker (recommended)
- OR
- Python 3.8+
- CVS command-line client installed
- Virtual environment recommended
## Installation and Running
### Docker (Recommended)
1. Build the Docker image
```bash
docker build -t cvs-proxy .
```
2. Run the container with CVS repository URL
```bash
docker run -d \
-p 5000:5000 \
-e CVS_URL=:pserver:username@cvs.example.com:/path/to/repository \
cvs-proxy
```
### Local Development
1. Clone the repository
```bash
git clone https://github.com/yourusername/cvs-proxy.git
cd cvs-proxy
```
2. Create and activate a virtual environment
```bash
python3 -m venv venv
source venv/bin/activate
```
3. Install dependencies using pip-compile
```bash
pip install -r requirements.txt
```
4. Install the package
```bash
pip install -e .
```
## Configuration
### Environment Variables
The following environment variables can be used to configure the CVS Proxy:
- `CVS_URL`: Full CVS repository URL (required)
- Format: `:pserver:username@hostname:/path/to/repository`
- `FLASK_HOST`: Host to bind the server (default: 0.0.0.0)
- `FLASK_PORT`: Port to run the server (default: 5000)
- `FLASK_DEBUG`: Enable debug mode (default: false)
### Configuration Methods
1. Environment Variables (Recommended for Docker)
```bash
export CVS_URL=:pserver:username@cvs.example.com:/path/to/repository
```
2. .env File (For local development)
Create a `.env` file in the project root:
```
CVS_URL=:pserver:username@cvs.example.com:/path/to/repository
```
## API Endpoints
All endpoints are versioned with the `/v1` prefix.
### GET /v1/tree
List repository tree structure
- Optional query param: `module`
### GET /v1/diff
Get diff between two revisions of a file
- Required params: `file`, `rev1`, `rev2`
### GET /v1/history
Get revision history for a file
- Required param: `file`
### GET /v1/file
Get raw file content at a specific revision
- Required param: `file`
- Optional param: `revision`
### GET /v1/health
Simple health check endpoint
## Example Requests
1. List repository tree
```
GET /v1/tree
GET /v1/tree?module=project_name
```
2. Get file diff
```
GET /v1/diff?file=src/main.py&rev1=1.1&rev2=1.2
```
3. Get file history
```
GET /v1/history?file=README.md
```
4. Get file content
```
GET /v1/file?file=config.json
GET /v1/file?file=config.json&revision=1.3
```
## Web UI
The CVS Proxy includes a modern, responsive web-based interface for browsing repositories. The UI is automatically served from the root path (`/`) when the application starts.
### Features
- **Repository Tree Navigation**: Browse the complete repository structure
- **File Viewing**: Display file contents
- **Revision History**: View complete revision history for any file
- **Diff Viewer**: Compare different versions of files with color-coded output
- **Real-time Status**: Connection status indicator
### Accessing the UI
Once the application is running, open your browser and navigate to:
```
http://localhost:5000
```
For more details about the UI, see [ui/README.md](ui/README.md)
## Development
### Dependency Management
This project uses [pip-tools](https://github.com/jazzband/pip-tools) to manage dependencies with pinned versions for reproducible builds.
**Files:**
- `requirements.in`: Direct dependencies (source of truth)
- `requirements.txt`: Pinned dependencies (auto-generated by pip-compile)
**Updating Dependencies:**
1. To add a new dependency, edit `requirements.in` and add the package name
2. Regenerate `requirements.txt`:
```bash
pip-compile requirements.in
```
3. To update all dependencies to their latest versions:
```bash
pip-compile --upgrade requirements.in
```
4. Install the updated dependencies:
```bash
pip install -r requirements.txt
```
### Running Tests
```bash
python -m unittest discover cvs_proxy
```
## Project Structure
```
cvs-proxy/
├── cvs_proxy/
│ ├── __init__.py
│ ├── app.py # Flask application with API endpoints
│ ├── cvs_client.py # CVS client implementation
│ └── test_cvs_client.py # Tests
├── ui/ # Web-based repository browser
│ ├── index.html
│ ├── styles.css
│ ├── api.js
│ ├── ui.js
│ ├── app.js
│ └── README.md
├── openapi.yaml # OpenAPI 3.0 specification
├── README.md # This file
├── requirements.in # Direct dependencies (source of truth)
├── requirements.txt # Pinned dependencies (auto-generated)
├── setup.py
└── Dockerfile
```
## Security Considerations
- Ensure your CVS repository URL is correctly formatted
- Use secure, environment-specific configurations
- The API does not implement additional authentication beyond CVS repository configuration
- The UI properly escapes all HTML content to prevent XSS attacks
## License
[Specify your license here]
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

2
cvs_proxy/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# CVS Proxy Package
# This file makes the directory a Python package

216
cvs_proxy/app.py Normal file
View file

@ -0,0 +1,216 @@
from flask import Flask, request, jsonify, send_from_directory
from .cvs_client import CVSClient
import os
import sys
import argparse
# Get the directory where this app.py is located
app_dir = os.path.dirname(os.path.abspath(__file__))
# Get the parent directory (cvs-proxy root)
project_root = os.path.dirname(app_dir)
# UI directory path
ui_dir = os.path.join(project_root, 'ui')
app = Flask(__name__, static_folder=ui_dir, static_url_path='')
# Global variables to store configuration
cvs_client = None
app_config = {}
# Initialize CVS Client using provided parameters
def create_cvs_client(cvs_url, repos_checkout=None, cvs_module=None):
"""
Create CVS client from provided parameters
:param cvs_url: CVS repository URL
:type cvs_url: str
:param repos_checkout: Path to the directory where repositories will be checked out
:type repos_checkout: str, optional
:param cvs_module: CVS module to work with
:type cvs_module: str, optional
:return: Configured CVS Client
:rtype: CVSClient
:raises ValueError: If CVS URL is not provided
"""
if not cvs_url:
raise ValueError("CVS_URL must be provided as a command-line argument")
return CVSClient(cvs_url, repos_checkout=repos_checkout, cvs_module=cvs_module)
@app.route('/v1/tree', methods=['GET'])
def get_repository_tree():
"""
Get repository tree structure
Optional query param: module
"""
if not cvs_client:
return jsonify({"error": "CVS Client not initialized"}), 500
module = request.args.get('module')
try:
tree = cvs_client.list_repository_tree(module)
return jsonify(tree)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/v1/diff', methods=['GET'])
def get_file_diff():
"""
Get diff between two revisions of a file
Required query params: file, rev1, rev2
"""
if not cvs_client:
return jsonify({"error": "CVS Client not initialized"}), 500
file_path = request.args.get('file')
rev1 = request.args.get('rev1')
rev2 = request.args.get('rev2')
if not all([file_path, rev1, rev2]):
return jsonify({"error": "Missing required parameters"}), 400
try:
diff = cvs_client.get_file_diff(file_path, rev1, rev2)
return jsonify({"diff": diff})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/v1/history', methods=['GET'])
def get_file_history():
"""
Get revision history for a file
Required query param: file
"""
if not cvs_client:
return jsonify({"error": "CVS Client not initialized"}), 500
file_path = request.args.get('file')
if not file_path:
return jsonify({"error": "Missing file parameter"}), 400
try:
history = cvs_client.get_file_history(file_path)
return jsonify(history)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/v1/file', methods=['GET'])
def get_file_content():
"""
Get raw file content at a specific revision
Required param: file
Optional param: revision
"""
if not cvs_client:
return jsonify({"error": "CVS Client not initialized"}), 500
file_path = request.args.get('file')
revision = request.args.get('revision')
if not file_path:
return jsonify({"error": "Missing file parameter"}), 400
try:
content = cvs_client.get_file_content(file_path, revision)
return content
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/v1/health', methods=['GET'])
def health_check():
"""
Simple health check endpoint
"""
return jsonify({
"status": "healthy",
"cvs_client": "initialized" if cvs_client else "not initialized"
})
@app.route('/', methods=['GET'])
def index():
"""
Serve the UI index.html
"""
return send_from_directory(ui_dir, 'index.html')
@app.route('/<path:filename>', methods=['GET'])
def serve_static(filename):
"""
Serve static files (CSS, JS, etc.)
"""
return send_from_directory(ui_dir, filename)
def main():
"""
Main entry point for the CVS Proxy application
"""
global cvs_client, app_config
parser = argparse.ArgumentParser(
description='CVS Proxy - A web proxy for CVS repositories'
)
# CVS configuration arguments
parser.add_argument(
'--cvs-url',
required=True,
help='CVS repository URL (e.g., :pserver:user@host:/path/to/repo)'
)
parser.add_argument(
'--repo-checkouts',
default='/tmp/cvs_checkouts',
help='Path to the directory where repositories will be checked out (default: /tmp/cvs_checkouts)'
)
parser.add_argument(
'--cvs-module',
default=None,
help='CVS module to work with (optional)'
)
# Flask configuration arguments
parser.add_argument(
'--host',
default='0.0.0.0',
help='Flask host to bind to (default: 0.0.0.0)'
)
parser.add_argument(
'--port',
type=int,
default=5000,
help='Flask port to bind to (default: 5000)'
)
parser.add_argument(
'--debug',
action='store_true',
help='Enable Flask debug mode'
)
args = parser.parse_args()
# Store configuration
app_config = {
'cvs_url': args.cvs_url,
'repo_checkouts': args.repo_checkouts,
'cvs_module': args.cvs_module,
'host': args.host,
'port': args.port,
'debug': args.debug
}
# Attempt to create CVS client at startup
try:
cvs_client = create_cvs_client(
args.cvs_url,
repos_checkout=args.repo_checkouts,
cvs_module=args.cvs_module
)
except ValueError as e:
print(f"Error initializing CVS Client: {e}", file=sys.stderr)
cvs_client = None
print(f"Starting CVS Proxy on {args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=args.debug)
if __name__ == '__main__':
main()

238
cvs_proxy/cvs_client.py Normal file
View file

@ -0,0 +1,238 @@
import subprocess
import os
import re
import sys
import shutil
class CVSClient:
def __init__(self, repo_url=None, repos_checkout=None, cvs_module=None):
"""
Initialize CVS client with repository URL, checkout location, and module
:param repo_url: CVS repository URL in the format :pserver:username@hostname:/path/to/repository
:type repo_url: str, optional
:param repos_checkout: Path to the directory where repositories will be checked out
:type repos_checkout: str, optional
:param cvs_module: CVS module to work with
:type cvs_module: str, optional
:raises ValueError: If no repository URL is provided
"""
if repo_url is None:
raise ValueError("CVS repository URL must be provided")
self.repo_url = repo_url
self.cvs_module = cvs_module
# Use provided repos_checkout or fall back to environment variable
checkouts_base_dir = repos_checkout or os.getenv('REPO_CHECKOUTS', '/tmp/cvs_checkouts')
# Create checkouts directory if it doesn't exist
os.makedirs(checkouts_base_dir, exist_ok=True)
# Generate a safe directory name from the repo URL
# Remove :pserver: prefix and replace non-alphanumeric characters with single underscore
safe_repo_name = re.sub(r'^:pserver:', '', repo_url)
safe_repo_name = re.sub(r'[^a-zA-Z0-9]+', '_', safe_repo_name)
self.local_repo_path = os.path.join(checkouts_base_dir, safe_repo_name)
# Ensure clean checkout
self._checkout_repository()
def _run_cvs_command(self, command, cwd=None):
"""
Run a CVS command with the configured repository URL
:param command: List of CVS command arguments
:type command: list
:param cwd: Working directory for the command
:type cwd: str, optional
:return: Command output as string
:rtype: str
:raises subprocess.CalledProcessError: If the CVS command fails
"""
full_command = ['cvs', '-d', self.repo_url] + command
# Debug printout of the command to be executed
print(f"DEBUG: Executing CVS command: {' '.join(full_command)}", file=sys.stderr)
print(f"DEBUG: Working directory: {cwd or self.local_repo_path}", file=sys.stderr)
try:
result = subprocess.run(
full_command,
capture_output=True,
text=True,
check=True,
cwd=cwd or self.local_repo_path
)
# Debug printout of stdout and stderr
if result.stdout:
print(f"DEBUG: CVS Command STDOUT:\n{result.stdout}", file=sys.stderr)
if result.stderr:
print(f"DEBUG: CVS Command STDERR:\n{result.stderr}", file=sys.stderr)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
# Debug printout for command failure
print(f"DEBUG: CVS Command FAILED", file=sys.stderr)
print(f"DEBUG: Return Code: {e.returncode}", file=sys.stderr)
print(f"DEBUG: Command: {' '.join(e.cmd)}", file=sys.stderr)
print(f"DEBUG: STDOUT:\n{e.stdout}", file=sys.stderr)
print(f"DEBUG: STDERR:\n{e.stderr}", file=sys.stderr)
# Re-raise to allow caller to handle specific errors
raise
def _checkout_repository(self):
"""
Checkout or update the local repository
"""
# Remove existing checkout if it exists
if os.path.exists(self.local_repo_path):
shutil.rmtree(self.local_repo_path)
# Create directory
os.makedirs(self.local_repo_path, exist_ok=True)
try:
# Perform initial checkout with module if specified
checkout_command = ['checkout']
if self.cvs_module:
checkout_command.append(self.cvs_module)
else:
checkout_command.append('.')
self._run_cvs_command(checkout_command, cwd=self.local_repo_path)
print(f"DEBUG: Repository checked out to {self.local_repo_path}", file=sys.stderr)
except subprocess.CalledProcessError as e:
print(f"DEBUG: Repository checkout failed: {e}", file=sys.stderr)
raise
def list_repository_tree(self, module=None):
"""
List repository tree structure using local filesystem
:param module: Optional module or subdirectory to list
:type module: str, optional
:return: List of files and directories
:rtype: list
"""
try:
# Determine the path to list
list_path = os.path.join(self.local_repo_path, module) if module else self.local_repo_path
# Walk through the directory
tree = []
for root, dirs, files in os.walk(list_path):
# Get relative paths
rel_root = os.path.relpath(root, self.local_repo_path)
# Add files
for file_name in files:
# Skip hidden files and CVS directories
if not file_name.startswith('.') and 'CVS' not in rel_root:
full_path = os.path.normpath(os.path.join(rel_root, file_name))
if full_path != '.':
# If a module is specified, strip the module prefix
if module:
full_path = os.path.basename(full_path)
tree.append(full_path)
return sorted(tree)
except Exception as e:
print(f"DEBUG: Error listing repository tree: {e}", file=sys.stderr)
return []
def get_file_diff(self, file_path, rev1, rev2):
"""
Get diff between two revisions of a file
:param file_path: Path to the file
:type file_path: str
:param rev1: First revision
:type rev1: str
:param rev2: Second revision
:type rev2: str
:return: Diff output
:rtype: str
"""
try:
output = self._run_cvs_command([
'rdiff',
'-u',
'-r', rev1,
'-r', rev2,
file_path
])
return output
except subprocess.CalledProcessError:
return f"Error generating diff for {file_path} between {rev1} and {rev2}"
def get_file_history(self, file_path):
"""
Get revision history for a file using 'cvs log' command
:param file_path: Path to the file
:type file_path: str
:return: List of revision details
:rtype: list
"""
try:
# Use 'cvs log' instead of 'rlog' as it's more widely supported
output = self._run_cvs_command(['log', file_path])
# Parse log output to extract revision details
revisions = []
current_revision = {}
for line in output.split('\n'):
# Look for revision lines (format: "revision X.X")
rev_match = re.match(r'^revision\s+(\S+)', line)
# Look for date/author/state line (format: "date: YYYY/MM/DD HH:MM:SS; author: NAME; state: STATE;")
date_match = re.match(r'^date:\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2});\s+author:\s+(\S+);\s+state:\s+(\S+);', line)
if rev_match:
# Start of a new revision
if current_revision and 'revision' in current_revision:
revisions.append(current_revision)
current_revision = {'revision': rev_match.group(1)}
if date_match:
current_revision.update({
'date': date_match.group(1),
'author': date_match.group(2),
'state': date_match.group(3),
'lines_changed': 'N/A' # cvs log doesn't provide line counts
})
# Add the last revision
if current_revision and 'revision' in current_revision:
revisions.append(current_revision)
return revisions
except subprocess.CalledProcessError as e:
print(f"DEBUG: Error getting file history: {e}", file=sys.stderr)
return []
def get_file_content(self, file_path, revision=None):
"""
Get raw file content, optionally at a specific revision
:param file_path: Path to the file
:type file_path: str
:param revision: Optional specific revision
:type revision: str, optional
:return: File content
:rtype: str
"""
try:
command = ['checkout', '-p']
if revision:
command.extend(['-r', revision])
command.append(file_path)
return self._run_cvs_command(command)
except subprocess.CalledProcessError:
return f"Error retrieving content for {file_path}"

View file

@ -0,0 +1,189 @@
import unittest
from unittest.mock import patch, MagicMock, mock_open
from cvs_proxy.cvs_client import CVSClient
import subprocess
import os
import tempfile
import shutil
class TestCVSClient(unittest.TestCase):
def setUp(self):
# Use a test repository URL directly
self.test_url = ':pserver:testuser@test.repo.com:/path/to/repo'
# Create a temporary directory to simulate checkouts
self.test_checkouts_dir = tempfile.mkdtemp()
# Patch the checkout method to prevent actual checkout
CVSClient._checkout_repository = lambda self: None
# Create the CVS client with explicit repos_checkout and cvs_module
self.cvs_client = CVSClient(self.test_url, repos_checkout=self.test_checkouts_dir, cvs_module='test_module')
# Override local_repo_path with a test directory
self.test_repo_dir = os.path.join(self.test_checkouts_dir, 'testuser_test_repo_com_path_to_repo')
os.makedirs(self.test_repo_dir, exist_ok=True)
self.cvs_client.local_repo_path = self.test_repo_dir
def tearDown(self):
# Clean up the temporary directory
shutil.rmtree(self.test_checkouts_dir)
# Remove the environment variable
if 'REPO_CHECKOUTS' in os.environ:
del os.environ['REPO_CHECKOUTS']
def _create_test_files(self):
"""
Create a test directory structure for repository tree testing
"""
# Create test files and directories
os.makedirs(os.path.join(self.test_repo_dir, 'src'), exist_ok=True)
os.makedirs(os.path.join(self.test_repo_dir, 'docs'), exist_ok=True)
# Create some files
with open(os.path.join(self.test_repo_dir, 'README.md'), 'w') as f:
f.write('Test README')
with open(os.path.join(self.test_repo_dir, 'src', 'main.py'), 'w') as f:
f.write('def main():\n pass')
with open(os.path.join(self.test_repo_dir, 'docs', 'manual.txt'), 'w') as f:
f.write('Documentation')
def test_initialization(self):
"""Test CVS client initialization"""
# Verify repository URL
self.assertEqual(self.cvs_client.repo_url, ':pserver:testuser@test.repo.com:/path/to/repo')
# Verify checkout directory is created in the specified REPO_CHECKOUTS location
self.assertTrue(os.path.exists(self.test_checkouts_dir))
self.assertTrue(os.path.exists(self.test_repo_dir))
def test_missing_url(self):
"""Test initialization without URL"""
with self.assertRaises(ValueError):
CVSClient()
def test_list_repository_tree(self):
"""Test listing repository tree structure"""
# Create test files
self._create_test_files()
# Test full repository tree
tree = self.cvs_client.list_repository_tree()
expected_tree = [
'README.md',
'docs/manual.txt',
'src/main.py'
]
self.assertEqual(sorted(tree), sorted(expected_tree))
# Test subdirectory listing
src_tree = self.cvs_client.list_repository_tree('src')
self.assertEqual(src_tree, ['main.py'])
# Test non-existent directory
empty_tree = self.cvs_client.list_repository_tree('nonexistent')
self.assertEqual(empty_tree, [])
def test_checkout_directory_configuration(self):
"""Test that checkout directory can be configured via environment variable"""
# Create another test directory
another_checkouts_dir = tempfile.mkdtemp()
os.environ['REPO_CHECKOUTS'] = another_checkouts_dir
# Create a new CVS client
another_client = CVSClient(':pserver:another@example.com:/another/repo')
# Verify the checkout is in the new directory
expected_repo_path = os.path.join(another_checkouts_dir, 'another_example_com_another_repo')
self.assertEqual(another_client.local_repo_path, expected_repo_path)
# Clean up
shutil.rmtree(another_checkouts_dir)
del os.environ['REPO_CHECKOUTS']
@patch('cvs_proxy.cvs_client.subprocess.run')
def test_run_cvs_command(self, mock_run):
"""Test the internal _run_cvs_command method"""
mock_run.return_value = MagicMock(
stdout='Command output',
stderr='',
returncode=0
)
result = self.cvs_client._run_cvs_command(['rlog', 'test.txt'])
self.assertEqual(result, 'Command output')
# Verify the command was constructed correctly
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
self.assertEqual(call_args, [
'cvs',
'-d',
':pserver:testuser@test.repo.com:/path/to/repo',
'rlog',
'test.txt'
])
@patch('cvs_proxy.cvs_client.subprocess.run')
def test_get_file_diff(self, mock_run):
"""Test getting file differences"""
mock_run.return_value = MagicMock(
stdout='--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,4 @@\n line1\n-old line\n+new line\n line3',
stderr='',
returncode=0
)
diff = self.cvs_client.get_file_diff('file.txt', '1.1', '1.2')
self.assertIn('line1', diff)
self.assertIn('-old line', diff)
self.assertIn('+new line', diff)
@patch('cvs_proxy.cvs_client.subprocess.run')
def test_get_file_history(self, mock_run):
"""Test retrieving file history"""
mock_run.return_value = MagicMock(
stdout='''
revision 1.2
date: 2023/11/20 10:00:00; author: testuser; state: Exp; lines: +5 -2
revision 1.1
date: 2023/11/19 15:30:00; author: testuser; state: Exp; lines: +10 -0
''',
stderr='',
returncode=0
)
history = self.cvs_client.get_file_history('file.txt')
self.assertIsInstance(history, list)
self.assertEqual(len(history), 2)
self.assertIn('revision', history[0])
self.assertEqual(history[0]['revision'], '1.2')
@patch('cvs_proxy.cvs_client.subprocess.run')
def test_get_file_content(self, mock_run):
"""Test retrieving file content"""
mock_run.return_value = MagicMock(
stdout='File contents\nMultiple lines\nof text',
stderr='',
returncode=0
)
content = self.cvs_client.get_file_content('file.txt', '1.2')
self.assertEqual(content, 'File contents\nMultiple lines\nof text')
@patch('cvs_proxy.cvs_client.subprocess.run')
def test_command_failure(self, mock_run):
"""Test handling of CVS command failure"""
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1,
cmd=['cvs', 'rlog'],
stderr='Error: Repository not found'
)
with self.assertRaises(subprocess.CalledProcessError):
self.cvs_client._run_cvs_command(['rlog'])
if __name__ == '__main__':
unittest.main()

341
openapi.yaml Normal file
View file

@ -0,0 +1,341 @@
openapi: 3.0.0
info:
title: CVS Proxy REST API
description: A Flask-based REST API that provides a proxy for CVS (Concurrent Versions System) repository operations. It allows programmatic access to repository information and file contents.
version: 1.0.0
contact:
name: CVS Proxy Support
license:
name: MIT
servers:
- url: http://localhost:5000
description: Local development server
- url: https://api.example.com
description: Production server (configure as needed)
tags:
- name: Repository
description: Operations for accessing repository structure and content
- name: Health
description: Service health and status checks
paths:
/v1/tree:
get:
summary: List repository tree structure
description: Retrieves the directory tree structure of the CVS repository, optionally filtered by module
operationId: getRepositoryTree
tags:
- Repository
parameters:
- name: module
in: query
description: Optional module or subdirectory to list. If not provided, lists the entire repository
required: false
schema:
type: string
example: project_name
responses:
'200':
description: Successfully retrieved repository tree
content:
application/json:
schema:
type: array
items:
type: string
description: File or directory path
example:
- src/main.py
- src/utils.py
- README.md
- config/settings.json
'500':
description: CVS Client not initialized or error retrieving tree
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: CVS Client not initialized
/v1/diff:
get:
summary: Get diff between two revisions of a file
description: Retrieves the unified diff output between two specified revisions of a file
operationId: getFileDiff
tags:
- Repository
parameters:
- name: file
in: query
description: Path to the file in the repository
required: true
schema:
type: string
example: src/main.py
- name: rev1
in: query
description: First revision number (e.g., 1.1, 1.2)
required: true
schema:
type: string
example: "1.1"
- name: rev2
in: query
description: Second revision number (e.g., 1.2, 1.3)
required: true
schema:
type: string
example: "1.2"
responses:
'200':
description: Successfully retrieved diff between revisions
content:
application/json:
schema:
type: object
properties:
diff:
type: string
description: Unified diff output
example:
diff: |
--- src/main.py 1.1
+++ src/main.py 1.2
@@ -1,5 +1,6 @@
def main():
- print("Hello")
+ print("Hello World")
return 0
'400':
description: Missing required parameters
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Missing required parameters
'500':
description: CVS Client not initialized or error generating diff
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Error generating diff for src/main.py between 1.1 and 1.2
/v1/history:
get:
summary: Get revision history for a file
description: Retrieves the complete revision history including dates, authors, and change information for a specified file
operationId: getFileHistory
tags:
- Repository
parameters:
- name: file
in: query
description: Path to the file in the repository
required: true
schema:
type: string
example: README.md
responses:
'200':
description: Successfully retrieved file revision history
content:
application/json:
schema:
type: array
items:
type: object
properties:
revision:
type: string
description: Revision number
example: "1.3"
date:
type: string
description: Date and time of the revision
example: "2024-01-15 10:30:45"
author:
type: string
description: Author who made the revision
example: john_doe
state:
type: string
description: State of the revision (e.g., Exp, dead)
example: Exp
lines_changed:
type: string
description: Number of lines added and removed
example: "+10 -5"
'400':
description: Missing required file parameter
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Missing file parameter
'500':
description: CVS Client not initialized or error retrieving history
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: CVS Client not initialized
/v1/file:
get:
summary: Get raw file content at a specific revision
description: Retrieves the raw content of a file, optionally at a specific revision. If no revision is specified, returns the latest version.
operationId: getFileContent
tags:
- Repository
parameters:
- name: file
in: query
description: Path to the file in the repository
required: true
schema:
type: string
example: config.json
- name: revision
in: query
description: Optional specific revision number. If not provided, retrieves the latest version.
required: false
schema:
type: string
example: "1.3"
responses:
'200':
description: Successfully retrieved file content
content:
text/plain:
schema:
type: string
description: Raw file content
example: |
{
"app_name": "CVS Proxy",
"version": "1.0.0"
}
'400':
description: Missing required file parameter
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Missing file parameter
'500':
description: CVS Client not initialized or error retrieving file
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Error retrieving content for config.json
/v1/health:
get:
summary: Health check endpoint
description: Returns the health status of the CVS Proxy service and CVS client initialization status
operationId: healthCheck
tags:
- Health
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
type: object
properties:
status:
type: string
description: Overall service status
example: healthy
cvs_client:
type: string
description: CVS client initialization status
enum:
- initialized
- not initialized
example: initialized
cvs_url:
type: string
description: Configured CVS repository URL (or "Not configured" if not set)
example: ":pserver:username@cvs.example.com:/path/to/repository"
components:
schemas:
Error:
type: object
properties:
error:
type: string
description: Error message describing what went wrong
required:
- error
FileRevision:
type: object
properties:
revision:
type: string
description: CVS revision number
date:
type: string
description: Date and time of the revision
author:
type: string
description: Author of the revision
state:
type: string
description: State of the revision
lines_changed:
type: string
description: Number of lines added and removed
HealthStatus:
type: object
properties:
status:
type: string
description: Service status
cvs_client:
type: string
description: CVS client status
cvs_url:
type: string
description: Configured CVS URL
securitySchemes:
cvs_auth:
type: apiKey
in: header
name: X-CVS-Auth
description: CVS authentication is configured via environment variables (CVS_URL). The API does not implement additional authentication beyond CVS repository configuration.
info:
x-logo:
url: https://www.nongnu.org/cvs/
altText: CVS Logo

2
requirements.in Normal file
View file

@ -0,0 +1,2 @@
flask
python-dotenv

25
requirements.txt Normal file
View file

@ -0,0 +1,25 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile requirements.in
#
blinker==1.9.0
# via flask
click==8.3.1
# via flask
flask==3.1.2
# via -r requirements.in
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
python-dotenv==1.2.1
# via -r requirements.in
werkzeug==3.1.3
# via flask

19
setup.py Normal file
View file

@ -0,0 +1,19 @@
from setuptools import setup, find_packages
setup(
name='cvs-proxy',
version='0.1.0',
packages=find_packages(),
install_requires=[
'flask',
'python-dotenv'
],
description='CVS Repository Proxy REST API',
author='Your Name',
author_email='your.email@example.com',
entry_points={
'console_scripts': [
'cvs-proxy=cvs_proxy.app:main',
],
},
)

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();