mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 06:11:07 -05:00
Revert "Add version control system functionality with branch management, history tracking, and merge operations"
This reverts commit f3d28817a5.
This commit is contained in:
@@ -1,332 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
/* Version Control System Styles */
|
||||
|
||||
.version-control-ui {
|
||||
--vcs-primary: #3b82f6;
|
||||
--vcs-success: #10b981;
|
||||
--vcs-warning: #f59e0b;
|
||||
--vcs-error: #ef4444;
|
||||
--vcs-gray: #6b7280;
|
||||
}
|
||||
|
||||
/* Branch Status Indicators */
|
||||
.branch-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.branch-indicator.active {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vcs-success);
|
||||
}
|
||||
|
||||
.branch-indicator.inactive {
|
||||
background-color: rgba(107, 114, 128, 0.1);
|
||||
color: var(--vcs-gray);
|
||||
}
|
||||
|
||||
/* Change Status Tags */
|
||||
.change-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-status.applied {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vcs-success);
|
||||
}
|
||||
|
||||
.change-status.pending {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: var(--vcs-warning);
|
||||
}
|
||||
|
||||
.change-status.failed {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vcs-error);
|
||||
}
|
||||
|
||||
/* Change History */
|
||||
.change-history {
|
||||
border-left: 2px solid #e5e7eb;
|
||||
margin-left: 1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.change-history-item {
|
||||
position: relative;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.change-history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.25rem;
|
||||
top: 1.5rem;
|
||||
height: 0.75rem;
|
||||
width: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
border: 2px solid var(--vcs-primary);
|
||||
}
|
||||
|
||||
/* Merge Interface */
|
||||
.merge-conflict {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.merge-conflict-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.merge-conflict-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.merge-version {
|
||||
background-color: #f9fafb;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Branch Selection */
|
||||
.branch-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.branch-list {
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.branch-item:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.branch-item.active {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Version Tags */
|
||||
.version-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: #f3f4f6;
|
||||
color: var(--vcs-gray);
|
||||
font-size: 0.875rem;
|
||||
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;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.vcs-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: var(--vcs-primary);
|
||||
border-radius: 50%;
|
||||
animation: vcs-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes vcs-spin {
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.merge-conflict-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.branch-selector {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { initVersionControl, setupBranchHandlers, handleMergeConflicts } from '../version-control';
|
||||
|
||||
describe('Version Control UI', () => {
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
container.id = 'version-control-panel';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Mock HTMX
|
||||
window.htmx = {
|
||||
trigger: jest.fn(),
|
||||
ajax: jest.fn(),
|
||||
on: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize version control UI', () => {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'version-control-panel';
|
||||
container.appendChild(panel);
|
||||
|
||||
initVersionControl();
|
||||
|
||||
expect(window.htmx.on).toHaveBeenCalled();
|
||||
expect(container.querySelector('.version-control-panel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should setup branch switch handlers', () => {
|
||||
const switchButton = document.createElement('button');
|
||||
switchButton.setAttribute('data-branch-id', '1');
|
||||
switchButton.className = 'branch-switch';
|
||||
container.appendChild(switchButton);
|
||||
|
||||
setupBranchHandlers();
|
||||
switchButton.click();
|
||||
|
||||
expect(window.htmx.ajax).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/version-control/switch-branch/',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch operations', () => {
|
||||
it('should handle branch creation', () => {
|
||||
const form = document.createElement('form');
|
||||
form.id = 'create-branch-form';
|
||||
container.appendChild(form);
|
||||
|
||||
const event = new Event('submit');
|
||||
form.dispatchEvent(event);
|
||||
|
||||
expect(window.htmx.trigger).toHaveBeenCalledWith(
|
||||
form,
|
||||
'branch-created',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should update UI after branch switch', () => {
|
||||
const response = {
|
||||
branch_name: 'feature/test',
|
||||
status: 'success'
|
||||
};
|
||||
|
||||
const event = new CustomEvent('branchSwitched', {
|
||||
detail: response
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(container.querySelector('.current-branch')?.textContent)
|
||||
.toContain('feature/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge operations', () => {
|
||||
it('should handle merge conflicts', () => {
|
||||
const conflicts = [
|
||||
{
|
||||
field: 'name',
|
||||
source_value: 'Feature Name',
|
||||
target_value: 'Main Name'
|
||||
}
|
||||
];
|
||||
|
||||
handleMergeConflicts(conflicts);
|
||||
|
||||
const conflictDialog = document.querySelector('.merge-conflict-dialog');
|
||||
expect(conflictDialog).toBeTruthy();
|
||||
expect(conflictDialog.innerHTML).toContain('name');
|
||||
expect(conflictDialog.innerHTML).toContain('Feature Name');
|
||||
expect(conflictDialog.innerHTML).toContain('Main Name');
|
||||
});
|
||||
|
||||
it('should submit merge resolution', () => {
|
||||
const resolutionForm = document.createElement('form');
|
||||
resolutionForm.id = 'merge-resolution-form';
|
||||
container.appendChild(resolutionForm);
|
||||
|
||||
const event = new Event('submit');
|
||||
resolutionForm.dispatchEvent(event);
|
||||
|
||||
expect(window.htmx.ajax).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/version-control/resolve-conflicts/',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should display error messages', () => {
|
||||
const errorEvent = new CustomEvent('showError', {
|
||||
detail: { message: 'Test error message' }
|
||||
});
|
||||
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
const errorMessage = document.querySelector('.error-message');
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain('Test error message');
|
||||
});
|
||||
|
||||
it('should clear error messages', () => {
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.className = 'error-message';
|
||||
container.appendChild(errorMessage);
|
||||
|
||||
const clearEvent = new Event('clearErrors');
|
||||
document.dispatchEvent(clearEvent);
|
||||
|
||||
expect(container.querySelector('.error-message')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading indicator during operations', () => {
|
||||
const loadingEvent = new Event('versionControlLoading');
|
||||
document.dispatchEvent(loadingEvent);
|
||||
|
||||
const loader = document.querySelector('.version-control-loader');
|
||||
expect(loader).toBeTruthy();
|
||||
expect(loader.style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('should hide loading indicator after operations', () => {
|
||||
const loader = document.createElement('div');
|
||||
loader.className = 'version-control-loader';
|
||||
container.appendChild(loader);
|
||||
|
||||
const doneEvent = new Event('versionControlLoaded');
|
||||
document.dispatchEvent(doneEvent);
|
||||
|
||||
expect(loader.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI updates', () => {
|
||||
it('should update branch list after operations', () => {
|
||||
const branchList = document.createElement('ul');
|
||||
branchList.className = 'branch-list';
|
||||
container.appendChild(branchList);
|
||||
|
||||
const updateEvent = new CustomEvent('updateBranchList', {
|
||||
detail: {
|
||||
branches: [
|
||||
{ name: 'main', active: true },
|
||||
{ name: 'feature/test', active: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
document.dispatchEvent(updateEvent);
|
||||
|
||||
const listItems = branchList.querySelectorAll('li');
|
||||
expect(listItems.length).toBe(2);
|
||||
expect(listItems[0].textContent).toContain('main');
|
||||
expect(listItems[1].textContent).toContain('feature/test');
|
||||
});
|
||||
|
||||
it('should highlight active branch', () => {
|
||||
const branchItems = [
|
||||
{ name: 'main', active: true },
|
||||
{ name: 'feature/test', active: false }
|
||||
].map(branch => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = branch.name;
|
||||
item.className = 'branch-item';
|
||||
if (branch.active) item.classList.add('active');
|
||||
return item;
|
||||
});
|
||||
|
||||
const branchList = document.createElement('ul');
|
||||
branchList.className = 'branch-list';
|
||||
branchList.append(...branchItems);
|
||||
container.appendChild(branchList);
|
||||
|
||||
const activeItem = branchList.querySelector('.branch-item.active');
|
||||
expect(activeItem).toBeTruthy();
|
||||
expect(activeItem.textContent).toBe('main');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,234 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,274 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,285 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,314 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,190 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Error handling and state management for version control system
|
||||
*/
|
||||
|
||||
class VersionControlError extends Error {
|
||||
constructor(message, code, details = {}) {
|
||||
super(message);
|
||||
this.name = 'VersionControlError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Error boundary for version control operations
|
||||
class VersionControlErrorBoundary {
|
||||
constructor(options = {}) {
|
||||
this.onError = options.onError || this.defaultErrorHandler;
|
||||
this.errors = new Map();
|
||||
this.retryAttempts = new Map();
|
||||
this.maxRetries = options.maxRetries || 3;
|
||||
}
|
||||
|
||||
defaultErrorHandler(error) {
|
||||
console.error(`[Version Control Error]: ${error.message}`, error);
|
||||
this.showErrorNotification(error);
|
||||
}
|
||||
|
||||
showErrorNotification(error) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'version-control-error notification';
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-message">${error.message}</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
${error.details.retry ? '<button class="retry-btn">Retry</button>' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-hide after 5 seconds unless it's a critical error
|
||||
if (!error.details.critical) {
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Handle retry
|
||||
const retryBtn = notification.querySelector('.retry-btn');
|
||||
if (retryBtn && error.details.retryCallback) {
|
||||
retryBtn.addEventListener('click', () => {
|
||||
notification.remove();
|
||||
error.details.retryCallback();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle close
|
||||
const closeBtn = notification.querySelector('.close-btn');
|
||||
closeBtn.addEventListener('click', () => notification.remove());
|
||||
}
|
||||
|
||||
async wrapOperation(operationKey, operation) {
|
||||
try {
|
||||
// Check if operation is already in progress
|
||||
if (this.errors.has(operationKey)) {
|
||||
throw new VersionControlError(
|
||||
'Operation already in progress',
|
||||
'DUPLICATE_OPERATION'
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.showLoading(operationKey);
|
||||
|
||||
const result = await operation();
|
||||
|
||||
// Clear any existing errors for this operation
|
||||
this.errors.delete(operationKey);
|
||||
this.retryAttempts.delete(operationKey);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const retryCount = this.retryAttempts.get(operationKey) || 0;
|
||||
|
||||
// Handle specific error types
|
||||
if (error.name === 'VersionControlError') {
|
||||
this.handleVersionControlError(error, operationKey, retryCount);
|
||||
} else {
|
||||
// Convert unknown errors to VersionControlError
|
||||
const vcError = new VersionControlError(
|
||||
'An unexpected error occurred',
|
||||
'UNKNOWN_ERROR',
|
||||
{ originalError: error }
|
||||
);
|
||||
this.handleVersionControlError(vcError, operationKey, retryCount);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.hideLoading(operationKey);
|
||||
}
|
||||
}
|
||||
|
||||
handleVersionControlError(error, operationKey, retryCount) {
|
||||
this.errors.set(operationKey, error);
|
||||
|
||||
// Determine if operation can be retried
|
||||
const canRetry = retryCount < this.maxRetries;
|
||||
|
||||
error.details.retry = canRetry;
|
||||
error.details.retryCallback = canRetry ?
|
||||
() => this.retryOperation(operationKey) :
|
||||
undefined;
|
||||
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
async retryOperation(operationKey) {
|
||||
const retryCount = (this.retryAttempts.get(operationKey) || 0) + 1;
|
||||
this.retryAttempts.set(operationKey, retryCount);
|
||||
|
||||
// Exponential backoff for retries
|
||||
const backoffDelay = Math.min(1000 * Math.pow(2, retryCount - 1), 10000);
|
||||
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
||||
|
||||
// Get the original operation and retry
|
||||
const operation = this.pendingOperations.get(operationKey);
|
||||
if (operation) {
|
||||
return this.wrapOperation(operationKey, operation);
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(operationKey) {
|
||||
const loadingElement = document.createElement('div');
|
||||
loadingElement.className = `loading-indicator loading-${operationKey}`;
|
||||
loadingElement.innerHTML = `
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="loading-text">Processing...</span>
|
||||
`;
|
||||
document.body.appendChild(loadingElement);
|
||||
}
|
||||
|
||||
hideLoading(operationKey) {
|
||||
const loadingElement = document.querySelector(`.loading-${operationKey}`);
|
||||
if (loadingElement) {
|
||||
loadingElement.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const errorBoundary = new VersionControlErrorBoundary({
|
||||
onError: (error) => {
|
||||
// Log to monitoring system
|
||||
if (window.monitoring) {
|
||||
window.monitoring.logError('version_control', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export error handling utilities
|
||||
export const versionControl = {
|
||||
/**
|
||||
* Wrap version control operations with error handling
|
||||
*/
|
||||
async performOperation(key, operation) {
|
||||
return errorBoundary.wrapOperation(key, operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new error instance
|
||||
*/
|
||||
createError(message, code, details) {
|
||||
return new VersionControlError(message, code, details);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show loading state manually
|
||||
*/
|
||||
showLoading(key) {
|
||||
errorBoundary.showLoading(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide loading state manually
|
||||
*/
|
||||
hideLoading(key) {
|
||||
errorBoundary.hideLoading(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show error notification manually
|
||||
*/
|
||||
showError(error) {
|
||||
errorBoundary.showErrorNotification(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add global error handler for uncaught version control errors
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
if (event.reason instanceof VersionControlError) {
|
||||
event.preventDefault();
|
||||
errorBoundary.defaultErrorHandler(event.reason);
|
||||
}
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mapContainer = document.getElementById('map');
|
||||
if (!mapContainer) return;
|
||||
|
||||
const lat = parseFloat(mapContainer.dataset.lat);
|
||||
const lng = parseFloat(mapContainer.dataset.lng);
|
||||
const name = mapContainer.dataset.name;
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) return;
|
||||
|
||||
const map = L.map('map').setView([lat, lng], 13);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
L.marker([lat, lng])
|
||||
.addTo(map)
|
||||
.bindPopup(name);
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
// Validation Helpers
|
||||
const ValidationRules = {
|
||||
date: {
|
||||
validate: (value, input) => {
|
||||
if (!value) return true;
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
const min = new Date('1800-01-01');
|
||||
|
||||
if (date > now) {
|
||||
return 'Date cannot be in the future';
|
||||
}
|
||||
if (date < min) {
|
||||
return 'Date cannot be before 1800';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
numeric: {
|
||||
validate: (value, input) => {
|
||||
if (!value) return true;
|
||||
const num = parseFloat(value);
|
||||
const min = parseFloat(input.getAttribute('min') || '-Infinity');
|
||||
const max = parseFloat(input.getAttribute('max') || 'Infinity');
|
||||
|
||||
if (isNaN(num)) {
|
||||
return 'Please enter a valid number';
|
||||
}
|
||||
if (num < min) {
|
||||
return `Value must be at least ${min}`;
|
||||
}
|
||||
if (num > max) {
|
||||
return `Value must be no more than ${max}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Form Validation and Error Handling
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Form Validation
|
||||
document.querySelectorAll('form[hx-post]').forEach(form => {
|
||||
// Add validation on field change
|
||||
form.addEventListener('input', function(e) {
|
||||
const input = e.target;
|
||||
if (input.hasAttribute('data-validate')) {
|
||||
validateField(input);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('htmx:beforeRequest', function(event) {
|
||||
let isValid = true;
|
||||
|
||||
// Validate all fields
|
||||
form.querySelectorAll('[data-validate]').forEach(input => {
|
||||
if (!validateField(input)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Check required notes field
|
||||
const notesField = form.querySelector('textarea[name="notes"]');
|
||||
if (notesField && !notesField.value.trim()) {
|
||||
showError(notesField, 'Notes are required');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
event.preventDefault();
|
||||
// Focus first invalid field
|
||||
form.querySelector('.border-red-500')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error states on input
|
||||
form.addEventListener('input', function(e) {
|
||||
if (e.target.classList.contains('border-red-500')) {
|
||||
e.target.classList.remove('border-red-500');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Form State Management
|
||||
document.querySelectorAll('form[hx-post]').forEach(form => {
|
||||
const formId = form.getAttribute('id');
|
||||
if (!formId) return;
|
||||
|
||||
// Save form state before submission
|
||||
form.addEventListener('htmx:beforeRequest', function() {
|
||||
const formData = new FormData(form);
|
||||
const state = {};
|
||||
formData.forEach((value, key) => {
|
||||
state[key] = value;
|
||||
});
|
||||
sessionStorage.setItem('formState-' + formId, JSON.stringify(state));
|
||||
});
|
||||
|
||||
// Restore form state if available
|
||||
const savedState = sessionStorage.getItem('formState-' + formId);
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState);
|
||||
Object.entries(state).forEach(([key, value]) => {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Park Area Sync with Park Selection
|
||||
document.querySelectorAll('[id^="park-input-"]').forEach(parkInput => {
|
||||
const submissionId = parkInput.id.replace('park-input-', '');
|
||||
const areaSelect = document.querySelector(`#park-area-select-${submissionId}`);
|
||||
|
||||
if (parkInput && areaSelect) {
|
||||
parkInput.addEventListener('change', function() {
|
||||
const parkId = this.value;
|
||||
if (!parkId) {
|
||||
areaSelect.innerHTML = '<option value="">Select area</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('GET', `/parks/${parkId}/areas/`, {
|
||||
target: areaSelect,
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Improved Error Handling
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center';
|
||||
errorToast.innerHTML = `
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span>${evt.detail.error || 'An error occurred'}</span>
|
||||
<button class="ml-4 hover:text-red-200" onclick="this.parentElement.remove()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(errorToast);
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Accessibility Improvements
|
||||
document.addEventListener('htmx:afterSettle', function(evt) {
|
||||
// Focus management
|
||||
const target = evt.detail.target;
|
||||
const focusableElement = target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusableElement) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
|
||||
// Announce state changes
|
||||
if (target.hasAttribute('aria-live')) {
|
||||
const announcement = target.getAttribute('aria-label') || target.textContent;
|
||||
const announcer = document.getElementById('a11y-announcer') || createAnnouncer();
|
||||
announcer.textContent = announcement;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create accessibility announcer
|
||||
function createAnnouncer() {
|
||||
const announcer = document.createElement('div');
|
||||
announcer.id = 'a11y-announcer';
|
||||
announcer.className = 'sr-only';
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(announcer);
|
||||
return announcer;
|
||||
}
|
||||
|
||||
// Validation Helper Functions
|
||||
function validateField(input) {
|
||||
const validationType = input.getAttribute('data-validate');
|
||||
if (!validationType || !ValidationRules[validationType]) return true;
|
||||
|
||||
const result = ValidationRules[validationType].validate(input.value, input);
|
||||
if (result === true) {
|
||||
clearError(input);
|
||||
return true;
|
||||
} else {
|
||||
showError(input, result);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(input, message) {
|
||||
const errorId = input.getAttribute('aria-describedby');
|
||||
const errorElement = document.getElementById(errorId);
|
||||
|
||||
input.classList.add('border-red-500', 'error-shake');
|
||||
if (errorElement) {
|
||||
errorElement.textContent = message;
|
||||
errorElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Announce error to screen readers
|
||||
const announcer = document.getElementById('a11y-announcer');
|
||||
if (announcer) {
|
||||
announcer.textContent = `Error: ${message}`;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
input.classList.remove('error-shake');
|
||||
}, 820);
|
||||
}
|
||||
|
||||
function clearError(input) {
|
||||
const errorId = input.getAttribute('aria-describedby');
|
||||
const errorElement = document.getElementById(errorId);
|
||||
|
||||
input.classList.remove('border-red-500');
|
||||
if (errorElement) {
|
||||
errorElement.classList.add('hidden');
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
// Version Control System Functionality
|
||||
|
||||
class VersionControl {
|
||||
constructor() {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Branch switching
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.target.id === 'branch-form-container') {
|
||||
this.handleBranchFormResponse(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for branch switches
|
||||
document.addEventListener('branch-switched', () => {
|
||||
this.refreshContent();
|
||||
});
|
||||
|
||||
// Handle merge operations
|
||||
document.addEventListener('htmx:afterRequest', (event) => {
|
||||
if (event.detail.target.id === 'merge-panel') {
|
||||
this.handleMergeResponse(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleBranchFormResponse(event) {
|
||||
if (event.detail.successful) {
|
||||
// Clear the branch form container
|
||||
document.getElementById('branch-form-container').innerHTML = '';
|
||||
// Trigger branch list refresh
|
||||
document.body.dispatchEvent(new CustomEvent('branch-updated'));
|
||||
}
|
||||
}
|
||||
|
||||
handleMergeResponse(event) {
|
||||
if (event.detail.successful) {
|
||||
const mergePanel = document.getElementById('merge-panel');
|
||||
if (mergePanel.innerHTML.includes('Merge Successful')) {
|
||||
// Trigger content refresh after successful merge
|
||||
setTimeout(() => {
|
||||
this.refreshContent();
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshContent() {
|
||||
// Reload the page to show content from new branch
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Branch operations
|
||||
createBranch(name, parentBranch = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
if (parentBranch) {
|
||||
formData.append('parent', parentBranch);
|
||||
}
|
||||
|
||||
return fetch('/vcs/branches/create/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
}
|
||||
}).then(response => response.json());
|
||||
}
|
||||
|
||||
switchBranch(branchName) {
|
||||
const formData = new FormData();
|
||||
formData.append('branch', branchName);
|
||||
|
||||
return fetch('/vcs/branches/switch/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
document.body.dispatchEvent(new CustomEvent('branch-switched'));
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Merge operations
|
||||
initiateMerge(sourceBranch, targetBranch) {
|
||||
const formData = new FormData();
|
||||
formData.append('source', sourceBranch);
|
||||
formData.append('target', targetBranch);
|
||||
|
||||
return fetch('/vcs/merge/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
}
|
||||
}).then(response => response.json());
|
||||
}
|
||||
|
||||
resolveConflicts(resolutions) {
|
||||
return fetch('/vcs/resolve-conflicts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(resolutions),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.getCsrfToken()
|
||||
}
|
||||
}).then(response => response.json());
|
||||
}
|
||||
|
||||
// History operations
|
||||
getHistory(branch = null) {
|
||||
let url = '/vcs/history/';
|
||||
if (branch) {
|
||||
url += `?branch=${encodeURIComponent(branch)}`;
|
||||
}
|
||||
|
||||
return fetch(url)
|
||||
.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;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-4';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.querySelector('.version-control-ui').prepend(errorDiv);
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 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