Initial commit
This commit is contained in:
commit
f2817bc1f1
17 changed files with 2785 additions and 0 deletions
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal 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
52
Dockerfile
Normal 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
223
README.md
Normal 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
2
cvs_proxy/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# CVS Proxy Package
|
||||||
|
# This file makes the directory a Python package
|
||||||
216
cvs_proxy/app.py
Normal file
216
cvs_proxy/app.py
Normal 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
238
cvs_proxy/cvs_client.py
Normal 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}"
|
||||||
189
cvs_proxy/test_cvs_client.py
Normal file
189
cvs_proxy/test_cvs_client.py
Normal 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
341
openapi.yaml
Normal 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
2
requirements.in
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
flask
|
||||||
|
python-dotenv
|
||||||
25
requirements.txt
Normal file
25
requirements.txt
Normal 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
19
setup.py
Normal 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
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