mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:51:09 -05:00
Add comment and reply functionality with preview and notification templates
This commit is contained in:
203
static/js/collaboration-system.js
Normal file
203
static/js/collaboration-system.js
Normal 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;
|
||||
234
static/js/components/approval-panel.js
Normal file
234
static/js/components/approval-panel.js
Normal 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;
|
||||
274
static/js/components/diff-viewer.js
Normal file
274
static/js/components/diff-viewer.js
Normal 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;
|
||||
285
static/js/components/inline-comment-panel.js
Normal file
285
static/js/components/inline-comment-panel.js
Normal 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;
|
||||
314
static/js/components/version-comparison.js
Normal file
314
static/js/components/version-comparison.js
Normal 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;
|
||||
190
static/js/components/virtual-scroller.js
Normal file
190
static/js/components/virtual-scroller.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user