From ca9cb9a47c2cccd21b35f8a940f61865744dd1ae Mon Sep 17 00:00:00 2001 From: Aitect Date: Tue, 5 Aug 2025 18:32:39 +0000 Subject: [PATCH] feat: implement auto-resolve functionality for document conflicts and enhance save status indicators --- src/components/CodeEditor.css | 15 ++ src/components/CodeEditor.jsx | 142 ++++++++++++++-- src/components/ConflictResolver.css | 65 ++++++++ src/components/ConflictResolver.jsx | 53 ++++++ src/components/SyncStatus.jsx | 41 ++--- src/services/DatabaseService.js | 15 +- src/services/SyncService.js | 246 +++++++++++++++++++++++++++- src/utils/consoleTools.js | 28 +++- 8 files changed, 557 insertions(+), 48 deletions(-) create mode 100644 src/components/ConflictResolver.css create mode 100644 src/components/ConflictResolver.jsx diff --git a/src/components/CodeEditor.css b/src/components/CodeEditor.css index f6fbd09..6a6d55d 100644 --- a/src/components/CodeEditor.css +++ b/src/components/CodeEditor.css @@ -67,6 +67,16 @@ align-items: center; } +.save-status { + font-size: 12px; + margin-left: 6px; + margin-right: 2px; +} + +.save-status.unsaved { + color: #8c8c8c; /* VS Code gray */ +} + .title-click-indicator { font-size: 14px; color: #0066cc; @@ -80,6 +90,11 @@ margin-top: 4px; } +.unsaved-indicator { + color: #ffc107; + font-weight: 500; +} + /* Markdown syntax highlighting */ .cm-header { color: #0066cc; diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx index 46dbf36..19b0cbe 100644 --- a/src/components/CodeEditor.jsx +++ b/src/components/CodeEditor.jsx @@ -16,6 +16,7 @@ const CodeEditor = () => { const [documents, setDocuments] = useState([]); const [currentDocument, setCurrentDocument] = useState(null); const [content, setContent] = useState(''); + const [isSaved, setIsSaved] = useState(true); // Track save status // Initialize documents from database useEffect(() => { @@ -27,31 +28,130 @@ const CodeEditor = () => { if (allDocs.length > 0) { setCurrentDocument(allDocs[0]); setContent(allDocs[0].content); + setIsSaved(true); // Initially loaded document is saved } } loadDocuments(); }, []); - - // Save current document content when it changes + + // Listen for document updates (e.g., after conflict resolution) useEffect(() => { - if (currentDocument && content !== currentDocument.content) { - const updatedDoc = { - ...currentDocument, - content, - updatedAt: new Date().toISOString() - }; + const handleDocumentsUpdate = async (event) => { + // If we have specific document IDs that were updated, only refresh those + const updatedDocIds = event?.detail?.documentIds; - async function saveDocument() { - const savedDoc = await DocumentManager.saveDocument(updatedDoc); - setCurrentDocument(savedDoc); + if (updatedDocIds && currentDocument) { + // Check if current document was updated + const currentDocId = currentDocument.id || currentDocument._id; + if (updatedDocIds.includes(currentDocId)) { + console.log('Current document was updated remotely, refreshing editor'); + try { + const updatedCurrentDoc = await DocumentManager.getDocument(currentDocId); + if (updatedCurrentDoc && updatedCurrentDoc.updatedAt !== currentDocument.updatedAt) { + setCurrentDocument(updatedCurrentDoc); + setContent(updatedCurrentDoc.content); + setIsSaved(true); // Document updated from remote is considered saved + + // Update the editor view with the new content + if (editorView) { + editorView.dispatch({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: updatedCurrentDoc.content + } + }); + } + } + } catch (error) { + console.error('Error refreshing current document:', error); + } + } - // Update document list to reflect the change + // Only reload full document list if we need to (for the command palette) + if (isCommandPaletteOpen) { + const allDocs = await DocumentManager.getAllDocuments(); + setDocuments(allDocs); + } + } else { + // Fallback: full refresh only if we don't have specific IDs const allDocs = await DocumentManager.getAllDocuments(); setDocuments(allDocs); + + // Check if the current document was updated + if (currentDocument) { + const updatedCurrentDoc = allDocs.find(doc => doc.id === currentDocument.id || doc._id === currentDocument.id); + if (updatedCurrentDoc && updatedCurrentDoc.updatedAt !== currentDocument.updatedAt) { + console.log('Current document was updated remotely, refreshing editor'); + setCurrentDocument(updatedCurrentDoc); + setContent(updatedCurrentDoc.content); + setIsSaved(true); // Document updated from remote is considered saved + + // Update the editor view with the new content + if (editorView) { + editorView.dispatch({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: updatedCurrentDoc.content + } + }); + } + } + } } + }; + + window.addEventListener('documentsUpdated', handleDocumentsUpdate); + + return () => { + window.removeEventListener('documentsUpdated', handleDocumentsUpdate); + }; + }, [currentDocument, editorView, isCommandPaletteOpen]); + + // Refresh documents list when command palette opens (lazy loading) + useEffect(() => { + if (isCommandPaletteOpen) { + async function refreshDocumentsList() { + const allDocs = await DocumentManager.getAllDocuments(); + setDocuments(allDocs); + } + refreshDocumentsList(); + } + }, [isCommandPaletteOpen]); + + // Debounced save - wait for user to stop typing before saving + useEffect(() => { + if (currentDocument && content !== currentDocument.content) { + // Mark as unsaved when content changes + setIsSaved(false); + + // Clear any existing timeout + const timeoutId = setTimeout(async () => { + const updatedDoc = { + ...currentDocument, + content, + updatedAt: new Date().toISOString() + }; + + try { + console.log('Auto-saving document after typing pause...'); + const savedDoc = await DocumentManager.saveDocument(updatedDoc); + setCurrentDocument(savedDoc); + setIsSaved(true); // Mark as saved after successful save + + // Update document list to reflect the change + const allDocs = await DocumentManager.getAllDocuments(); + setDocuments(allDocs); + } catch (error) { + console.error('Error auto-saving document:', error); + // Keep isSaved as false if save failed + } + }, 2000); // Wait 2 seconds after user stops typing - saveDocument(); + // Cleanup function to clear timeout if component unmounts or content changes again + return () => clearTimeout(timeoutId); } }, [content, currentDocument]); @@ -77,6 +177,7 @@ const CodeEditor = () => { // Switch to the selected document setCurrentDocument(document); setContent(document.content); + setIsSaved(true); // Reset save status for new document // Update editor content if (editorView) { @@ -104,6 +205,7 @@ const CodeEditor = () => { // Switch to the new document setCurrentDocument(newDoc); setContent(newDoc.content); + setIsSaved(true); // New document is considered saved // Update editor content if (editorView) { @@ -147,10 +249,13 @@ const CodeEditor = () => { // Use async/await in an IIFE (async () => { try { + console.log('Manual save triggered (Ctrl+S)'); const savedDoc = await DocumentManager.saveDocument(updatedDoc); setCurrentDocument(savedDoc); + setIsSaved(true); // Mark as saved after successful save const allDocs = await DocumentManager.getAllDocuments(); setDocuments(allDocs); + console.log('Document saved successfully'); } catch (error) { console.error('Error saving document with keyboard shortcut:', error); } @@ -218,10 +323,13 @@ const CodeEditor = () => { // Use an IIFE to handle async calls (async () => { try { + console.log('Manual save triggered (Ctrl+S in editor)'); const savedDoc = await DocumentManager.saveDocument(updatedDoc); setCurrentDocument(savedDoc); + setIsSaved(true); // Mark as saved after successful save const allDocs = await DocumentManager.getAllDocuments(); setDocuments(allDocs); + console.log('Document saved successfully'); } catch (error) { console.error('Error saving document in CodeMirror keybinding:', error); } @@ -257,7 +365,13 @@ const CodeEditor = () => { title="Click to open document list" >

- {currentDocument.title} + {currentDocument.title} + {!isSaved && ( + + ● + + )} +

Last updated: {new Date(currentDocument.updatedAt).toLocaleString()} diff --git a/src/components/ConflictResolver.css b/src/components/ConflictResolver.css new file mode 100644 index 0000000..98f5277 --- /dev/null +++ b/src/components/ConflictResolver.css @@ -0,0 +1,65 @@ +.conflict-resolver-simple { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 12px 16px; + margin: 12px 0; + border-left: 4px solid #f39c12; +} + +.conflict-notification { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.conflict-icon { + font-size: 1.2em; +} + +.conflict-message { + flex: 1; + color: #856404; + font-weight: 500; +} + +.auto-resolve-button { + background: #f39c12; + color: white; + border: none; + border-radius: 4px; + padding: 6px 12px; + font-size: 0.9em; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.auto-resolve-button:hover:not(:disabled) { + background: #e67e22; +} + +.auto-resolve-button:disabled { + background: #bdc3c7; + cursor: not-allowed; +} + +.conflict-help { + color: #6c757d; + font-size: 0.85em; + line-height: 1.4; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .conflict-notification { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .auto-resolve-button { + align-self: stretch; + text-align: center; + } +} diff --git a/src/components/ConflictResolver.jsx b/src/components/ConflictResolver.jsx new file mode 100644 index 0000000..21eff68 --- /dev/null +++ b/src/components/ConflictResolver.jsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { DatabaseService } from '../services/DatabaseService'; +import './ConflictResolver.css'; + +const ConflictResolver = ({ conflictCount, onRefresh }) => { + const [isResolving, setIsResolving] = useState(false); + + const handleAutoResolve = async () => { + try { + setIsResolving(true); + const resolved = await DatabaseService.autoResolveConflicts(); + + if (resolved > 0) { + console.log(`Auto-resolved ${resolved} conflict(s)`); + // Refresh the conflicts list and document list + if (onRefresh) { + onRefresh(); + } + } + } catch (error) { + console.error('Error auto-resolving conflicts:', error); + } finally { + setIsResolving(false); + } + }; + + if (conflictCount === 0) { + return null; + } + + return ( +
+
+ ⚠️ + + {conflictCount} document conflict{conflictCount > 1 ? 's' : ''} detected + + +
+
+ Conflicts are usually resolved automatically. Click "Auto-Resolve" to merge changes or create conflict markers. +
+
+ ); +}; + +export default ConflictResolver; diff --git a/src/components/SyncStatus.jsx b/src/components/SyncStatus.jsx index 205b277..caa4cea 100644 --- a/src/components/SyncStatus.jsx +++ b/src/components/SyncStatus.jsx @@ -25,18 +25,9 @@ const SyncStatus = () => { setSyncStatus(newStatus); }); - // Check for conflicts periodically - const checkConflicts = async () => { - try { - const conflictList = await DatabaseService.getConflicts(); - setConflicts(conflictList); - } catch (error) { - console.error('Error checking conflicts:', error); - } - }; - + // Check for conflicts initially and periodically checkConflicts(); - const conflictInterval = setInterval(checkConflicts, 30000); // Check every 30 seconds + const conflictInterval = setInterval(checkConflicts, 10000); // Check every 10 seconds return () => { unsubscribe(); @@ -44,6 +35,15 @@ const SyncStatus = () => { }; }, []); + const checkConflicts = async () => { + try { + const conflictList = await DatabaseService.getConflicts(); + setConflicts(conflictList); + } catch (error) { + console.error('Error checking conflicts:', error); + } + }; + const getStatusIcon = () => { if (!syncStatus.isOnline) return '🔴'; @@ -154,25 +154,6 @@ const SyncStatus = () => { {syncStatus.status === 'syncing' ? 'Syncing...' : 'Force Sync'}
- - {conflicts.length > 0 && ( -
-

Conflicts ({conflicts.length})

-
- {conflicts.map(conflict => ( -
- {conflict.id} - - {conflict.conflicts.length} revision(s) - -
- ))} -
-

- Use console tools to resolve conflicts: commad.sync.conflicts() -

-
- )} )} diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index d083189..97a668c 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -43,11 +43,16 @@ export const DatabaseService = { /** * Get a document by ID - * @param {string} id - Document ID + * @param {string} id - Document ID (can include ?rev=revision for specific revision) * @returns {Promise} Promise resolving to document object or null if not found */ getDocument: async (id) => { try { + // Check if this is a request for a specific revision + if (id.includes('?rev=')) { + const [docId, revParam] = id.split('?rev='); + return await db.get(docId, { rev: revParam }); + } return await db.get(id); } catch (error) { if (error.name === 'not_found') { @@ -173,5 +178,13 @@ export const DatabaseService = { */ resolveConflict: async (docId, winningRev, losingRevs) => { return await syncService.resolveConflict(docId, winningRev, losingRevs); + }, + + /** + * Auto-resolve all conflicts by merging content + * @returns {Promise} Promise resolving to number of conflicts resolved + */ + autoResolveConflicts: async () => { + return await syncService.autoResolveConflicts(); } }; diff --git a/src/services/SyncService.js b/src/services/SyncService.js index 7f5dcf7..3c27e5e 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -126,13 +126,41 @@ class SyncService { // Handle sync events this.syncHandler - .on('change', (info) => { + .on('change', async (info) => { console.log('Sync change:', info); this.lastSyncTime = new Date().toISOString(); this.syncStatus = 'syncing'; this.notifyListeners(); + + // Always trigger document update when we receive changes + // This ensures the editor refreshes regardless of sync direction + if (info.change && info.change.docs && info.change.docs.length > 0) { + console.log('Documents changed:', info.change.docs.length); + const updatedDocIds = info.change.docs.map(doc => doc._id); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('documentsUpdated', { + detail: { documentIds: updatedDocIds } + })); + }, 100); + } + + // Auto-resolve conflicts immediately after any sync change + setTimeout(async () => { + try { + const resolved = await this.autoResolveConflicts(); + if (resolved > 0) { + console.log(`Auto-resolved ${resolved} conflict(s) after sync change`); + // Trigger general document refresh after conflict resolution + setTimeout(() => { + window.dispatchEvent(new CustomEvent('documentsUpdated')); + }, 500); + } + } catch (error) { + console.error('Error during auto-conflict resolution:', error); + } + }, 500); // Small delay to let sync settle }) - .on('paused', (err) => { + .on('paused', async (err) => { if (err) { console.error('Sync paused with error:', err); this.syncStatus = 'error'; @@ -141,6 +169,20 @@ class SyncService { console.log('Sync paused (up to date)'); this.syncStatus = 'up-to-date'; this.syncError = null; + + // Auto-resolve any remaining conflicts when sync is up to date + try { + const resolved = await this.autoResolveConflicts(); + if (resolved > 0) { + console.log(`Auto-resolved ${resolved} conflict(s) on sync pause`); + // Trigger document refresh + setTimeout(() => { + window.dispatchEvent(new CustomEvent('documentsUpdated')); + }, 500); + } + } catch (error) { + console.error('Error during auto-conflict resolution on pause:', error); + } } this.notifyListeners(); }) @@ -373,6 +415,206 @@ class SyncService { } } + /** + * Auto-resolve conflicts by merging content + */ + async autoResolveConflicts() { + try { + const conflicts = await this.getConflicts(); + + for (const conflict of conflicts) { + try { + await this.autoResolveConflict(conflict); + } catch (error) { + console.error(`Failed to auto-resolve conflict for ${conflict.id}:`, error); + } + } + + return conflicts.length; + } catch (error) { + console.error('Error in auto-resolve conflicts:', error); + return 0; + } + } + + /** + * Auto-resolve a single conflict + */ + async autoResolveConflict(conflict) { + try { + // Get current version + const currentDoc = conflict.doc; + + // Get all conflicting versions + const conflictVersions = []; + for (const conflictRev of conflict.conflicts) { + try { + const conflictDoc = await this.localDB.get(conflict.id, { rev: conflictRev }); + conflictVersions.push(conflictDoc); + } catch (error) { + console.warn(`Could not load conflict revision ${conflictRev}:`, error); + } + } + + if (conflictVersions.length === 0) return; + + // Try to merge the content + const mergedContent = this.mergeContent(currentDoc, conflictVersions[0]); + + // Create resolved document + const resolvedDoc = { + ...currentDoc, + content: mergedContent, + updatedAt: new Date().toISOString() + }; + + // Save merged document + await this.localDB.put(resolvedDoc); + + // Remove conflict revisions + for (const rev of conflict.conflicts) { + try { + await this.localDB.remove(conflict.id, rev); + } catch (error) { + console.warn(`Could not remove conflict revision ${rev}:`, error); + } + } + + console.log(`Auto-resolved conflict for document: ${conflict.id}`); + + // Notify that specific document was updated + setTimeout(() => { + window.dispatchEvent(new CustomEvent('documentsUpdated', { + detail: { documentIds: [conflict.id] } + })); + }, 100); + + return true; + } catch (error) { + console.error('Error auto-resolving conflict:', error); + return false; + } + } + + /** + * Merge content from two document versions + * Conservative approach - never lose any content + */ + mergeContent(doc1, doc2) { + const content1 = doc1.content || ''; + const content2 = doc2.content || ''; + + // If contents are identical, return as-is + if (content1 === content2) { + return content1; + } + + // If one is empty, use the non-empty one + if (!content1.trim()) return content2; + if (!content2.trim()) return content1; + + // Split into lines for comparison + const lines1 = content1.split('\n'); + const lines2 = content2.split('\n'); + + // Try intelligent merging - if one version contains all content of the other + if (lines1.every(line => lines2.includes(line))) { + return content2; // content2 is a superset + } + if (lines2.every(line => lines1.includes(line))) { + return content1; // content1 is a superset + } + + // Try to merge by combining unique lines while preserving order + const mergedLines = this.mergeLinesByContent(lines1, lines2); + if (mergedLines) { + return mergedLines.join('\n'); + } + + // If we can't merge intelligently, preserve both versions + // Use timestamp to determine order + const time1 = new Date(doc1.updatedAt || doc1.createdAt || 0); + const time2 = new Date(doc2.updatedAt || doc2.createdAt || 0); + + let firstContent, secondContent, firstLabel, secondLabel; + if (time1 > time2) { + firstContent = content1; + secondContent = content2; + firstLabel = `Recent Version (${time1.toLocaleString()})`; + secondLabel = `Earlier Version (${time2.toLocaleString()})`; + } else { + firstContent = content2; + secondContent = content1; + firstLabel = `Recent Version (${time2.toLocaleString()})`; + secondLabel = `Earlier Version (${time1.toLocaleString()})`; + } + + // Combine both versions with clear markers + const mergedContent = [ + `<<<<<<< ${firstLabel}`, + firstContent, + '=======', + secondContent, + `>>>>>>> ${secondLabel}`, + '', + '', + '' + ].join('\n'); + + return mergedContent; + } + + /** + * Attempt to merge lines intelligently by content + */ + mergeLinesByContent(lines1, lines2) { + // If one list is much shorter, it might be a subset + if (lines1.length < 5 && lines2.length > lines1.length * 2) { + // Check if lines1 is mostly contained in lines2 + const contained = lines1.filter(line => line.trim() && lines2.includes(line)).length; + if (contained >= lines1.length * 0.8) { + return lines2; // Use the longer version + } + } + + if (lines2.length < 5 && lines1.length > lines2.length * 2) { + // Check if lines2 is mostly contained in lines1 + const contained = lines2.filter(line => line.trim() && lines1.includes(line)).length; + if (contained >= lines2.length * 0.8) { + return lines1; // Use the longer version + } + } + + // Try simple append detection - if one version starts with the other + const lines1Str = lines1.join('\n'); + const lines2Str = lines2.join('\n'); + + if (lines1Str.startsWith(lines2Str) || lines2Str.startsWith(lines1Str)) { + // One is likely an extension of the other + return lines1.length > lines2.length ? lines1 : lines2; + } + + // Check if they have a common beginning and different endings + let commonStart = 0; + while (commonStart < Math.min(lines1.length, lines2.length) && + lines1[commonStart] === lines2[commonStart]) { + commonStart++; + } + + if (commonStart > 0 && commonStart >= Math.min(lines1.length, lines2.length) * 0.5) { + // They share a significant common beginning + const commonLines = lines1.slice(0, commonStart); + const unique1 = lines1.slice(commonStart); + const unique2 = lines2.slice(commonStart); + + // Combine: common part + unique parts + return [...commonLines, ...unique1, ...unique2]; + } + + // No clear merge pattern found + return null; + } + /** * Clean up resources */ diff --git a/src/utils/consoleTools.js b/src/utils/consoleTools.js index 2a87a9f..fbac953 100644 --- a/src/utils/consoleTools.js +++ b/src/utils/consoleTools.js @@ -358,7 +358,7 @@ Connection Management: Conflict Resolution: • commad.sync.conflicts() - List documents with conflicts -• commad.sync.resolve(id, winningRev, losingRevs) - Resolve conflict +• commad.sync.resolve(id, winningRev, losingRevs) - Manual conflict resolution Examples: commad.sync.status() @@ -407,6 +407,19 @@ Sync Interval: ${config.syncInterval}ms try { const result = await DatabaseService.forceSync(); console.log('✅ Sync completed successfully:', result); + + // Auto-resolve any conflicts that occurred during sync + setTimeout(async () => { + try { + const resolved = await DatabaseService.autoResolveConflicts(); + if (resolved > 0) { + console.log(`🤖 Auto-resolved ${resolved} conflict(s) after sync`); + } + } catch (error) { + console.warn('⚠️ Could not auto-resolve conflicts after sync:', error.message); + } + }, 1000); + return result; } catch (error) { console.error('❌ Sync failed:', error.message); @@ -437,6 +450,19 @@ Sync Interval: ${config.syncInterval}ms try { const result = await syncService.pullFromRemote(); console.log('✅ Pull completed successfully:', result); + + // Auto-resolve any conflicts that occurred during pull + setTimeout(async () => { + try { + const resolved = await DatabaseService.autoResolveConflicts(); + if (resolved > 0) { + console.log(`🤖 Auto-resolved ${resolved} conflict(s) after pull`); + } + } catch (error) { + console.warn('⚠️ Could not auto-resolve conflicts after pull:', error.message); + } + }, 1000); + return result; } catch (error) { console.error('❌ Pull failed:', error.message);