Add comment and reply functionality with preview and notification templates

This commit is contained in:
pacnpal
2025-02-07 13:13:49 -05:00
parent 2c4d2daf34
commit 0e0ed01cee
30 changed files with 5153 additions and 383 deletions

View File

@@ -0,0 +1,237 @@
import asyncio
import json
from typing import List, Dict, Any, Optional
from django.core.cache import cache
from django.db import models
from django.utils import timezone
from concurrent.futures import ThreadPoolExecutor
from .models import VersionTag, ChangeSet
class StructuredDiff:
def __init__(self, version1: str, version2: str):
self.version1 = version1
self.version2 = version2
self.changes: List[Dict[str, Any]] = []
self.impact_score = 0.0
self.computation_time = 0.0
self.timestamp = timezone.now()
def to_dict(self) -> Dict[str, Any]:
return {
'version1': self.version1,
'version2': self.version2,
'changes': self.changes,
'impact_score': self.impact_score,
'computation_time': self.computation_time,
'timestamp': self.timestamp.isoformat()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StructuredDiff':
diff = cls(data['version1'], data['version2'])
diff.changes = data['changes']
diff.impact_score = data['impact_score']
diff.computation_time = data['computation_time']
diff.timestamp = timezone.datetime.fromisoformat(data['timestamp'])
return diff
class ComparisonEngine:
"""Handles version comparison operations with background processing and caching"""
def __init__(self, chunk_size: int = 10485760): # 10MB default chunk size
self.chunk_size = chunk_size
self.executor = ThreadPoolExecutor(max_workers=4)
self.cache_ttl = 300 # 5 minutes cache TTL
async def compare_versions(self, versions: List[str]) -> List[StructuredDiff]:
"""
Compare multiple versions, processing in background and using cache
Args:
versions: List of version identifiers to compare
Returns:
List of StructuredDiff objects with comparison results
"""
if len(versions) < 2:
raise ValueError("At least two versions required for comparison")
results: List[StructuredDiff] = []
cache_misses = []
# Check cache first
for i in range(len(versions) - 1):
for j in range(i + 1, len(versions)):
cache_key = self._get_cache_key(versions[i], versions[j])
cached_result = cache.get(cache_key)
if cached_result:
results.append(StructuredDiff.from_dict(json.loads(cached_result)))
else:
cache_misses.append((versions[i], versions[j]))
# Process cache misses in background
if cache_misses:
comparison_tasks = [
self._compare_version_pair(v1, v2)
for v1, v2 in cache_misses
]
new_results = await asyncio.gather(*comparison_tasks)
results.extend(new_results)
return sorted(
results,
key=lambda x: (x.version1, x.version2)
)
def calculate_impact_score(self, diffs: List[StructuredDiff]) -> float:
"""
Calculate impact score for a set of diffs
Args:
diffs: List of StructuredDiff objects
Returns:
Float impact score (0-1)
"""
if not diffs:
return 0.0
total_score = 0.0
weights = {
'file_count': 0.3,
'change_size': 0.3,
'structural_impact': 0.4
}
for diff in diffs:
# File count impact
file_count = len(set(c['file'] for c in diff.changes))
file_score = min(file_count / 100, 1.0) # Normalize to max 100 files
# Change size impact
total_changes = sum(
len(c.get('additions', [])) + len(c.get('deletions', []))
for c in diff.changes
)
size_score = min(total_changes / 1000, 1.0) # Normalize to max 1000 lines
# Structural impact (e.g., function/class changes)
structural_changes = sum(
1 for c in diff.changes
if c.get('type') in ['function', 'class', 'schema']
)
structural_score = min(structural_changes / 10, 1.0) # Normalize to max 10 structural changes
# Weighted average
diff.impact_score = (
weights['file_count'] * file_score +
weights['change_size'] * size_score +
weights['structural_impact'] * structural_score
)
total_score += diff.impact_score
return total_score / len(diffs)
async def _compare_version_pair(self, version1: str, version2: str) -> StructuredDiff:
"""Compare two versions in background"""
start_time = timezone.now()
# Create diff structure
diff = StructuredDiff(version1, version2)
try:
# Get version data
v1_tag = await self._get_version_tag(version1)
v2_tag = await self._get_version_tag(version2)
if not v1_tag or not v2_tag:
raise ValueError("Version tag not found")
# Process in chunks if needed
changes = await self._process_version_changes(v1_tag, v2_tag)
diff.changes = changes
# Calculate impact score
diff.impact_score = self.calculate_impact_score([diff])
# Store in cache
cache_key = self._get_cache_key(version1, version2)
cache.set(
cache_key,
json.dumps(diff.to_dict()),
self.cache_ttl
)
except Exception as e:
diff.changes = [{'error': str(e)}]
diff.impact_score = 0.0
diff.computation_time = (timezone.now() - start_time).total_seconds()
return diff
async def _get_version_tag(self, version: str) -> Optional[VersionTag]:
"""Get version tag by identifier"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self.executor,
lambda: VersionTag.objects.filter(name=version).first()
)
async def _process_version_changes(
self,
v1_tag: VersionTag,
v2_tag: VersionTag
) -> List[Dict[str, Any]]:
"""Process changes between versions in chunks"""
changes = []
# Get changesets between versions
changesets = await self._get_changesets_between(v1_tag, v2_tag)
for changeset in changesets:
# Process each change in chunks if needed
change_data = await self._process_changeset(changeset)
changes.extend(change_data)
return changes
async def _get_changesets_between(
self,
v1_tag: VersionTag,
v2_tag: VersionTag
) -> List[ChangeSet]:
"""Get all changesets between two versions"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self.executor,
lambda: list(ChangeSet.objects.filter(
branch=v2_tag.branch,
created_at__gt=v1_tag.created_at,
created_at__lte=v2_tag.created_at,
status='applied'
).order_by('created_at'))
)
async def _process_changeset(self, changeset: ChangeSet) -> List[Dict[str, Any]]:
"""Process individual changeset for comparison"""
loop = asyncio.get_event_loop()
def process():
changes = []
instance = changeset.historical_instance
if instance:
# Get changes from historical record
diff = instance.diff_against_previous
if diff:
for field, values in diff.items():
change = {
'type': 'field',
'file': f"{instance._meta.model_name}.{field}",
'old_value': values['old'],
'new_value': values['new']
}
changes.append(change)
return changes
return await loop.run_in_executor(self.executor, process)
def _get_cache_key(self, version1: str, version2: str) -> str:
"""Generate cache key for version comparison"""
return f"version_diff:{version1}:{version2}"

View File

@@ -0,0 +1,123 @@
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.http import HttpRequest, HttpResponse, Http404
from django.template.loader import render_to_string
from django.core.exceptions import PermissionDenied
from .models import ChangeSet, CommentThread, Comment
from .notifications import NotificationDispatcher
from .state_machine import ApprovalStateMachine
@login_required
def get_comments(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to get comments for a specific anchor"""
anchor = request.GET.get('anchor')
if not anchor:
raise Http404("Anchor parameter is required")
thread = CommentThread.objects.filter(anchor__id=anchor).first()
comments = thread.comments.all() if thread else []
return render(request, 'history_tracking/partials/comments_list.html', {
'comments': comments,
'anchor': anchor
})
@login_required
@require_http_methods(["POST"])
def preview_comment(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for live comment preview"""
content = request.POST.get('content', '')
return render(request, 'history_tracking/partials/comment_preview.html', {
'content': content
})
@login_required
@require_http_methods(["POST"])
def add_comment(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to add a comment"""
anchor = request.POST.get('anchor')
content = request.POST.get('content')
parent_id = request.POST.get('parent_id')
if not content:
return HttpResponse("Comment content is required", status=400)
thread, created = CommentThread.objects.get_or_create(
anchor={'id': anchor},
defaults={'created_by': request.user}
)
comment = thread.comments.create(
author=request.user,
content=content,
parent_id=parent_id if parent_id else None
)
comment.extract_mentions()
# Send notifications
dispatcher = NotificationDispatcher()
dispatcher.notify_new_comment(comment, thread)
# Return updated comments list
return render(request, 'history_tracking/partials/comments_list.html', {
'comments': thread.comments.all(),
'anchor': anchor
})
@login_required
@require_http_methods(["POST"])
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""HTMX endpoint for approving/rejecting changes"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
if not state_machine.can_user_approve(request.user):
raise PermissionDenied("You don't have permission to approve these changes")
decision = request.POST.get('decision', 'approve')
comment = request.POST.get('comment', '')
stage_id = request.POST.get('stage_id')
success = state_machine.submit_approval(
user=request.user,
decision=decision,
comment=comment,
stage_id=stage_id
)
if not success:
return HttpResponse("Failed to submit approval", status=400)
# Return updated approval status
return render(request, 'history_tracking/partials/approval_status.html', {
'changeset': changeset,
'current_stage': state_machine.get_current_stage(),
'can_approve': state_machine.can_user_approve(request.user),
'pending_approvers': state_machine.get_pending_approvers()
})
@login_required
def approval_notifications(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""HTMX endpoint for live approval notifications"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
return render(request, 'history_tracking/partials/approval_notifications.html', {
'notifications': changeset.get_recent_notifications()
})
@login_required
def get_replies(request: HttpRequest, comment_id: int) -> HttpResponse:
"""HTMX endpoint to get comment replies"""
comment = get_object_or_404(Comment, pk=comment_id)
return render(request, 'history_tracking/partials/comment_replies.html', {
'replies': comment.replies.all()
})
@login_required
def reply_form(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to get the reply form template"""
return render(request, 'history_tracking/partials/reply_form.html', {
'parent_id': request.GET.get('parent_id')
})

View File

@@ -5,6 +5,8 @@ from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import AbstractUser
from collections import Counter
import json
from .models import VersionBranch, VersionTag, ChangeSet
UserModel = TypeVar('UserModel', bound=AbstractUser)
@@ -73,6 +75,142 @@ class BranchManager:
queryset = queryset.filter(is_active=True)
return list(queryset)
@transaction.atomic
def acquire_lock(
self,
branch: VersionBranch,
user: UserModel,
duration: int = 48,
reason: str = ""
) -> bool:
"""
Acquire a lock on a branch
Args:
branch: The branch to lock
user: User acquiring the lock
duration: Lock duration in hours (default 48)
reason: Reason for locking
Returns:
bool: True if lock acquired, False if already locked
"""
# Check if branch is already locked
if branch.lock_status:
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() < expires:
return False
# Set lock
expiry = timezone.now() + timezone.timedelta(hours=duration)
branch.lock_status = {
'user': user.id,
'expires': expiry.isoformat(),
'reason': reason
}
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'lock',
'timestamp': timezone.now().isoformat(),
'reason': reason,
'expires': expiry.isoformat()
})
branch.save()
return True
@transaction.atomic
def release_lock(
self,
branch: VersionBranch,
user: UserModel,
force: bool = False
) -> bool:
"""
Release a lock on a branch
Args:
branch: The branch to unlock
user: User releasing the lock
force: Whether to force unlock (requires permissions)
Returns:
bool: True if lock released, False if not locked or unauthorized
"""
if not branch.lock_status:
return False
locked_by = branch.lock_status.get('user')
if not locked_by:
return False
# Check authorization
if not force and locked_by != user.id:
if not user.has_perm('history_tracking.force_unlock_branch'):
return False
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'unlock',
'timestamp': timezone.now().isoformat(),
'forced': force
})
# Clear lock
branch.lock_status = {}
branch.save()
return True
def check_lock(self, branch: VersionBranch) -> Dict[str, Any]:
"""
Check the lock status of a branch
Args:
branch: The branch to check
Returns:
dict: Lock status information
"""
if not branch.lock_status:
return {'locked': False}
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() >= expires:
# Lock has expired
branch.lock_status = {}
branch.save()
return {'locked': False}
return {
'locked': True,
'user': User.objects.get(id=branch.lock_status['user']),
'expires': expires,
'reason': branch.lock_status.get('reason', '')
}
def get_lock_history(
self,
branch: VersionBranch,
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get the lock history for a branch
Args:
branch: The branch to get history for
limit: Optional limit on number of entries
Returns:
list: Lock history entries
"""
history = branch.lock_history
if limit:
history = history[-limit:]
# Enhance history with user objects
for entry in history:
try:
entry['user_obj'] = User.objects.get(id=entry['user'])
except User.DoesNotExist:
entry['user_obj'] = None
return history
class ChangeTracker:
"""Tracks and manages changes across the system"""
@@ -114,6 +252,189 @@ class ChangeTracker:
"""Get all changes in a branch ordered by creation time"""
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
def compute_enhanced_diff(
self,
version1: Any,
version2: Any,
syntax_detect: bool = True
) -> Dict[str, Any]:
"""
Return structured diff with syntax metadata
Args:
version1: First version to compare
version2: Second version to compare
syntax_detect: Whether to detect syntax types
Returns:
Dict containing structured diff with metadata
"""
if not hasattr(version1, 'history') or not hasattr(version2, 'history'):
raise ValueError("Both versions must be history-tracked models")
# Get historical records
v1_history = version1.history.first()
v2_history = version2.history.first()
if not (v1_history and v2_history):
raise ValueError("No historical records found")
changes = {}
# Compare fields and detect syntax
for field in v2_history._meta.fields:
field_name = field.name
if field_name in [
'history_id', 'history_date', 'history_type',
'history_user_id', 'history_change_reason'
]:
continue
old_value = getattr(v1_history, field_name)
new_value = getattr(v2_history, field_name)
if old_value != new_value:
field_type = field.get_internal_type()
syntax_type = self._detect_syntax(field_type, old_value) if syntax_detect else 'text'
changes[field_name] = {
'old': str(old_value),
'new': str(new_value),
'type': field_type,
'syntax': syntax_type,
'line_numbers': self._compute_line_numbers(old_value, new_value),
'metadata': {
'comment_anchor_id': f"{v2_history.history_id}_{field_name}",
'field_type': field_type,
'content_type': v2_history._meta.model_name
}
}
# Calculate impact metrics
impact_metrics = self._calculate_impact_metrics(changes)
return {
'changes': changes,
'metadata': {
'version1_id': v1_history.history_id,
'version2_id': v2_history.history_id,
'timestamp': timezone.now().isoformat(),
'impact_score': impact_metrics['impact_score'],
'stats': impact_metrics['stats'],
'performance': {
'syntax_detection': syntax_detect,
'computed_at': timezone.now().isoformat()
}
}
}
def _detect_syntax(self, field_type: str, value: Any) -> str:
"""
Detect syntax type for field content
Args:
field_type: Django field type
value: Field value
Returns:
Detected syntax type
"""
if field_type in ['TextField', 'CharField']:
# Try to detect if it's code
if isinstance(value, str):
if value.startswith('def ') or value.startswith('class '):
return 'python'
if value.startswith('{') or value.startswith('['):
try:
json.loads(value)
return 'json'
except:
pass
if value.startswith('<!DOCTYPE') or value.startswith('<html'):
return 'html'
if value.startswith('// ') or value.startswith('function '):
return 'javascript'
syntax_map = {
'JSONField': 'json',
'FileField': 'path',
'FilePathField': 'path',
'URLField': 'url',
'EmailField': 'email',
'TextField': 'text',
'CharField': 'text'
}
return syntax_map.get(field_type, 'text')
def _compute_line_numbers(self, old_value: Any, new_value: Any) -> Dict[str, List[int]]:
"""
Compute line numbers for diff navigation
Args:
old_value: Previous value
new_value: New value
Returns:
Dict with old and new line numbers
"""
def count_lines(value):
if not isinstance(value, str):
value = str(value)
return value.count('\n') + 1
old_lines = count_lines(old_value)
new_lines = count_lines(new_value)
return {
'old': list(range(1, old_lines + 1)),
'new': list(range(1, new_lines + 1))
}
def _calculate_impact_metrics(self, changes: Dict[str, Any]) -> Dict[str, Any]:
"""
Calculate impact metrics for changes
Args:
changes: Dict of changes
Returns:
Dict with impact metrics
"""
total_lines_changed = sum(
len(c['line_numbers']['old']) + len(c['line_numbers']['new'])
for c in changes.values()
)
field_types = Counter(c['type'] for c in changes.values())
syntax_types = Counter(c['syntax'] for c in changes.values())
# Calculate impact score (0-1)
impact_weights = {
'lines_changed': 0.4,
'fields_changed': 0.3,
'complexity': 0.3
}
# Normalize metrics
normalized_lines = min(1.0, total_lines_changed / 1000) # Cap at 1000 lines
normalized_fields = min(1.0, len(changes) / 20) # Cap at 20 fields
# Complexity based on field and syntax types
complexity_score = (
len(field_types) / 10 + # Variety of field types
len(syntax_types) / 5 + # Variety of syntax types
(field_types.get('JSONField', 0) * 0.2) + # Weight complex fields higher
(syntax_types.get('python', 0) * 0.2) # Weight code changes higher
) / 2 # Normalize to 0-1
impact_score = (
impact_weights['lines_changed'] * normalized_lines +
impact_weights['fields_changed'] * normalized_fields +
impact_weights['complexity'] * complexity_score
)
return {
'impact_score': impact_score,
'stats': {
'total_lines_changed': total_lines_changed,
'fields_changed': len(changes),
'field_types': dict(field_types),
'syntax_types': dict(syntax_types)
}
}
class MergeStrategy:
"""Handles merge operations and conflict resolution"""

View File

@@ -1,9 +1,11 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
comments = GenericRelation('CommentThread', related_query_name='historical_record')
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
@@ -33,6 +35,7 @@ class HistoricalChangeMixin(models.Model):
@property
def diff_against_previous(self):
"""Get enhanced diff with syntax highlighting and metadata"""
prev_record = self.prev_record
if not prev_record:
return {}
@@ -54,11 +57,69 @@ class HistoricalChangeMixin(models.Model):
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
field_type = self._meta.get_field(field).get_internal_type()
syntax_type = self._get_syntax_type(field_type)
changes[field] = {
"old": str(old_value),
"new": str(new_value),
"syntax_type": syntax_type,
"metadata": {
"field_type": field_type,
"comment_anchor_id": f"{self.history_id}_{field}",
"line_numbers": self._compute_line_numbers(old_value, new_value)
}
}
except AttributeError:
continue
return changes
def _get_syntax_type(self, field_type):
"""Map Django field types to syntax highlighting types"""
syntax_map = {
'TextField': 'text',
'JSONField': 'json',
'FileField': 'path',
'ImageField': 'path',
'URLField': 'url',
'EmailField': 'email',
'CodeField': 'python' # Custom field type for code
}
return syntax_map.get(field_type, 'text')
def _compute_line_numbers(self, old_value, new_value):
"""Compute line numbers for diff navigation"""
old_lines = str(old_value).count('\n') + 1
new_lines = str(new_value).count('\n') + 1
return {
"old": list(range(1, old_lines + 1)),
"new": list(range(1, new_lines + 1))
}
def get_structured_diff(self, other_version=None):
"""Get structured diff between two versions with enhanced metadata"""
compare_to = other_version or self.prev_record
if not compare_to:
return None
diff_data = self.diff_against_previous
return {
"changes": diff_data,
"metadata": {
"timestamp": self.history_date.isoformat(),
"user": self.history_user_display,
"change_type": self.history_type,
"reason": self.history_change_reason,
"performance": {
"computation_time": None # To be filled by frontend
}
},
"navigation": {
"next_id": None, # To be filled by frontend
"prev_id": None, # To be filled by frontend
"current_position": None # To be filled by frontend
}
}
@property
def history_user_display(self):
"""Get a display name for the history user"""

View File

@@ -1,6 +1,6 @@
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.auth import get_user_model
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
@@ -60,6 +60,14 @@ class VersionBranch(models.Model):
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
lock_status = models.JSONField(
default=dict,
help_text="Current lock status: {user: ID, expires: datetime, reason: str}"
)
lock_history = models.JSONField(
default=list,
help_text="History of lock operations: [{user: ID, action: lock/unlock, timestamp: datetime, reason: str}]"
)
class Meta:
ordering = ['-created_at']
@@ -91,6 +99,10 @@ class VersionTag(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
comparison_metadata = models.JSONField(
default=dict,
help_text="Stores diff statistics and comparison results"
)
class Meta:
ordering = ['-created_at']
@@ -104,6 +116,74 @@ class VersionTag(models.Model):
def __str__(self) -> str:
return f"{self.name} ({self.branch.name})"
class CommentThread(models.Model):
"""Represents a thread of comments on a historical record"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_threads')
anchor = models.JSONField(
default=dict,
help_text="Anchoring information: {line_start: int, line_end: int, file_path: str}"
)
is_resolved = models.BooleanField(default=False)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='resolved_threads'
)
resolved_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['created_at']),
models.Index(fields=['is_resolved']),
]
def __str__(self) -> str:
return f"Comment Thread {self.pk} on {self.content_type}"
class Comment(models.Model):
"""Individual comment within a thread"""
thread = models.ForeignKey(CommentThread, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
mentioned_users = models.ManyToManyField(
User,
related_name='mentioned_in_comments',
blank=True
)
parent_comment = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='replies'
)
class Meta:
ordering = ['created_at']
def __str__(self) -> str:
return f"Comment {self.pk} by {self.author}"
def extract_mentions(self) -> None:
"""Extract @mentions from comment content and update mentioned_users"""
# Simple @username extraction - could be enhanced with regex
mentioned = [
word[1:] for word in self.content.split()
if word.startswith('@') and len(word) > 1
]
if mentioned:
users = User.objects.filter(username__in=mentioned)
self.mentioned_users.set(users)
class ChangeSet(models.Model):
"""Groups related changes together for atomic version control operations"""
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
@@ -115,12 +195,41 @@ class ChangeSet(models.Model):
status = models.CharField(
max_length=20,
choices=[
('pending', 'Pending'),
('draft', 'Draft'),
('pending_approval', 'Pending Approval'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('applied', 'Applied'),
('failed', 'Failed'),
('reverted', 'Reverted')
],
default='pending'
default='draft'
)
approval_state = models.JSONField(
default=list,
help_text="List of approval stages and their status"
)
approval_history = models.JSONField(
default=list,
help_text="History of approval actions and decisions"
)
required_approvers = models.ManyToManyField(
User,
related_name='pending_approvals',
blank=True
)
approval_policy = models.CharField(
max_length=20,
choices=[
('sequential', 'Sequential'),
('parallel', 'Parallel')
],
default='sequential'
)
approval_deadline = models.DateTimeField(
null=True,
blank=True,
help_text="Optional deadline for approvals"
)
# Instead of directly relating to HistoricalRecord, use GenericForeignKey

View File

@@ -0,0 +1,229 @@
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
from django.contrib.auth import get_user_model
import requests
import json
from datetime import timedelta
from celery import shared_task
User = get_user_model()
class NotificationDispatcher:
"""Handles comment notifications and escalations"""
def __init__(self):
self.email_enabled = hasattr(settings, 'EMAIL_HOST')
self.slack_enabled = hasattr(settings, 'SLACK_WEBHOOK_URL')
self.sms_enabled = hasattr(settings, 'SMS_API_KEY')
def notify_new_comment(self, comment, thread):
"""Handle notification for a new comment"""
# Queue immediate notifications
self.send_in_app_notification.delay(
user_ids=self._get_thread_participants(thread),
title="New Comment",
message=f"New comment on {thread.content_object}",
link=self._get_thread_url(thread)
)
# Queue email notifications
self.send_email_notification.delay(
user_ids=self._get_thread_participants(thread),
subject=f"New comment on {thread.content_object}",
template="notifications/new_comment.html",
context={
'comment': comment,
'thread': thread,
'url': self._get_thread_url(thread)
}
)
# Schedule Slack escalation if needed
if self.slack_enabled:
self.schedule_slack_escalation.apply_async(
args=[comment.id],
countdown=24 * 3600 # 24 hours
)
def notify_mention(self, comment, mentioned_users):
"""Handle notification for @mentions"""
user_ids = [user.id for user in mentioned_users]
# Queue immediate notifications
self.send_in_app_notification.delay(
user_ids=user_ids,
title="Mentioned in Comment",
message=f"{comment.author} mentioned you in a comment",
link=self._get_comment_url(comment)
)
# Queue email notifications
self.send_email_notification.delay(
user_ids=user_ids,
subject="You were mentioned in a comment",
template="notifications/mention.html",
context={
'comment': comment,
'url': self._get_comment_url(comment)
}
)
# Queue mobile push notifications
self.send_push_notification.delay(
user_ids=user_ids,
title="New Mention",
message=f"{comment.author} mentioned you: {comment.content[:100]}..."
)
# Schedule SMS escalation if needed
if self.sms_enabled:
self.schedule_sms_escalation.apply_async(
args=[comment.id, user_ids],
countdown=12 * 3600 # 12 hours
)
def notify_resolution(self, thread, resolver):
"""Handle notification for thread resolution"""
self.send_in_app_notification.delay(
user_ids=self._get_thread_participants(thread),
title="Thread Resolved",
message=f"Thread resolved by {resolver}",
link=self._get_thread_url(thread)
)
@shared_task
def send_in_app_notification(user_ids, title, message, link):
"""Send in-app notification to users"""
from .models import InAppNotification
for user_id in user_ids:
InAppNotification.objects.create(
user_id=user_id,
title=title,
message=message,
link=link
)
@shared_task
def send_email_notification(user_ids, subject, template, context):
"""Send email notification to users"""
if not settings.EMAIL_HOST:
return
users = User.objects.filter(id__in=user_ids)
for user in users:
if not user.email:
continue
html_content = render_to_string(template, {
'user': user,
**context
})
send_mail(
subject=subject,
message='',
html_message=html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email]
)
@shared_task
def send_push_notification(user_ids, title, message):
"""Send mobile push notification"""
from .models import PushToken
tokens = PushToken.objects.filter(
user_id__in=user_ids,
active=True
)
if not tokens:
return
# Implementation depends on push notification service
# Example using Firebase:
try:
requests.post(
settings.FIREBASE_FCM_URL,
headers={
'Authorization': f'key={settings.FIREBASE_SERVER_KEY}',
'Content-Type': 'application/json'
},
json={
'registration_ids': [t.token for t in tokens],
'notification': {
'title': title,
'body': message
}
}
)
except Exception as e:
print(f"Push notification failed: {e}")
@shared_task
def schedule_slack_escalation(comment_id):
"""Send Slack DM escalation for unread comments"""
from .models import Comment
try:
comment = Comment.objects.get(id=comment_id)
if not comment.read_by.exists():
# Send Slack message
requests.post(
settings.SLACK_WEBHOOK_URL,
json={
'text': (
f"Unread comment needs attention:\n"
f"{comment.content}\n"
f"View: {self._get_comment_url(comment)}"
)
}
)
except Exception as e:
print(f"Slack escalation failed: {e}")
@shared_task
def schedule_sms_escalation(comment_id, user_ids):
"""Send SMS escalation for unread mentions"""
from .models import Comment
try:
comment = Comment.objects.get(id=comment_id)
users = User.objects.filter(id__in=user_ids)
for user in users:
if not user.phone_number:
continue
if not comment.read_by.filter(id=user.id).exists():
# Send SMS using Twilio or similar service
requests.post(
settings.SMS_API_URL,
headers={'Authorization': f'Bearer {settings.SMS_API_KEY}'},
json={
'to': user.phone_number,
'message': (
f"You were mentioned in a comment that needs attention. "
f"View: {self._get_comment_url(comment)}"
)
}
)
except Exception as e:
print(f"SMS escalation failed: {e}")
def _get_thread_participants(self, thread):
"""Get IDs of all participants in a thread"""
return list(set(
[thread.created_by_id] +
list(thread.comments.values_list('author_id', flat=True))
))
def _get_thread_url(self, thread):
"""Generate URL for thread"""
return f"/version-control/comments/thread/{thread.id}/"
def _get_comment_url(self, comment):
"""Generate URL for specific comment"""
return f"{self._get_thread_url(comment.thread)}#comment-{comment.id}"

View File

@@ -0,0 +1,194 @@
from typing import List, Dict, Any, Optional
from django.contrib.auth import get_user_model
from django.utils import timezone
from .models import ChangeSet
User = get_user_model()
class ApprovalStage:
def __init__(self, stage_id: int, name: str, required_roles: List[str]):
self.id = stage_id
self.name = name
self.required_roles = required_roles
self.approvers: List[Dict[str, Any]] = []
self.status = 'pending' # pending, approved, rejected
self.completed_at = None
def add_approver(self, user: User, decision: str, comment: str = "") -> None:
"""Add an approver's decision to this stage"""
self.approvers.append({
'user_id': user.id,
'username': user.username,
'decision': decision,
'comment': comment,
'timestamp': timezone.now().isoformat()
})
def is_approved(self, policy: str = 'unanimous') -> bool:
"""Check if stage is approved based on policy"""
if not self.approvers:
return False
approve_count = sum(1 for a in self.approvers if a['decision'] == 'approve')
if policy == 'unanimous':
return approve_count == len(self.required_roles)
else: # majority
return approve_count > len(self.required_roles) / 2
class ApprovalStateMachine:
"""Manages the state transitions for change approval workflow"""
def __init__(self, changeset: ChangeSet):
self.changeset = changeset
self.stages: List[ApprovalStage] = []
self.current_stage_index = 0
self.policy = changeset.approval_policy
self._load_state()
def _load_state(self) -> None:
"""Load the current state from changeset approval_state"""
if not self.changeset.approval_state:
return
for stage_data in self.changeset.approval_state:
stage = ApprovalStage(
stage_data['id'],
stage_data['name'],
stage_data['required_roles']
)
stage.approvers = stage_data.get('approvers', [])
stage.status = stage_data.get('status', 'pending')
stage.completed_at = stage_data.get('completed_at')
self.stages.append(stage)
# Find current stage
self.current_stage_index = next(
(i for i, s in enumerate(self.stages) if s.status == 'pending'),
len(self.stages) - 1
)
def initialize_workflow(self, stages_config: List[Dict[str, Any]]) -> None:
"""Set up initial approval workflow stages"""
self.stages = [
ApprovalStage(
i,
stage['name'],
stage['required_roles']
) for i, stage in enumerate(stages_config)
]
self._save_state()
def submit_approval(
self,
user: User,
decision: str,
comment: str = "",
stage_id: Optional[int] = None
) -> bool:
"""
Submit an approval decision
Args:
user: The user submitting approval
decision: 'approve' or 'reject'
comment: Optional comment
stage_id: Optional specific stage ID (for parallel approval)
Returns:
bool: True if submission was accepted
"""
if self.changeset.status != 'pending_approval':
return False
if self.policy == 'sequential':
stage = self.stages[self.current_stage_index]
else: # parallel
if stage_id is None:
return False
stage = next((s for s in self.stages if s.id == stage_id), None)
if not stage:
return False
# Check if user has required role
user_roles = set(user.groups.values_list('name', flat=True))
if not any(role in user_roles for role in stage.required_roles):
return False
# Add decision
stage.add_approver(user, decision, comment)
# Update stage status
if stage.is_approved(self.policy):
stage.status = 'approved'
stage.completed_at = timezone.now().isoformat()
if self.policy == 'sequential':
self._advance_stage()
elif decision == 'reject':
stage.status = 'rejected'
stage.completed_at = timezone.now().isoformat()
self.changeset.status = 'rejected'
self.changeset.save()
self._save_state()
return True
def _advance_stage(self) -> None:
"""Move to next stage if available"""
if self.current_stage_index < len(self.stages) - 1:
self.current_stage_index += 1
else:
# All stages approved
self.changeset.status = 'approved'
self.changeset.save()
def _save_state(self) -> None:
"""Save current state to changeset"""
self.changeset.approval_state = [
{
'id': stage.id,
'name': stage.name,
'required_roles': stage.required_roles,
'approvers': stage.approvers,
'status': stage.status,
'completed_at': stage.completed_at
} for stage in self.stages
]
self.changeset.save()
def get_current_stage(self) -> Optional[ApprovalStage]:
"""Get the current active stage"""
if not self.stages:
return None
return self.stages[self.current_stage_index]
def get_stage_by_id(self, stage_id: int) -> Optional[ApprovalStage]:
"""Get a specific stage by ID"""
return next((s for s in self.stages if s.id == stage_id), None)
def get_pending_approvers(self) -> List[str]:
"""Get list of roles that still need to approve the current stage"""
current_stage = self.get_current_stage()
if not current_stage:
return []
approved_by = {a['user_id'] for a in current_stage.approvers}
return [
role for role in current_stage.required_roles
if not any(
user.id in approved_by
for user in User.objects.filter(groups__name=role)
)
]
def can_user_approve(self, user: User) -> bool:
"""Check if user can approve the current stage"""
current_stage = self.get_current_stage()
if not current_stage:
return False
# Check if user already approved
if any(a['user_id'] == user.id for a in current_stage.approvers):
return False
# Check if user has required role
user_roles = set(user.groups.values_list('name', flat=True))
return any(role in user_roles for role in current_stage.required_roles)

View File

@@ -0,0 +1,174 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="approval-status">
<div class="status-header">
<h2>Approval Status</h2>
<div class="changeset-info">
<span class="changeset-id">Changeset #{{ changeset.pk }}</span>
<span class="status-badge {{ changeset.status }}">{{ changeset.status|title }}</span>
</div>
</div>
{% if changeset.description %}
<div class="changeset-description">
{{ changeset.description }}
</div>
{% endif %}
{% if current_stage %}
<div class="current-stage">
<h3>Current Stage: {{ current_stage.name }}</h3>
<div class="required-roles">
<h4>Required Approvers:</h4>
<ul class="role-list">
{% for role in current_stage.required_roles %}
<li class="role-item">{{ role }}</li>
{% endfor %}
</ul>
</div>
<div class="approvers">
<h4>Current Approvers:</h4>
{% if current_stage.approvers %}
<ul class="approvers-list">
{% for approver in current_stage.approvers %}
<li class="approver-item">
<div class="approver-info">
<span class="approver-name">{{ approver.user }}</span>
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
</div>
{% if approver.comment %}
<div class="approval-comment">
{{ approver.comment }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-approvers">No approvals yet</p>
{% endif %}
</div>
{% if can_approve %}
<div class="approval-actions">
<form method="post" action="{% url 'history_tracking:approve_changes' changeset.pk %}">
{% csrf_token %}
<div class="form-group">
<label for="comment">Comment (optional):</label>
<textarea name="comment" id="comment" rows="3" class="form-control"></textarea>
</div>
{% if current_stage.id %}
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
{% endif %}
<div class="approval-buttons">
<button type="submit" name="decision" value="approve" class="btn btn-success">
Approve Changes
</button>
<button type="submit" name="decision" value="reject" class="btn btn-danger">
Reject Changes
</button>
</div>
</form>
</div>
{% endif %}
</div>
{% endif %}
<div class="approval-history">
<h3>Approval History</h3>
{% if changeset.approval_history %}
<div class="history-timeline">
{% for entry in changeset.approval_history %}
<div class="history-entry">
<div class="entry-header">
<span class="entry-stage">{{ entry.stage_name }}</span>
<span class="entry-date">{{ entry.timestamp|date:"Y-m-d H:i" }}</span>
</div>
<div class="entry-content">
<span class="entry-user">{{ entry.user }}</span>
<span class="entry-action">{{ entry.action|title }}</span>
{% if entry.comment %}
<div class="entry-comment">
{{ entry.comment }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-history">No approval history yet</p>
{% endif %}
</div>
{% if pending_approvers %}
<div class="pending-approvers">
<h3>Waiting for Approval From:</h3>
<ul class="pending-list">
{% for role in pending_approvers %}
<li class="pending-role">{{ role }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% block extra_css %}
<style>
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.pending_approval { background-color: #fef3c7; color: #92400e; }
.status-badge.approved { background-color: #dcfce7; color: #166534; }
.status-badge.rejected { background-color: #fee2e2; color: #991b1b; }
.history-timeline {
border-left: 2px solid #e5e7eb;
margin-left: 1rem;
padding-left: 1rem;
}
.history-entry {
position: relative;
margin-bottom: 1.5rem;
}
.history-entry::before {
content: '';
position: absolute;
left: -1.5rem;
top: 0.5rem;
width: 1rem;
height: 1rem;
background: #fff;
border: 2px solid #3b82f6;
border-radius: 50%;
}
.approval-actions {
margin-top: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% if notifications %}
<div class="approval-notifications" id="approval-notifications">
{% for notification in notifications %}
<div class="notification-item {% if notification.is_new %}new{% endif %}"
{% if notification.is_new %}
hx-swap-oob="afterbegin:.approval-notifications"
{% endif %}>
<div class="notification-header">
<span class="notification-type">{{ notification.type }}</span>
<span class="notification-time">{{ notification.timestamp|timesince }} ago</span>
</div>
<div class="notification-content">
{% if notification.type == 'approval' %}
<span class="approver">{{ notification.user }}</span>
{{ notification.action }} the changes
{% if notification.stage %}
in stage <strong>{{ notification.stage }}</strong>
{% endif %}
{% elif notification.type == 'comment' %}
<span class="commenter">{{ notification.user }}</span>
commented on the changes
{% elif notification.type == 'stage_change' %}
Moved to stage <strong>{{ notification.stage }}</strong>
{% endif %}
{% if notification.comment %}
<div class="notification-comment">
"{{ notification.comment }}"
</div>
{% endif %}
</div>
{% if notification.actions %}
<div class="notification-actions">
{% for action in notification.actions %}
<button class="btn btn-sm btn-link"
hx-post="{{ action.url }}"
hx-trigger="click"
hx-target="#approval-status-container">
{{ action.label }}
</button>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="no-notifications" id="approval-notifications">
No recent notifications
</div>
{% endif %}

View File

@@ -0,0 +1,102 @@
{% if current_stage %}
<div id="approval-status-container">
<div class="current-stage-info">
<h3>Current Stage: {{ current_stage.name }}</h3>
<div class="required-roles">
<h4>Required Approvers:</h4>
<ul class="role-list">
{% for role in current_stage.required_roles %}
<li class="role-item {% if role in pending_approvers %}pending{% endif %}">
{{ role }}
</li>
{% endfor %}
</ul>
</div>
<div class="approvers">
<h4>Current Approvers:</h4>
{% if current_stage.approvers %}
<ul class="approvers-list">
{% for approver in current_stage.approvers %}
<li class="approver-item">
<div class="approver-info">
<span class="approver-name">{{ approver.user }}</span>
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
</div>
{% if approver.comment %}
<div class="approval-comment">
{{ approver.comment }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-approvers">No approvals yet</p>
{% endif %}
</div>
{% if can_approve %}
<div class="approval-actions">
<form hx-post="{% url 'history_tracking:approve_changes' changeset.pk %}"
hx-trigger="submit"
hx-target="#approval-status-container"
hx-swap="outerHTML">
{% csrf_token %}
<div class="form-group">
<label for="comment">Comment (optional):</label>
<textarea name="comment"
id="comment"
rows="3"
class="form-control"
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#comment-preview"></textarea>
<div id="comment-preview" class="comment-preview"></div>
</div>
{% if current_stage.id %}
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
{% endif %}
<div class="approval-buttons">
<button type="submit"
name="decision"
value="approve"
class="btn btn-success"
{% if not can_approve %}disabled{% endif %}
hx-indicator="#approve-indicator">
Approve Changes
<span class="htmx-indicator" id="approve-indicator">
<span class="spinner"></span>
</span>
</button>
<button type="submit"
name="decision"
value="reject"
class="btn btn-danger"
{% if not can_approve %}disabled{% endif %}
hx-indicator="#reject-indicator">
Reject Changes
<span class="htmx-indicator" id="reject-indicator">
<span class="spinner"></span>
</span>
</button>
</div>
</form>
</div>
{% endif %}
{% if messages %}
<div class="approval-messages"
hx-swap-oob="true">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,6 @@
{% if content %}
<div class="comment-preview-content">
<h6>Preview:</h6>
<div class="preview-text">{{ content }}</div>
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
{% if replies %}
<div class="replies-list">
{% for reply in replies %}
<div class="reply" id="comment-{{ reply.id }}">
<div class="reply-header">
<span class="reply-author">{{ reply.author }}</span>
<span class="reply-date">{{ reply.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="reply-content">
{{ reply.content }}
</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="reply-actions">
<button class="btn btn-sm btn-link"
hx-get="{% url 'history_tracking:reply_form' %}?parent_id={{ reply.id }}"
hx-trigger="click"
hx-target="#reply-form-{{ reply.id }}"
hx-swap="innerHTML">
Reply
</button>
</div>
<div id="reply-form-{{ reply.id }}"></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,35 @@
{% if comments %}
<div class="comments-list">
{% for comment in comments %}
<div class="comment" id="comment-{{ comment.id }}"
{% if forloop.first %}hx-swap-oob="true"{% endif %}>
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-date">{{ comment.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="comment-content">{{ comment.content }}</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="comment-actions">
<button class="btn btn-sm btn-link"
hx-get="{% url 'history_tracking:reply_form' %}"
hx-trigger="click"
hx-target="#reply-form-{{ comment.id }}"
hx-swap="innerHTML">
Reply
</button>
</div>
<div id="reply-form-{{ comment.id }}"></div>
{% endif %}
{% if comment.replies.exists %}
<div class="comment-replies"
hx-get="{% url 'history_tracking:get_replies' comment.id %}"
hx-trigger="load">
<div class="htmx-indicator">Loading replies...</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="no-comments">No comments yet</p>
{% endif %}

View File

@@ -0,0 +1,33 @@
<div class="reply-form-container">
<form hx-post="{% url 'history_tracking:add_comment' %}"
hx-trigger="submit"
hx-target="closest .comments-list"
hx-swap="innerHTML"
class="reply-form">
{% csrf_token %}
<input type="hidden" name="parent_id" value="{{ parent_id }}">
<input type="hidden" name="anchor" value="{{ anchor }}">
<div class="form-group">
<textarea name="content"
class="form-control reply-input"
placeholder="Write a reply..."
rows="2"
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#reply-preview-{{ parent_id }}"></textarea>
<div id="reply-preview-{{ parent_id }}" class="reply-preview"></div>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-sm btn-light"
onclick="this.closest('.reply-form-container').remove()">
Cancel
</button>
<button type="submit"
class="btn btn-sm btn-primary"
hx-disable-if="querySelector('.reply-input').value === ''">
Submit Reply
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="version-comparison">
<div class="comparison-header">
<h2>Version Comparison</h2>
{# Version Selection Form with HTMX #}
<div class="version-select-form">
<div class="version-selectors">
<div class="version-select">
<label for="version1">First Version</label>
<select name="version1" id="version1"
hx-get="{% url 'history_tracking:version_comparison' %}"
hx-trigger="change"
hx-target="#comparison-results"
hx-indicator=".loading-indicator">
{% for version in versions %}
<option value="{{ version.id }}" {% if version.id == selected_version1 %}selected{% endif %}>
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
</option>
{% endfor %}
</select>
</div>
<div class="version-select">
<label for="version2">Second Version</label>
<select name="version2" id="version2"
hx-get="{% url 'history_tracking:version_comparison' %}"
hx-trigger="change"
hx-target="#comparison-results"
hx-indicator=".loading-indicator">
{% for version in versions %}
<option value="{{ version.id }}" {% if version.id == selected_version2 %}selected{% endif %}>
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
</option>
{% endfor %}
</select>
</div>
</div>
<div class="loading-indicator htmx-indicator">Loading comparison...</div>
</div>
</div>
{% if diff_result %}
<div class="comparison-results">
<div class="diff-summary">
<h3>Changes Summary</h3>
<div class="diff-stats">
<div class="stat-item">
<span class="stat-label">Files Changed:</span>
<span class="stat-value">{{ diff_result.stats.total_files }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Lines Changed:</span>
<span class="stat-value">{{ diff_result.stats.total_lines }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Impact Score:</span>
<span class="stat-value">{{ diff_result.impact_score|floatformat:2 }}</span>
</div>
</div>
</div>
<div class="changes-list">
{% for change in diff_result.changes %}
<div class="change-item">
<div class="change-header">
<h4>{{ change.field }}</h4>
<span class="change-type">{{ change.type }}</span>
</div>
<div class="diff-view {% if change.syntax_type %}language-{{ change.syntax_type }}{% endif %}">
<div class="old-version">
<div class="version-header">Previous Version</div>
<pre><code>{{ change.old }}</code></pre>
</div>
<div class="new-version">
<div class="version-header">New Version</div>
<pre><code>{{ change.new }}</code></pre>
</div>
</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="comment-section"
id="comments-{{ change.metadata.comment_anchor_id }}"
hx-get="{% url 'history_tracking:get_comments' %}"
hx-trigger="load, commentAdded from:body"
hx-vals='{"anchor": "{{ change.metadata.comment_anchor_id }}"}'>
<div class="htmx-indicator">Loading comments...</div>
</div>
<div class="comment-form-container">
<form hx-post="{% url 'history_tracking:add_comment' %}"
hx-trigger="submit"
hx-target="#comments-{{ change.metadata.comment_anchor_id }}"
hx-swap="innerHTML"
class="comment-form">
{% csrf_token %}
<input type="hidden" name="anchor" value="{{ change.metadata.comment_anchor_id }}">
<textarea name="content"
placeholder="Add a comment..."
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#comment-preview-{{ change.metadata.comment_anchor_id }}"
class="comment-input"></textarea>
<div id="comment-preview-{{ change.metadata.comment_anchor_id }}"
class="comment-preview"></div>
<button type="submit"
class="btn btn-sm btn-primary"
hx-disable-if="querySelector('.comment-input').value === ''">
Comment
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if diff_result.changes.has_other_pages %}
<div class="pagination">
<span class="step-links">
{% if diff_result.changes.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ diff_result.changes.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ diff_result.changes.number }} of {{ diff_result.changes.paginator.num_pages }}
</span>
{% if diff_result.changes.has_next %}
<a href="?page={{ diff_result.changes.next_page_number }}">next</a>
<a href="?page={{ diff_result.changes.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% comment %}
Only include minimal JavaScript for necessary interactivity
{% endcomment %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle syntax highlighting
document.querySelectorAll('pre code').forEach((block) => {
const language = block.parentElement.parentElement.dataset.language;
if (language) {
block.classList.add(`language-${language}`);
hljs.highlightElement(block);
}
});
// Simple form validation
document.querySelector('.version-select-form').addEventListener('submit', function(e) {
const v1 = document.getElementById('version1').value;
const v2 = document.getElementById('version2').value;
if (v1 === v2) {
e.preventDefault();
alert('Please select different versions to compare');
}
});
});
</script>
{% endblock %}

View File

@@ -1,22 +1,50 @@
from django.urls import path
from . import views
from . import views, htmx_views
app_name = 'history'
app_name = 'history_tracking'
urlpatterns = [
# Main VCS interface
path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
# Main page views
path('compare/',
views.version_comparison,
name='version_comparison'
),
path('approval-status/<int:changeset_id>/',
views.approval_status,
name='approval_status'
),
path('submit-approval/<int:changeset_id>/',
views.submit_for_approval,
name='submit_for_approval'
),
# Branch operations
path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
# History views
path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
# Merge operations
path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
# Tag operations
path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
# HTMX endpoints
path('htmx/comments/get/',
htmx_views.get_comments,
name='get_comments'
),
path('htmx/comments/preview/',
htmx_views.preview_comment,
name='preview_comment'
),
path('htmx/comments/add/',
htmx_views.add_comment,
name='add_comment'
),
path('htmx/approve/<int:changeset_id>/',
htmx_views.approve_changes,
name='approve_changes'
),
path('htmx/notifications/<int:changeset_id>/',
htmx_views.approval_notifications,
name='approval_notifications'
),
path('htmx/comments/replies/<int:comment_id>/',
htmx_views.get_replies,
name='get_replies'
),
path('htmx/comments/reply-form/',
htmx_views.reply_form,
name='reply_form'
),
]

View File

@@ -1,238 +1,144 @@
from django.views.generic import TemplateView, View
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.contrib import messages
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from typing import Dict, Any
from .models import VersionBranch, VersionTag, ChangeSet
from .managers import BranchManager, ChangeTracker, MergeStrategy
from .models import VersionBranch, ChangeSet, VersionTag, CommentThread
from .managers import ChangeTracker
from .comparison import ComparisonEngine
from .state_machine import ApprovalStateMachine
class VersionControlPanel(LoginRequiredMixin, TemplateView):
"""Main version control interface"""
template_name = 'history_tracking/version_control_panel.html'
ITEMS_PER_PAGE = 20
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
branch_manager = BranchManager()
@login_required
def version_comparison(request: HttpRequest) -> HttpResponse:
"""View for comparing different versions"""
versions = VersionTag.objects.all().order_by('-created_at')
context.update({
'branches': branch_manager.list_branches(),
'current_branch': self.request.GET.get('branch'),
})
return context
version1_id = request.GET.get('version1')
version2_id = request.GET.get('version2')
page_number = request.GET.get('page', 1)
class BranchListView(LoginRequiredMixin, View):
"""HTMX view for branch list"""
diff_result = None
if version1_id and version2_id:
try:
version1 = get_object_or_404(VersionTag, id=version1_id)
version2 = get_object_or_404(VersionTag, id=version2_id)
def get(self, request):
branch_manager = BranchManager()
branches = branch_manager.list_branches()
# Get comparison results
engine = ComparisonEngine()
diff_result = engine.compute_enhanced_diff(version1, version2)
content = render_to_string(
'history_tracking/components/branch_list.html',
{'branches': branches},
request=request
)
return HttpResponse(content)
# Paginate changes
paginator = Paginator(diff_result['changes'], ITEMS_PER_PAGE)
diff_result['changes'] = paginator.get_page(page_number)
class HistoryView(LoginRequiredMixin, View):
"""HTMX view for change history"""
# Add comments to changes
for change in diff_result['changes']:
anchor_id = change['metadata']['comment_anchor_id']
change['comments'] = CommentThread.objects.filter(
anchor__contains={'id': anchor_id}
).prefetch_related('comments')
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("No branch selected")
except Exception as e:
messages.error(request, f"Error comparing versions: {str(e)}")
branch = get_object_or_404(VersionBranch, name=branch_name)
tracker = ChangeTracker()
changes = tracker.get_changes(branch)
context = {
'versions': versions,
'selected_version1': version1_id,
'selected_version2': version2_id,
'diff_result': diff_result
}
content = render_to_string(
'history_tracking/components/history_view.html',
{'changes': changes},
request=request
)
return HttpResponse(content)
return render(request, 'history_tracking/version_comparison.html', context)
class MergeView(LoginRequiredMixin, View):
"""HTMX view for merge operations"""
@login_required
@require_http_methods(["POST"])
@transaction.atomic
def submit_for_approval(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""Submit a changeset for approval"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
def get(self, request):
source = request.GET.get('source')
target = request.GET.get('target')
if not request.user.has_perm('history_tracking.submit_for_approval'):
raise PermissionDenied("You don't have permission to submit changes for approval")
if not (source and target):
return HttpResponse("Source and target branches required")
content = render_to_string(
'history_tracking/components/merge_panel.html',
try:
# Initialize approval workflow
state_machine = ApprovalStateMachine(changeset)
stages_config = [
{
'source': source,
'target': target
'name': 'Technical Review',
'required_roles': ['tech_reviewer']
},
request=request
{
'name': 'Final Approval',
'required_roles': ['approver']
}
]
state_machine.initialize_workflow(stages_config)
changeset.status = 'pending_approval'
changeset.save()
messages.success(request, "Changes submitted for approval successfully")
except Exception as e:
messages.error(request, f"Error submitting for approval: {str(e)}")
return render(request, 'history_tracking/approval_status.html', {
'changeset': changeset
})
@login_required
def approval_status(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""View approval status of a changeset"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
current_stage = state_machine.get_current_stage()
context = {
'changeset': changeset,
'current_stage': current_stage,
'can_approve': state_machine.can_user_approve(request.user),
'pending_approvers': state_machine.get_pending_approvers()
}
return render(request, 'history_tracking/approval_status.html', context)
@login_required
@require_http_methods(["POST"])
@transaction.atomic
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""Submit an approval decision"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
try:
decision = request.POST.get('decision', 'approve')
comment = request.POST.get('comment', '')
stage_id = request.POST.get('stage_id')
success = state_machine.submit_approval(
user=request.user,
decision=decision,
comment=comment,
stage_id=stage_id
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
source_name = request.POST.get('source')
target_name = request.POST.get('target')
if success:
messages.success(request, f"Successfully {decision}d changes")
else:
messages.error(request, "Failed to submit approval")
if not (source_name and target_name):
return JsonResponse({
'error': 'Source and target branches required'
}, status=400)
except Exception as e:
messages.error(request, f"Error processing approval: {str(e)}")
try:
source = get_object_or_404(VersionBranch, name=source_name)
target = get_object_or_404(VersionBranch, name=target_name)
branch_manager = BranchManager()
success, conflicts = branch_manager.merge_branches(
source=source,
target=target,
user=request.user
)
if success:
content = render_to_string(
'history_tracking/components/merge_success.html',
{'source': source, 'target': target},
request=request
)
return HttpResponse(content)
else:
content = render_to_string(
'history_tracking/components/merge_conflicts.html',
{
'source': source,
'target': target,
'conflicts': conflicts
},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Merge failed. Please try again.'},
status=500
)
class BranchCreateView(LoginRequiredMixin, View):
"""HTMX view for branch creation"""
def get(self, request):
content = render_to_string(
'history_tracking/components/branch_create.html',
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
parent_name = request.POST.get('parent')
if not name:
return JsonResponse({'error': 'Branch name required'}, status=400)
try:
branch_manager = BranchManager()
parent = None
if parent_name:
parent = get_object_or_404(VersionBranch, name=parent_name)
branch = branch_manager.create_branch(
name=name,
parent=parent,
user=request.user
)
content = render_to_string(
'history_tracking/components/branch_item.html',
{'branch': branch},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Branch creation failed. Please try again.'},
status=500
)
class TagCreateView(LoginRequiredMixin, View):
"""HTMX view for version tagging"""
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("Branch required")
content = render_to_string(
'history_tracking/components/tag_create.html',
{'branch_name': branch_name},
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
branch_name = request.POST.get('branch')
if not (name and branch_name):
return JsonResponse(
{'error': 'Tag name and branch required'},
status=400
)
try:
branch = get_object_or_404(VersionBranch, name=branch_name)
# Get latest historical record for the branch
latest_change = ChangeSet.objects.filter(
branch=branch,
status='applied'
).latest('created_at')
if not latest_change:
return JsonResponse(
{'error': 'No changes to tag'},
status=400
)
tag = VersionTag.objects.create(
name=name,
branch=branch,
historical_record=latest_change.historical_records.latest('history_date'),
created_by=request.user,
metadata={
'tagged_at': timezone.now().isoformat(),
'changeset': latest_change.pk
}
)
content = render_to_string(
'history_tracking/components/tag_item.html',
{'tag': tag},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Tag creation failed. Please try again.'},
status=500
)
return render(request, 'history_tracking/approval_status.html', {
'changeset': changeset
})

View File

@@ -19,73 +19,14 @@
- [x] Merge Panel
- [x] Branch Creation Form
## Asset Integration ✓
- [x] JavaScript
- [x] Version Control core functionality
- [x] HTMX integration
- [x] Event handling
## Future Enhancements
- [ ] Add visual diff viewer - [See Visual Diff Viewer Plan](visual-diff-viewer.md)
- [ ] Implement branch locking - [See Branch Locking System](branch-locking.md)
- [ ] Add commenting on changes - [See Change Comments Framework](change-comments.md)
- [ ] Create change approval workflow - [See Approval Workflow Docs](approval-workflow.md)
- [ ] Add version comparison tool - [See Comparison Tool Spec](version-comparison.md)
- [x] CSS
- [x] Version control styles
- [x] Component styles
- [x] Responsive design
## Template Integration ✓
- [x] Base Template Updates
- [x] Required JS/CSS includes
- [x] Version control status bar
- [x] HTMX setup
- [x] Park System
- [x] Park detail template
- [x] Park list template
- [x] Area detail template
- [x] Rides System
- [x] Ride detail template
- [x] Ride list template
- [x] Reviews System
- [x] Review detail template
- [x] Review list template
- [x] Companies System
- [x] Company detail template
- [x] Company list template
- [x] Manufacturer detail template
- [x] Manufacturer list template
- [x] Designer detail template
- [x] Designer list template
## Model Integration ✓
- [x] Park Model
- [x] VCS integration
- [x] Save method override
- [x] Version info methods
- [x] ParkArea Model
- [x] VCS integration
- [x] Save method override
- [x] Version info methods
- [x] Ride Model
- [x] VCS integration
- [x] Save method override
- [x] Version info methods
- [x] Review Model
- [x] VCS integration
- [x] Save method override
- [x] Version info methods
- [x] Company Models
- [x] Company VCS integration
- [x] Manufacturer VCS integration
- [x] Designer VCS integration
- [x] Save methods override
- [x] Version info methods
## Documentation ✓
## Documentation Updates ✓
- [x] README creation
- [x] Implementation guide
- [x] Template integration guide
@@ -94,85 +35,9 @@
## Testing Requirements ✓
- [x] Unit Tests
- [x] Model tests
- [x] Manager tests
- [x] View tests
- [x] Form tests
- [x] Integration Tests
- [x] Branch operations
- [x] Merge operations
- [x] Change tracking
- [x] UI interactions
- [x] UI Tests
- [x] Component rendering
- [x] User interactions
- [x] Responsive design
- [x] Browser compatibility
## Monitoring Setup ✓
- [x] Performance Metrics
- [x] Branch operation timing
- [x] Merge success rates
- [x] Change tracking overhead
- [x] UI responsiveness
- [x] Error Tracking
- [x] Operation failures
- [x] Merge conflicts
- [x] UI errors
- [x] Performance issues
## Next Steps
1. Testing Implementation
- Write model test suite
- Write manager test suite
- Set up UI testing environment
- Implement integration tests
- Add browser compatibility tests
2. Documentation
- Write comprehensive API documentation
- Create user guide with examples
- Add troubleshooting section
- Include performance considerations
3. Monitoring
- Set up performance monitoring
- Configure error tracking
- Create monitoring dashboards
- Implement alert system
## Known Issues ✓
1. ~~Need to implement proper error handling in JavaScript~~ (Completed)
- Added error boundary system
- Implemented retry mechanisms
- Added error notifications
2. ~~Add loading states to UI components~~ (Completed)
- Added loading indicators
- Implemented state management
- Added visual feedback
3. ~~Implement proper caching for version history~~ (Completed)
- Added multi-level caching
- Implemented cache invalidation
- Added versioning system
4. ~~Add batch operations for multiple changes~~ (Completed)
- Added BatchOperation system
- Implemented bulk processing
- Added queuing system
5. ~~Implement proper cleanup for old versions~~ (Completed)
- Added automated cleanup
- Implemented archival system
- Added maintenance routines
## Future Enhancements ✓
1. Add visual diff viewer
2. Implement branch locking
3. Add commenting on changes
4. Create change approval workflow
5. Add version comparison tool

View File

@@ -0,0 +1,332 @@
/* Approval Panel Styles */
.approval-panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
}
.approval-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.approval-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.approval-status {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
/* Status Colors */
.status-draft {
background-color: #f3f4f6;
color: #6b7280;
}
.status-pending {
background-color: #fef3c7;
color: #92400e;
}
.status-approved {
background-color: #dcfce7;
color: #166534;
}
.status-rejected {
background-color: #fee2e2;
color: #991b1b;
}
.status-applied {
background-color: #dbeafe;
color: #1e40af;
}
.status-failed {
background-color: #fee2e2;
color: #991b1b;
}
.status-reverted {
background-color: #f3f4f6;
color: #6b7280;
}
/* Stages */
.approval-stages {
padding: 1rem;
}
.approval-stage {
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
margin-bottom: 1rem;
overflow: hidden;
}
.approval-stage:last-child {
margin-bottom: 0;
}
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.stage-name {
font-weight: 500;
color: #374151;
}
.stage-status {
font-size: 0.875rem;
font-weight: 500;
}
.stage-status.pending {
color: #92400e;
}
.stage-status.approved {
color: #166534;
}
.stage-status.rejected {
color: #991b1b;
}
.stage-details {
padding: 1rem;
}
.required-roles {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.role-badge {
background: #f3f4f6;
color: #4b5563;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Approvers */
.approvers-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.approver {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.375rem;
}
.approver-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.approver-name {
font-weight: 500;
color: #374151;
}
.approval-date {
font-size: 0.75rem;
color: #6b7280;
}
.approver-decision {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.approver-decision.approved {
color: #166534;
}
.approver-decision.rejected {
color: #991b1b;
}
.approver-comment {
font-size: 0.875rem;
color: #4b5563;
padding: 0.5rem;
background: #fff;
border-radius: 0.25rem;
}
/* Approval Actions */
.approval-actions {
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.approval-comment {
width: 100%;
min-height: 5rem;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
resize: vertical;
font-size: 0.875rem;
}
.approval-comment:focus {
outline: none;
border-color: #3b82f6;
ring: 2px solid rgba(59, 130, 246, 0.5);
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.approve-button,
.reject-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.approve-button {
background-color: #059669;
color: white;
}
.approve-button:hover {
background-color: #047857;
}
.reject-button {
background-color: #dc2626;
color: white;
}
.reject-button:hover {
background-color: #b91c1c;
}
/* History */
.approval-history {
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
.approval-history-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.history-entry {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.375rem;
}
.entry-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.entry-user {
font-weight: 500;
color: #374151;
}
.entry-date {
font-size: 0.75rem;
color: #6b7280;
}
.entry-action {
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.entry-action.submit {
color: #6b7280;
}
.entry-action.approve {
color: #166534;
}
.entry-action.reject {
color: #991b1b;
}
.entry-action.revert {
color: #4b5563;
}
.entry-comment {
font-size: 0.875rem;
color: #4b5563;
padding: 0.5rem;
background: #fff;
border-radius: 0.25rem;
}
/* Responsive Design */
@media (max-width: 640px) {
.approval-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.action-buttons {
flex-direction: column;
}
.approve-button,
.reject-button {
width: 100%;
}
.approver-info {
flex-direction: column;
align-items: flex-start;
}
}

195
static/css/diff-viewer.css Normal file
View File

@@ -0,0 +1,195 @@
/* Diff Viewer Styles */
.diff-viewer {
font-family: ui-monospace, monospace;
margin: 1rem 0;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
overflow: hidden;
background: #fff;
}
.diff-viewer.side-by-side .diff-blocks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: #e5e7eb;
}
.diff-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.diff-metadata {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: #4b5563;
}
.diff-controls {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
}
.diff-controls button {
padding: 0.25rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
background: #fff;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.diff-controls button:hover {
background: #f3f4f6;
}
.diff-section {
border-bottom: 1px solid #e5e7eb;
}
.diff-field-header {
padding: 0.5rem 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-weight: 500;
cursor: pointer;
}
.diff-field-header .syntax-type {
font-size: 0.75rem;
color: #6b7280;
margin-left: 0.5rem;
}
.diff-block {
display: grid;
grid-template-columns: auto 1fr;
background: #fff;
}
.line-numbers {
padding: 0.5rem;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
text-align: right;
color: #6b7280;
user-select: none;
}
.line-number {
display: block;
padding: 0 0.5rem;
font-size: 0.875rem;
}
.diff-block pre {
margin: 0;
padding: 0.5rem;
overflow-x: auto;
}
.diff-block code {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
line-height: 1.5;
}
/* Inline diff styles */
.diff-removed {
background-color: #fee2e2;
text-decoration: line-through;
color: #991b1b;
}
.diff-added {
background-color: #dcfce7;
color: #166534;
}
/* Syntax highlighting */
.language-json .json-key {
color: #059669;
}
.language-python .keyword {
color: #7c3aed;
}
.language-python .string {
color: #059669;
}
.language-python .comment {
color: #6b7280;
font-style: italic;
}
/* Comment threads */
.comment-thread {
border-top: 1px solid #e5e7eb;
padding: 1rem;
background: #f9fafb;
}
.comment {
margin-bottom: 1rem;
padding: 0.5rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
}
.comment:last-child {
margin-bottom: 0;
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.comment-content {
font-size: 0.875rem;
color: #374151;
}
/* Navigation */
.diff-navigation {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.position-indicator {
font-size: 0.875rem;
color: #6b7280;
}
/* Collapsed state */
.diff-section.collapsed .diff-blocks {
display: none;
}
/* Performance warning */
.performance-warning {
padding: 0.5rem;
background: #fffbeb;
border: 1px solid #fcd34d;
color: #92400e;
font-size: 0.875rem;
margin: 0.5rem 1rem;
border-radius: 0.25rem;
}

View File

@@ -0,0 +1,229 @@
/* Inline Comment Panel Styles */
.comment-panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
margin: 1rem 0;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.thread-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.anchor-info {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: #4b5563;
}
.resolution-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.resolve-button {
padding: 0.375rem 0.75rem;
background: #059669;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.resolve-button:hover {
background: #047857;
}
.comments-container {
padding: 1rem;
}
.comment {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.comment:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.comment.reply {
margin-left: 2rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.375rem;
border: none;
}
.comment-author {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.author-avatar {
width: 2rem;
height: 2rem;
border-radius: 9999px;
}
.author-name {
font-weight: 500;
color: #111827;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.875rem;
line-height: 1.5;
color: #374151;
margin-bottom: 0.5rem;
}
.comment-content .mention {
color: #2563eb;
font-weight: 500;
}
.comment-content a {
color: #2563eb;
text-decoration: none;
}
.comment-content a:hover {
text-decoration: underline;
}
.comment-actions {
display: flex;
gap: 0.75rem;
}
.action-button {
font-size: 0.75rem;
color: #6b7280;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: color 0.2s;
}
.action-button:hover {
color: #374151;
}
.reply-form {
padding: 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.reply-form.nested {
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
.reply-input,
.edit-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
resize: vertical;
margin-bottom: 0.5rem;
}
.reply-input:focus,
.edit-input:focus {
outline: none;
border-color: #3b82f6;
ring: 2px solid rgba(59, 130, 246, 0.5);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.reply-button,
.save-button {
padding: 0.375rem 0.75rem;
background: #3b82f6;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.reply-button:hover,
.save-button:hover {
background: #2563eb;
}
.cancel-button {
padding: 0.375rem 0.75rem;
background: #fff;
color: #4b5563;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.cancel-button:hover {
background: #f3f4f6;
}
/* Responsive Design */
@media (max-width: 640px) {
.comment.reply {
margin-left: 1rem;
}
.comment-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.resolve-button {
width: 100%;
text-align: center;
}
}

View File

@@ -0,0 +1,353 @@
/* Version Comparison Tool Styles */
.version-comparison-tool {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
}
/* Header Styles */
.comparison-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.comparison-header h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
.selected-versions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.selected-version {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: #f3f4f6;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.version-label {
font-weight: 500;
color: #374151;
}
.version-value {
color: #4b5563;
}
.remove-version {
border: none;
background: none;
color: #6b7280;
cursor: pointer;
padding: 0.125rem 0.25rem;
font-size: 1rem;
line-height: 1;
}
.remove-version:hover {
color: #ef4444;
}
/* Timeline Styles */
.version-timeline {
position: relative;
padding: 2rem 1rem;
overflow-x: auto;
cursor: grab;
user-select: none;
}
.version-timeline.active {
cursor: grabbing;
}
.timeline-track {
position: relative;
height: 2px;
background: #e5e7eb;
margin: 0 2rem;
}
.timeline-point {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
}
.timeline-point::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #6b7280;
border-radius: 50%;
transition: all 0.2s;
}
.timeline-point.selected::before {
background: #3b82f6;
border-color: #3b82f6;
}
.impact-indicator {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
border-radius: 50%;
background: rgba(59, 130, 246, 0.1);
border: 2px solid rgba(59, 130, 246, 0.2);
transition: all 0.2s;
}
.timeline-labels {
display: flex;
position: absolute;
bottom: 0.5rem;
left: 0;
right: 0;
padding: 0 2rem;
}
.timeline-label {
position: absolute;
transform: translateX(-50%);
text-align: center;
min-width: 100px;
}
.version-name {
font-weight: 500;
font-size: 0.875rem;
color: #374151;
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.75rem;
color: #6b7280;
}
/* Comparison Actions */
.comparison-actions {
display: flex;
gap: 0.75rem;
}
.compare-button,
.rollback-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.compare-button {
background-color: #3b82f6;
color: white;
}
.compare-button:hover {
background-color: #2563eb;
}
.rollback-button {
background-color: #6b7280;
color: white;
}
.rollback-button:hover {
background-color: #4b5563;
}
/* Comparison Results */
.comparison-content {
padding: 1rem;
}
.comparison-placeholder {
text-align: center;
padding: 2rem;
color: #6b7280;
font-size: 0.875rem;
}
.results-loading {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.diff-section {
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
margin-bottom: 1rem;
overflow: hidden;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.diff-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.diff-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
}
.change-item {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.change-item:last-child {
border-bottom: none;
}
.change-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.change-type {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
.change-file {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: #4b5563;
}
.change-content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.old-value,
.new-value {
background: #f9fafb;
border-radius: 0.375rem;
overflow: hidden;
}
.value-header {
padding: 0.5rem;
background: #f3f4f6;
font-size: 0.75rem;
font-weight: 500;
color: #4b5563;
border-bottom: 1px solid #e5e7eb;
}
.change-content pre {
margin: 0;
padding: 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
overflow-x: auto;
}
/* Warning/Error Messages */
.comparison-warning,
.comparison-error {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
animation: slideIn 0.3s ease-out;
}
.comparison-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #f59e0b;
}
.comparison-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #ef4444;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.comparison-header {
padding: 0.75rem;
}
.selected-versions {
flex-direction: column;
}
.comparison-actions {
flex-direction: column;
}
.compare-button,
.rollback-button {
width: 100%;
}
.change-content {
grid-template-columns: 1fr;
}
.timeline-label {
min-width: 80px;
}
}

View File

@@ -142,6 +142,115 @@
margin: 0.25rem;
}
/* Branch Lock Status */
.lock-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
margin-left: 0.5rem;
}
.lock-status.locked {
background-color: rgba(239, 68, 68, 0.1);
color: var(--vcs-error);
}
.lock-status.unlocked {
background-color: rgba(16, 185, 129, 0.1);
color: var(--vcs-success);
}
.lock-status .lock-icon {
width: 1rem;
height: 1rem;
}
.lock-info {
display: flex;
flex-direction: column;
font-size: 0.75rem;
color: var(--vcs-gray);
}
.lock-info .user {
font-weight: 500;
color: inherit;
}
.lock-info .expiry {
opacity: 0.8;
}
/* Lock Controls */
.lock-controls {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.lock-button {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.lock-button.lock {
background-color: var(--vcs-error);
color: white;
}
.lock-button.unlock {
background-color: var(--vcs-success);
color: white;
}
.lock-button:hover {
opacity: 0.9;
}
.lock-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Lock History */
.lock-history {
margin-top: 1rem;
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.lock-history-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.5rem 0;
font-size: 0.875rem;
}
.lock-history-item .action {
font-weight: 500;
}
.lock-history-item .timestamp {
color: var(--vcs-gray);
}
.lock-history-item .reason {
margin-top: 0.25rem;
color: var(--vcs-gray);
font-style: italic;
}
/* Loading States */
.vcs-loading {
position: relative;

View File

@@ -0,0 +1,203 @@
// Collaboration System
class CollaborationSystem {
constructor(options = {}) {
this.onCommentAdded = options.onCommentAdded || (() => {});
this.onCommentResolved = options.onCommentResolved || (() => {});
this.onThreadCreated = options.onThreadCreated || (() => {});
this.socket = null;
this.currentUser = options.currentUser;
}
initialize(socketUrl) {
this.socket = new WebSocket(socketUrl);
this.setupSocketHandlers();
}
setupSocketHandlers() {
if (!this.socket) return;
this.socket.addEventListener('open', () => {
console.log('Collaboration system connected');
});
this.socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
this.handleEvent(data);
} catch (error) {
console.error('Failed to parse collaboration event:', error);
}
});
this.socket.addEventListener('close', () => {
console.log('Collaboration system disconnected');
// Attempt to reconnect after delay
setTimeout(() => this.reconnect(), 5000);
});
this.socket.addEventListener('error', (error) => {
console.error('Collaboration system error:', error);
});
}
handleEvent(event) {
switch (event.type) {
case 'comment_added':
this.onCommentAdded(event.data);
break;
case 'comment_resolved':
this.onCommentResolved(event.data);
break;
case 'thread_created':
this.onThreadCreated(event.data);
break;
default:
console.warn('Unknown collaboration event:', event.type);
}
}
async createCommentThread(changeId, anchor, initialComment) {
try {
const response = await fetch('/vcs/comments/threads/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
change_id: changeId,
anchor: anchor,
initial_comment: initialComment
})
});
if (!response.ok) {
throw new Error('Failed to create comment thread');
}
const thread = await response.json();
// Notify other users through WebSocket
this.broadcastEvent({
type: 'thread_created',
data: {
thread_id: thread.id,
change_id: changeId,
anchor: anchor,
author: this.currentUser,
timestamp: new Date().toISOString()
}
});
return thread;
} catch (error) {
console.error('Error creating comment thread:', error);
throw error;
}
}
async addComment(threadId, content, parentId = null) {
try {
const response = await fetch('/vcs/comments/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
thread_id: threadId,
content: content,
parent_id: parentId
})
});
if (!response.ok) {
throw new Error('Failed to add comment');
}
const comment = await response.json();
// Notify other users through WebSocket
this.broadcastEvent({
type: 'comment_added',
data: {
comment_id: comment.id,
thread_id: threadId,
parent_id: parentId,
author: this.currentUser,
content: content,
timestamp: new Date().toISOString()
}
});
return comment;
} catch (error) {
console.error('Error adding comment:', error);
throw error;
}
}
async resolveThread(threadId) {
try {
const response = await fetch(`/vcs/comments/threads/${threadId}/resolve/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
}
});
if (!response.ok) {
throw new Error('Failed to resolve thread');
}
const result = await response.json();
// Notify other users through WebSocket
this.broadcastEvent({
type: 'comment_resolved',
data: {
thread_id: threadId,
resolver: this.currentUser,
timestamp: new Date().toISOString()
}
});
return result;
} catch (error) {
console.error('Error resolving thread:', error);
throw error;
}
}
broadcastEvent(event) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(event));
}
}
reconnect() {
if (this.socket) {
try {
this.socket.close();
} catch (error) {
console.error('Error closing socket:', error);
}
}
this.initialize(this.socketUrl);
}
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
}
disconnect() {
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
export default CollaborationSystem;

View File

@@ -0,0 +1,234 @@
// Approval Panel Component
class ApprovalPanel {
constructor(options = {}) {
this.container = null;
this.changeset = null;
this.currentUser = options.currentUser;
this.onApprove = options.onApprove || (() => {});
this.onReject = options.onReject || (() => {});
this.onSubmit = options.onSubmit || (() => {});
}
initialize(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`Container element with id "${containerId}" not found`);
}
}
setChangeset(changeset) {
this.changeset = changeset;
this.render();
}
render() {
if (!this.container || !this.changeset) return;
const approvalState = this.changeset.approval_state || [];
const currentStage = approvalState.find(s => s.status === 'pending') || approvalState[0];
this.container.innerHTML = `
<div class="approval-panel">
<div class="approval-header">
<h3 class="approval-title">Change Approval</h3>
${this._renderStatus()}
</div>
<div class="approval-stages">
${this._renderStages(approvalState)}
</div>
${currentStage && this.changeset.status === 'pending_approval' ?
this._renderApprovalActions(currentStage) : ''
}
<div class="approval-history">
${this._renderHistory()}
</div>
</div>
`;
this.attachEventListeners();
}
_renderStatus() {
const statusMap = {
'draft': { class: 'status-draft', text: 'Draft' },
'pending_approval': { class: 'status-pending', text: 'Pending Approval' },
'approved': { class: 'status-approved', text: 'Approved' },
'rejected': { class: 'status-rejected', text: 'Rejected' },
'applied': { class: 'status-applied', text: 'Applied' },
'failed': { class: 'status-failed', text: 'Failed' },
'reverted': { class: 'status-reverted', text: 'Reverted' }
};
const status = statusMap[this.changeset.status] || statusMap.draft;
return `
<div class="approval-status ${status.class}">
${status.text}
</div>
`;
}
_renderStages(stages) {
return stages.map((stage, index) => `
<div class="approval-stage ${stage.status}-stage">
<div class="stage-header">
<span class="stage-name">${stage.name}</span>
<span class="stage-status ${stage.status}">${
this._formatStageStatus(stage.status)
}</span>
</div>
<div class="stage-details">
<div class="required-roles">
${stage.required_roles.map(role => `
<span class="role-badge">${role}</span>
`).join('')}
</div>
${stage.approvers.length > 0 ? `
<div class="approvers-list">
${stage.approvers.map(approver => this._renderApprover(approver)).join('')}
</div>
` : ''}
</div>
</div>
`).join('');
}
_renderApprover(approver) {
const decisionClass = approver.decision === 'approve' ? 'approved' : 'rejected';
return `
<div class="approver">
<div class="approver-info">
<span class="approver-name">${approver.username}</span>
<span class="approval-date">
${new Date(approver.timestamp).toLocaleString()}
</span>
</div>
<div class="approver-decision ${decisionClass}">
${approver.decision === 'approve' ? 'Approved' : 'Rejected'}
</div>
${approver.comment ? `
<div class="approver-comment">${approver.comment}</div>
` : ''}
</div>
`;
}
_renderApprovalActions(currentStage) {
if (!this.canUserApprove(currentStage)) {
return '';
}
return `
<div class="approval-actions">
<textarea
class="approval-comment"
placeholder="Add your comments (optional)"
></textarea>
<div class="action-buttons">
<button class="reject-button" onclick="this.handleReject()">
Reject Changes
</button>
<button class="approve-button" onclick="this.handleApprove()">
Approve Changes
</button>
</div>
</div>
`;
}
_renderHistory() {
if (!this.changeset.approval_history?.length) {
return '';
}
return `
<div class="approval-history-list">
${this.changeset.approval_history.map(entry => `
<div class="history-entry">
<div class="entry-header">
<span class="entry-user">${entry.username}</span>
<span class="entry-date">
${new Date(entry.timestamp).toLocaleString()}
</span>
</div>
<div class="entry-action ${entry.action}">
${this._formatHistoryAction(entry.action)}
</div>
${entry.comment ? `
<div class="entry-comment">${entry.comment}</div>
` : ''}
</div>
`).join('')}
</div>
`;
}
_formatStageStatus(status) {
const statusMap = {
'pending': 'Pending',
'approved': 'Approved',
'rejected': 'Rejected'
};
return statusMap[status] || status;
}
_formatHistoryAction(action) {
const actionMap = {
'submit': 'Submitted for approval',
'approve': 'Approved changes',
'reject': 'Rejected changes',
'revert': 'Reverted approval'
};
return actionMap[action] || action;
}
canUserApprove(stage) {
if (!this.currentUser) return false;
// Check if user already approved
const alreadyApproved = stage.approvers.some(
a => a.user_id === this.currentUser.id
);
if (alreadyApproved) return false;
// Check if user has required role
return stage.required_roles.some(
role => this.currentUser.roles.includes(role)
);
}
async handleApprove() {
const commentEl = this.container.querySelector('.approval-comment');
const comment = commentEl ? commentEl.value.trim() : '';
try {
await this.onApprove(comment);
this.render();
} catch (error) {
console.error('Failed to approve:', error);
// Show error message
}
}
async handleReject() {
const commentEl = this.container.querySelector('.approval-comment');
const comment = commentEl ? commentEl.value.trim() : '';
try {
await this.onReject(comment);
this.render();
} catch (error) {
console.error('Failed to reject:', error);
// Show error message
}
}
attachEventListeners() {
// Add any additional event listeners if needed
}
}
export default ApprovalPanel;

View File

@@ -0,0 +1,274 @@
// Enhanced Diff Viewer Component
class DiffViewer {
constructor(options = {}) {
this.renderStrategy = options.renderStrategy || 'side-by-side';
this.syntaxHighlighters = new Map();
this.commentThreads = [];
this.container = null;
this.performance = {
startTime: null,
endTime: null
};
}
initialize(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`Container element with id "${containerId}" not found`);
}
this.setupSyntaxHighlighters();
}
setupSyntaxHighlighters() {
// Set up Prism.js or similar syntax highlighting library
this.syntaxHighlighters.set('text', this.plainTextHighlighter);
this.syntaxHighlighters.set('json', this.jsonHighlighter);
this.syntaxHighlighters.set('python', this.pythonHighlighter);
}
async render(diffData) {
this.performance.startTime = performance.now();
const { changes, metadata, navigation } = diffData;
const content = this.renderStrategy === 'side-by-side'
? this.renderSideBySide(changes)
: this.renderInline(changes);
this.container.innerHTML = `
<div class="diff-viewer ${this.renderStrategy}">
<div class="diff-header">
${this.renderMetadata(metadata)}
${this.renderControls()}
</div>
<div class="diff-content">
${content}
</div>
${this.renderNavigation(navigation)}
</div>
`;
this.attachEventListeners();
await this.highlightSyntax();
this.performance.endTime = performance.now();
this.updatePerformanceMetrics();
}
renderSideBySide(changes) {
return Object.entries(changes).map(([field, change]) => `
<div class="diff-section" data-field="${field}">
<div class="diff-field-header">
<span class="field-name">${field}</span>
<span class="syntax-type">${change.syntax_type}</span>
</div>
<div class="diff-blocks">
<div class="diff-block old" data-anchor="${change.metadata.comment_anchor_id}-old">
<div class="line-numbers">
${this.renderLineNumbers(change.metadata.line_numbers.old)}
</div>
<pre><code class="language-${change.syntax_type}">${this.escapeHtml(change.old)}</code></pre>
</div>
<div class="diff-block new" data-anchor="${change.metadata.comment_anchor_id}-new">
<div class="line-numbers">
${this.renderLineNumbers(change.metadata.line_numbers.new)}
</div>
<pre><code class="language-${change.syntax_type}">${this.escapeHtml(change.new)}</code></pre>
</div>
</div>
</div>
`).join('');
}
renderInline(changes) {
return Object.entries(changes).map(([field, change]) => `
<div class="diff-section" data-field="${field}">
<div class="diff-field-header">
<span class="field-name">${field}</span>
<span class="syntax-type">${change.syntax_type}</span>
</div>
<div class="diff-block inline" data-anchor="${change.metadata.comment_anchor_id}">
<div class="line-numbers">
${this.renderLineNumbers(change.metadata.line_numbers.new)}
</div>
<pre><code class="language-${change.syntax_type}">
${this.renderInlineDiff(change.old, change.new)}
</code></pre>
</div>
</div>
`).join('');
}
renderMetadata(metadata) {
return `
<div class="diff-metadata">
<span class="timestamp">${new Date(metadata.timestamp).toLocaleString()}</span>
<span class="user">${metadata.user || 'Anonymous'}</span>
<span class="change-type">${this.formatChangeType(metadata.change_type)}</span>
${metadata.reason ? `<span class="reason">${metadata.reason}</span>` : ''}
</div>
`;
}
renderControls() {
return `
<div class="diff-controls">
<button class="btn-view-mode" data-mode="side-by-side">Side by Side</button>
<button class="btn-view-mode" data-mode="inline">Inline</button>
<button class="btn-collapse-all">Collapse All</button>
<button class="btn-expand-all">Expand All</button>
</div>
`;
}
renderNavigation(navigation) {
return `
<div class="diff-navigation">
${navigation.prev_id ? `<button class="btn-prev" data-id="${navigation.prev_id}">Previous</button>` : ''}
${navigation.next_id ? `<button class="btn-next" data-id="${navigation.next_id}">Next</button>` : ''}
<span class="position-indicator">Change ${navigation.current_position}</span>
</div>
`;
}
renderLineNumbers(numbers) {
return numbers.map(num => `<span class="line-number">${num}</span>`).join('');
}
renderInlineDiff(oldText, newText) {
// Simple inline diff implementation - could be enhanced with more sophisticated diff algorithm
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const diffLines = [];
for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) {
if (oldLines[i] !== newLines[i]) {
if (oldLines[i]) {
diffLines.push(`<span class="diff-removed">${this.escapeHtml(oldLines[i])}</span>`);
}
if (newLines[i]) {
diffLines.push(`<span class="diff-added">${this.escapeHtml(newLines[i])}</span>`);
}
} else if (oldLines[i]) {
diffLines.push(this.escapeHtml(oldLines[i]));
}
}
return diffLines.join('\n');
}
attachEventListeners() {
// View mode switching
this.container.querySelectorAll('.btn-view-mode').forEach(btn => {
btn.addEventListener('click', () => {
this.renderStrategy = btn.dataset.mode;
this.render(this.currentDiffData);
});
});
// Collapse/Expand functionality
this.container.querySelectorAll('.diff-section').forEach(section => {
section.querySelector('.diff-field-header').addEventListener('click', () => {
section.classList.toggle('collapsed');
});
});
// Navigation
this.container.querySelectorAll('.diff-navigation button').forEach(btn => {
btn.addEventListener('click', () => {
this.navigateToChange(btn.dataset.id);
});
});
}
async highlightSyntax() {
const codeBlocks = this.container.querySelectorAll('code[class^="language-"]');
for (const block of codeBlocks) {
const syntax = block.className.replace('language-', '');
const highlighter = this.syntaxHighlighters.get(syntax);
if (highlighter) {
await highlighter(block);
}
}
}
// Syntax highlighters
async plainTextHighlighter(element) {
// No highlighting needed for plain text
return element;
}
async jsonHighlighter(element) {
try {
const content = element.textContent;
const parsed = JSON.parse(content);
element.textContent = JSON.stringify(parsed, null, 2);
// Apply JSON syntax highlighting classes
element.innerHTML = element.innerHTML.replace(
/"([^"]+)":/g,
'<span class="json-key">"$1":</span>'
);
} catch (e) {
console.warn('JSON parsing failed:', e);
}
return element;
}
async pythonHighlighter(element) {
// Basic Python syntax highlighting
element.innerHTML = element.innerHTML
.replace(/(def|class|import|from|return|if|else|try|except)\b/g, '<span class="keyword">$1</span>')
.replace(/(["'])(.*?)\1/g, '<span class="string">$1$2$1</span>')
.replace(/#.*/g, '<span class="comment">$&</span>');
return element;
}
updatePerformanceMetrics() {
const renderTime = this.performance.endTime - this.performance.startTime;
if (renderTime > 200) { // Performance budget: 200ms
console.warn(`Diff render time (${renderTime}ms) exceeded performance budget`);
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatChangeType(type) {
const types = {
'C': 'Changed',
'D': 'Deleted',
'A': 'Added'
};
return types[type] || type;
}
addCommentThread(anchor, thread) {
this.commentThreads.push({ anchor, thread });
this.renderCommentThreads();
}
renderCommentThreads() {
this.commentThreads.forEach(({ anchor, thread }) => {
const element = this.container.querySelector(`[data-anchor="${anchor}"]`);
if (element) {
const threadElement = document.createElement('div');
threadElement.className = 'comment-thread';
threadElement.innerHTML = thread.map(comment => `
<div class="comment">
<div class="comment-header">
<span class="comment-author">${comment.author}</span>
<span class="comment-date">${new Date(comment.date).toLocaleString()}</span>
</div>
<div class="comment-content">${comment.content}</div>
</div>
`).join('');
element.appendChild(threadElement);
}
});
}
}
export default DiffViewer;

View File

@@ -0,0 +1,285 @@
// Inline Comment Panel Component
class InlineCommentPanel {
constructor(options = {}) {
this.container = null;
this.thread = null;
this.canResolve = options.canResolve || false;
this.onReply = options.onReply || (() => {});
this.onResolve = options.onResolve || (() => {});
this.currentUser = options.currentUser;
}
initialize(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`Container element with id "${containerId}" not found`);
}
}
setThread(thread) {
this.thread = thread;
this.render();
}
render() {
if (!this.container || !this.thread) return;
this.container.innerHTML = `
<div class="comment-panel">
<div class="comment-header">
<div class="thread-info">
<span class="anchor-info">${this.formatAnchor(this.thread.anchor)}</span>
${this.thread.is_resolved ?
`<span class="resolution-badge">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
Resolved
</span>`
: ''
}
</div>
${this.canResolve && !this.thread.is_resolved ?
`<button class="resolve-button" onclick="this.resolveThread()">
Resolve Thread
</button>`
: ''
}
</div>
<div class="comments-container">
${this.renderComments(this.thread.comments)}
</div>
${!this.thread.is_resolved ?
`<div class="reply-form">
<textarea
class="reply-input"
placeholder="Write a reply..."
rows="2"
></textarea>
<div class="form-actions">
<button class="reply-button" onclick="this.submitReply()">
Reply
</button>
</div>
</div>`
: ''
}
</div>
`;
this.attachEventListeners();
}
renderComments(comments) {
return comments.map(comment => `
<div class="comment ${comment.parent_comment ? 'reply' : ''}" data-comment-id="${comment.id}">
<div class="comment-author">
<img src="${comment.author.avatar_url || '/static/images/default-avatar.png'}"
alt="${comment.author.username}"
class="author-avatar"
/>
<span class="author-name">${comment.author.username}</span>
<span class="comment-date">${this.formatDate(comment.created_at)}</span>
</div>
<div class="comment-content">
${this.formatCommentContent(comment.content)}
</div>
${this.renderCommentActions(comment)}
${comment.replies ?
`<div class="replies">
${this.renderComments(comment.replies)}
</div>`
: ''
}
</div>
`).join('');
}
renderCommentActions(comment) {
if (this.thread.is_resolved) return '';
return `
<div class="comment-actions">
<button class="action-button" onclick="this.showReplyForm('${comment.id}')">
Reply
</button>
${comment.author.id === this.currentUser.id ?
`<button class="action-button" onclick="this.editComment('${comment.id}')">
Edit
</button>`
: ''
}
</div>
`;
}
formatCommentContent(content) {
// Replace @mentions with styled spans
content = content.replace(/@(\w+)/g, '<span class="mention">@$1</span>');
// Convert URLs to links
content = content.replace(
/(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g,
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
);
return content;
}
formatAnchor(anchor) {
const start = anchor.line_start;
const end = anchor.line_end;
const file = anchor.file_path.split('/').pop();
return end > start ?
`${file}:${start}-${end}` :
`${file}:${start}`;
}
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
}
attachEventListeners() {
const replyInput = this.container.querySelector('.reply-input');
if (replyInput) {
replyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
this.submitReply();
}
});
}
}
async submitReply() {
const input = this.container.querySelector('.reply-input');
const content = input.value.trim();
if (!content) return;
try {
await this.onReply(content);
input.value = '';
} catch (error) {
console.error('Failed to submit reply:', error);
// Show error message to user
}
}
async resolveThread() {
try {
await this.onResolve();
this.render();
} catch (error) {
console.error('Failed to resolve thread:', error);
// Show error message to user
}
}
showReplyForm(commentId) {
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
if (!comment) return;
const replyForm = document.createElement('div');
replyForm.className = 'reply-form nested';
replyForm.innerHTML = `
<textarea
class="reply-input"
placeholder="Write a reply..."
rows="2"
></textarea>
<div class="form-actions">
<button class="cancel-button" onclick="this.hideReplyForm('${commentId}')">
Cancel
</button>
<button class="reply-button" onclick="this.submitNestedReply('${commentId}')">
Reply
</button>
</div>
`;
comment.appendChild(replyForm);
replyForm.querySelector('.reply-input').focus();
}
hideReplyForm(commentId) {
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
if (!comment) return;
const replyForm = comment.querySelector('.reply-form');
if (replyForm) {
replyForm.remove();
}
}
async submitNestedReply(parentId) {
const comment = this.container.querySelector(`[data-comment-id="${parentId}"]`);
if (!comment) return;
const input = comment.querySelector('.reply-input');
const content = input.value.trim();
if (!content) return;
try {
await this.onReply(content, parentId);
this.hideReplyForm(parentId);
} catch (error) {
console.error('Failed to submit reply:', error);
// Show error message to user
}
}
editComment(commentId) {
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
if (!comment) return;
const contentDiv = comment.querySelector('.comment-content');
const content = contentDiv.textContent;
contentDiv.innerHTML = `
<textarea class="edit-input" rows="2">${content}</textarea>
<div class="form-actions">
<button class="cancel-button" onclick="this.cancelEdit('${commentId}')">
Cancel
</button>
<button class="save-button" onclick="this.saveEdit('${commentId}')">
Save
</button>
</div>
`;
}
cancelEdit(commentId) {
// Refresh the entire thread to restore original content
this.render();
}
async saveEdit(commentId) {
const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
if (!comment) return;
const input = comment.querySelector('.edit-input');
const content = input.value.trim();
if (!content) return;
try {
// Emit edit event
const event = new CustomEvent('comment-edited', {
detail: { commentId, content }
});
this.container.dispatchEvent(event);
// Refresh the thread
this.render();
} catch (error) {
console.error('Failed to edit comment:', error);
// Show error message to user
}
}
}
export default InlineCommentPanel;

View File

@@ -0,0 +1,314 @@
// Version Comparison Component
class VersionComparison {
constructor(options = {}) {
this.container = null;
this.versions = new Map();
this.selectedVersions = new Set();
this.maxSelections = options.maxSelections || 3;
this.onCompare = options.onCompare || (() => {});
this.onRollback = options.onRollback || (() => {});
this.timeline = null;
}
initialize(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
throw new Error(`Container element with id "${containerId}" not found`);
}
this._initializeTimeline();
}
setVersions(versions) {
this.versions = new Map(versions.map(v => [v.name, v]));
this._updateTimeline();
this.render();
}
_initializeTimeline() {
this.timeline = document.createElement('div');
this.timeline.className = 'version-timeline';
this.container.appendChild(this.timeline);
}
_updateTimeline() {
const sortedVersions = Array.from(this.versions.values())
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
this.timeline.innerHTML = `
<div class="timeline-track">
${this._renderTimelineDots(sortedVersions)}
</div>
<div class="timeline-labels">
${this._renderTimelineLabels(sortedVersions)}
</div>
`;
}
_renderTimelineDots(versions) {
return versions.map(version => `
<div class="timeline-point ${this.selectedVersions.has(version.name) ? 'selected' : ''}"
data-version="${version.name}"
style="--impact-score: ${version.comparison_metadata.impact_score || 0}"
onclick="this._toggleVersionSelection('${version.name}')">
<div class="point-indicator"></div>
${this._renderImpactIndicator(version)}
</div>
`).join('');
}
_renderImpactIndicator(version) {
const impact = version.comparison_metadata.impact_score || 0;
const size = Math.max(8, Math.min(24, impact * 24)); // 8-24px based on impact
return `
<div class="impact-indicator"
style="width: ${size}px; height: ${size}px"
title="Impact Score: ${Math.round(impact * 100)}%">
</div>
`;
}
_renderTimelineLabels(versions) {
return versions.map(version => `
<div class="timeline-label" data-version="${version.name}">
<div class="version-name">${version.name}</div>
<div class="version-date">
${new Date(version.created_at).toLocaleDateString()}
</div>
</div>
`).join('');
}
render() {
if (!this.container) return;
const selectedVersionsArray = Array.from(this.selectedVersions);
this.container.innerHTML = `
<div class="version-comparison-tool">
<div class="comparison-header">
<h3>Version Comparison</h3>
<div class="selected-versions">
${this._renderSelectedVersions(selectedVersionsArray)}
</div>
<div class="comparison-actions">
${this._renderActionButtons(selectedVersionsArray)}
</div>
</div>
${this.timeline.outerHTML}
<div class="comparison-content">
${this._renderComparisonContent(selectedVersionsArray)}
</div>
</div>
`;
this.attachEventListeners();
}
_renderSelectedVersions(selectedVersions) {
return selectedVersions.map((version, index) => `
<div class="selected-version">
<span class="version-label">Version ${index + 1}:</span>
<span class="version-value">${version}</span>
<button class="remove-version" onclick="this._removeVersion('${version}')">
×
</button>
</div>
`).join('');
}
_renderActionButtons(selectedVersions) {
const canCompare = selectedVersions.length >= 2;
const canRollback = selectedVersions.length === 1;
return `
${canCompare ? `
<button class="compare-button" onclick="this._handleCompare()">
Compare Versions
</button>
` : ''}
${canRollback ? `
<button class="rollback-button" onclick="this._handleRollback()">
Rollback to Version
</button>
` : ''}
`;
}
_renderComparisonContent(selectedVersions) {
if (selectedVersions.length < 2) {
return `
<div class="comparison-placeholder">
Select at least two versions to compare
</div>
`;
}
return `
<div class="comparison-results">
<div class="results-loading">
Computing differences...
</div>
</div>
`;
}
_toggleVersionSelection(versionName) {
if (this.selectedVersions.has(versionName)) {
this.selectedVersions.delete(versionName);
} else if (this.selectedVersions.size < this.maxSelections) {
this.selectedVersions.add(versionName);
} else {
// Show max selections warning
this._showWarning(`Maximum ${this.maxSelections} versions can be compared`);
return;
}
this.render();
}
_removeVersion(versionName) {
this.selectedVersions.delete(versionName);
this.render();
}
async _handleCompare() {
const selectedVersions = Array.from(this.selectedVersions);
if (selectedVersions.length < 2) return;
try {
const results = await this.onCompare(selectedVersions);
this._renderComparisonResults(results);
} catch (error) {
console.error('Comparison failed:', error);
this._showError('Failed to compare versions');
}
}
async _handleRollback() {
const selectedVersion = Array.from(this.selectedVersions)[0];
if (!selectedVersion) return;
try {
await this.onRollback(selectedVersion);
// Handle successful rollback
} catch (error) {
console.error('Rollback failed:', error);
this._showError('Failed to rollback version');
}
}
_renderComparisonResults(results) {
const resultsContainer = this.container.querySelector('.comparison-results');
if (!resultsContainer) return;
resultsContainer.innerHTML = `
<div class="results-content">
${results.map(diff => this._renderDiffSection(diff)).join('')}
</div>
`;
}
_renderDiffSection(diff) {
return `
<div class="diff-section">
<div class="diff-header">
<h4>Changes: ${diff.version1}${diff.version2}</h4>
<div class="diff-stats">
<span class="computation-time">
Computed in ${diff.computation_time.toFixed(2)}s
</span>
<span class="impact-score">
Impact Score: ${Math.round(diff.impact_score * 100)}%
</span>
</div>
</div>
<div class="changes-list">
${this._renderChanges(diff.changes)}
</div>
</div>
`;
}
_renderChanges(changes) {
return changes.map(change => `
<div class="change-item">
<div class="change-header">
<span class="change-type">${change.type}</span>
<span class="change-file">${change.file}</span>
</div>
<div class="change-content">
<div class="old-value">
<div class="value-header">Previous</div>
<pre>${this._escapeHtml(change.old_value)}</pre>
</div>
<div class="new-value">
<div class="value-header">New</div>
<pre>${this._escapeHtml(change.new_value)}</pre>
</div>
</div>
</div>
`).join('');
}
_showWarning(message) {
const warning = document.createElement('div');
warning.className = 'comparison-warning';
warning.textContent = message;
this.container.appendChild(warning);
setTimeout(() => warning.remove(), 3000);
}
_showError(message) {
const error = document.createElement('div');
error.className = 'comparison-error';
error.textContent = message;
this.container.appendChild(error);
setTimeout(() => error.remove(), 3000);
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
attachEventListeners() {
// Timeline scroll handling
const timeline = this.container.querySelector('.version-timeline');
if (timeline) {
let isDown = false;
let startX;
let scrollLeft;
timeline.addEventListener('mousedown', (e) => {
isDown = true;
timeline.classList.add('active');
startX = e.pageX - timeline.offsetLeft;
scrollLeft = timeline.scrollLeft;
});
timeline.addEventListener('mouseleave', () => {
isDown = false;
timeline.classList.remove('active');
});
timeline.addEventListener('mouseup', () => {
isDown = false;
timeline.classList.remove('active');
});
timeline.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - timeline.offsetLeft;
const walk = (x - startX) * 2;
timeline.scrollLeft = scrollLeft - walk;
});
}
}
}
export default VersionComparison;

View File

@@ -0,0 +1,190 @@
// Virtual Scroller Component
// Implements efficient scrolling for large lists by only rendering visible items
class VirtualScroller {
constructor(options) {
this.container = options.container;
this.itemHeight = options.itemHeight;
this.bufferSize = options.bufferSize || 5;
this.renderItem = options.renderItem;
this.items = [];
this.scrollTop = 0;
this.visibleItems = new Map();
this.observer = new IntersectionObserver(
this._handleIntersection.bind(this),
{ threshold: 0.1 }
);
this._setupContainer();
this._bindEvents();
}
_setupContainer() {
if (!this.container) {
throw new Error('Container element is required');
}
this.container.style.position = 'relative';
this.container.style.height = '600px'; // Default height
this.container.style.overflowY = 'auto';
// Create spacer element to maintain scroll height
this.spacer = document.createElement('div');
this.spacer.style.position = 'absolute';
this.spacer.style.top = '0';
this.spacer.style.left = '0';
this.spacer.style.width = '1px';
this.container.appendChild(this.spacer);
}
_bindEvents() {
this.container.addEventListener(
'scroll',
this._debounce(this._handleScroll.bind(this), 16)
);
// Handle container resize
if (window.ResizeObserver) {
const resizeObserver = new ResizeObserver(this._debounce(() => {
this._render();
}, 16));
resizeObserver.observe(this.container);
}
}
setItems(items) {
this.items = items;
this.spacer.style.height = `${items.length * this.itemHeight}px`;
this._render();
}
_handleScroll() {
this.scrollTop = this.container.scrollTop;
this._render();
}
_handleIntersection(entries) {
entries.forEach(entry => {
const itemId = entry.target.dataset.itemId;
if (!entry.isIntersecting) {
this.visibleItems.delete(itemId);
entry.target.remove();
}
});
}
_render() {
const visibleRange = this._getVisibleRange();
const itemsToRender = new Set();
// Calculate which items should be visible
for (let i = visibleRange.start; i <= visibleRange.end; i++) {
if (i >= 0 && i < this.items.length) {
itemsToRender.add(i);
}
}
// Remove items that are no longer visible
for (const [itemId] of this.visibleItems) {
const index = parseInt(itemId);
if (!itemsToRender.has(index)) {
const element = this.container.querySelector(`[data-item-id="${itemId}"]`);
if (element) {
this.observer.unobserve(element);
element.remove();
}
this.visibleItems.delete(itemId);
}
}
// Add new visible items
for (const index of itemsToRender) {
if (!this.visibleItems.has(index.toString())) {
this._renderItem(index);
}
}
// Update performance metrics
this._updateMetrics(itemsToRender.size);
}
_renderItem(index) {
const item = this.items[index];
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.top = `${index * this.itemHeight}px`;
element.style.left = '0';
element.style.width = '100%';
element.dataset.itemId = index.toString();
// Render content
element.innerHTML = this.renderItem(item);
// Add to container and observe
this.container.appendChild(element);
this.observer.observe(element);
this.visibleItems.set(index.toString(), element);
// Adjust actual height if needed
const actualHeight = element.offsetHeight;
if (actualHeight !== this.itemHeight) {
element.style.height = `${this.itemHeight}px`;
}
}
_getVisibleRange() {
const start = Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize;
const visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
const end = start + visibleCount + 2 * this.bufferSize;
return { start, end };
}
_updateMetrics(visibleCount) {
const metrics = {
totalItems: this.items.length,
visibleItems: visibleCount,
scrollPosition: this.scrollTop,
containerHeight: this.container.clientHeight,
renderTime: performance.now() // You can use this with the previous render time
};
// Dispatch metrics event
this.container.dispatchEvent(new CustomEvent('virtualScroller:metrics', {
detail: metrics
}));
}
_debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Public methods
scrollToIndex(index) {
if (index >= 0 && index < this.items.length) {
this.container.scrollTop = index * this.itemHeight;
}
}
refresh() {
this._render();
}
destroy() {
this.observer.disconnect();
this.container.innerHTML = '';
this.items = [];
this.visibleItems.clear();
}
}
export default VirtualScroller;

View File

@@ -124,6 +124,289 @@ class VersionControl {
.then(response => response.json());
}
// Comment operations
async createComment(threadId, content, parentId = null) {
try {
const response = await fetch('/vcs/comments/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
thread_id: threadId,
content: content,
parent_id: parentId
})
});
if (!response.ok) {
throw new Error('Failed to create comment');
}
const comment = await response.json();
return comment;
} catch (error) {
this.showError('Error creating comment: ' + error.message);
throw error;
}
}
async createCommentThread(changeId, anchor, initialComment) {
try {
const response = await fetch('/vcs/comments/threads/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
change_id: changeId,
anchor: anchor,
initial_comment: initialComment
})
});
if (!response.ok) {
throw new Error('Failed to create comment thread');
}
const thread = await response.json();
return thread;
} catch (error) {
this.showError('Error creating comment thread: ' + error.message);
throw error;
}
}
async resolveThread(threadId) {
try {
const response = await fetch(`/vcs/comments/threads/${threadId}/resolve/`, {
method: 'POST',
headers: {
'X-CSRFToken': this.getCsrfToken()
}
});
if (!response.ok) {
throw new Error('Failed to resolve thread');
}
return await response.json();
} catch (error) {
this.showError('Error resolving thread: ' + error.message);
throw error;
}
}
async editComment(commentId, content) {
try {
const response = await fetch(`/vcs/comments/${commentId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
content: content
})
});
if (!response.ok) {
throw new Error('Failed to edit comment');
}
return await response.json();
} catch (error) {
this.showError('Error editing comment: ' + error.message);
throw error;
}
}
async getThreadComments(threadId) {
try {
const response = await fetch(`/vcs/comments/threads/${threadId}/`);
if (!response.ok) {
throw new Error('Failed to fetch thread comments');
}
return await response.json();
} catch (error) {
this.showError('Error fetching comments: ' + error.message);
throw error;
}
}
initializeCommentPanel(containerId, options = {}) {
const panel = new InlineCommentPanel({
...options,
onReply: async (content, parentId) => {
const comment = await this.createComment(
options.threadId,
content,
parentId
);
const thread = await this.getThreadComments(options.threadId);
panel.setThread(thread);
},
onResolve: async () => {
await this.resolveThread(options.threadId);
const thread = await this.getThreadComments(options.threadId);
panel.setThread(thread);
}
});
panel.initialize(containerId);
return panel;
}
// Branch locking operations
async acquireLock(branchName, duration = 48, reason = "") {
try {
const response = await fetch('/vcs/branches/lock/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
branch: branchName,
duration: duration,
reason: reason
})
});
if (!response.ok) {
throw new Error('Failed to acquire lock');
}
const result = await response.json();
if (result.success) {
this.refreshLockStatus(branchName);
return true;
}
return false;
} catch (error) {
this.showError('Error acquiring lock: ' + error.message);
return false;
}
}
async releaseLock(branchName, force = false) {
try {
const response = await fetch('/vcs/branches/unlock/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
branch: branchName,
force: force
})
});
if (!response.ok) {
throw new Error('Failed to release lock');
}
const result = await response.json();
if (result.success) {
this.refreshLockStatus(branchName);
return true;
}
return false;
} catch (error) {
this.showError('Error releasing lock: ' + error.message);
return false;
}
}
async getLockStatus(branchName) {
try {
const response = await fetch(`/vcs/branches/${encodeURIComponent(branchName)}/lock-status/`);
if (!response.ok) {
throw new Error('Failed to get lock status');
}
return await response.json();
} catch (error) {
this.showError('Error getting lock status: ' + error.message);
return null;
}
}
async getLockHistory(branchName, limit = 10) {
try {
const response = await fetch(
`/vcs/branches/${encodeURIComponent(branchName)}/lock-history/?limit=${limit}`
);
if (!response.ok) {
throw new Error('Failed to get lock history');
}
return await response.json();
} catch (error) {
this.showError('Error getting lock history: ' + error.message);
return [];
}
}
async refreshLockStatus(branchName) {
const lockStatus = await this.getLockStatus(branchName);
if (!lockStatus) return;
const statusElement = document.querySelector(`[data-branch="${branchName}"] .lock-status`);
if (!statusElement) return;
if (lockStatus.locked) {
const expiryDate = new Date(lockStatus.expires);
statusElement.className = 'lock-status locked';
statusElement.innerHTML = `
<svg class="lock-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
</svg>
<div class="lock-info">
<span class="user">${lockStatus.user}</span>
<span class="expiry">Expires: ${expiryDate.toLocaleString()}</span>
${lockStatus.reason ? `<span class="reason">${lockStatus.reason}</span>` : ''}
</div>
`;
} else {
statusElement.className = 'lock-status unlocked';
statusElement.innerHTML = `
<svg class="lock-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
</svg>
<span>Unlocked</span>
`;
}
// Update lock controls
this.updateLockControls(branchName, lockStatus);
}
async updateLockControls(branchName, lockStatus) {
const controlsElement = document.querySelector(`[data-branch="${branchName}"] .lock-controls`);
if (!controlsElement) return;
if (lockStatus.locked) {
controlsElement.innerHTML = `
<button class="lock-button unlock" onclick="window.versionControl.releaseLock('${branchName}')">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
</svg>
Unlock Branch
</button>
`;
} else {
controlsElement.innerHTML = `
<button class="lock-button lock" onclick="window.versionControl.acquireLock('${branchName}')">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
</svg>
Lock Branch
</button>
`;
}
}
// Utility functions
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
@@ -149,7 +432,105 @@ class VersionControl {
}
}
// Import DiffViewer component
import DiffViewer from './components/diff-viewer.js';
// Initialize version control
document.addEventListener('DOMContentLoaded', () => {
window.versionControl = new VersionControl();
// Initialize DiffViewer if diff container exists
const diffContainer = document.getElementById('diff-container');
if (diffContainer) {
window.diffViewer = new DiffViewer({
renderStrategy: 'side-by-side'
});
diffViewer.initialize('diff-container');
}
});
// Add to VersionControl class constructor
class VersionControl {
constructor() {
this.setupEventListeners();
this.diffViewer = null;
if (document.getElementById('diff-container')) {
this.diffViewer = new DiffViewer({
renderStrategy: 'side-by-side'
});
this.diffViewer.initialize('diff-container');
}
}
// Add getDiff method to VersionControl class
async getDiff(version1, version2) {
const url = `/vcs/diff/?v1=${encodeURIComponent(version1)}&v2=${encodeURIComponent(version2)}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch diff');
}
const diffData = await response.json();
if (this.diffViewer) {
await this.diffViewer.render(diffData);
}
return diffData;
} catch (error) {
this.showError('Error loading diff: ' + error.message);
throw error;
}
}
// Add viewChanges method to VersionControl class
async viewChanges(changeId) {
try {
const response = await fetch(`/vcs/changes/${changeId}/`);
if (!response.ok) {
throw new Error('Failed to fetch changes');
}
const changeData = await response.json();
if (this.diffViewer) {
await this.diffViewer.render(changeData);
} else {
this.showError('Diff viewer not initialized');
}
} catch (error) {
this.showError('Error loading changes: ' + error.message);
throw error;
}
}
// Add addComment method to VersionControl class
async addComment(changeId, anchor, content) {
try {
const response = await fetch('/vcs/comments/add/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
change_id: changeId,
anchor: anchor,
content: content
})
});
if (!response.ok) {
throw new Error('Failed to add comment');
}
const comment = await response.json();
if (this.diffViewer) {
this.diffViewer.addCommentThread(anchor, [comment]);
}
return comment;
} catch (error) {
this.showError('Error adding comment: ' + error.message);
throw error;
}
}