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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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