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
+
+
+ {isResolving ? 'Resolving...' : 'Auto-Resolve'}
+
+
+
+ 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);