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,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;
}
}