Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 54 additions & 12 deletions apps/desktop/src/main/services/sync/SyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
import { MAX_BACKOFF_MS, isNetworkError, isAuthError } from './helpers.js';
import { parseSyncedNote, serializeSyncedNote } from './envelope.js';
import type { SyncCursorStore } from './cursorStore.js';
import { chosenConflictContent, needsLocalRestore } from './resolveNoteConflict.js';

// ============================================================================
// SyncService Class
Expand All @@ -51,6 +52,7 @@ export class SyncService {
private abortController: AbortController | null = null;
private statusListener: SyncStatusListener | null = null;
private cursorStore: SyncCursorStore | null = null;
private pendingConflicts = new Map<string, SyncConflict>();

constructor(
apiClient: ApiClient,
Expand Down Expand Up @@ -610,6 +612,7 @@ export class SyncService {
type: 'sync-success',
changesApplied: totalApplied,
changesPushed: totalPushed,
conflicts: pullResult.conflicts,
Comment thread
tomymaritano marked this conversation as resolved.
});

return {
Expand Down Expand Up @@ -714,16 +717,53 @@ export class SyncService {
throw new Error(`Note ${noteId} not found`);
}

const pending = this.pendingConflicts.get(noteId);
const id = createNoteId(noteId);
const copy = pending?.localCopyId
? await this.noteRepository.get(createNoteId(pending.localCopyId))
: null;

if (resolution === 'local') {
// Keep local version, mark for push to server
this.noteRepository.resetSyncTracking(createNoteId(noteId));
console.warn(`Conflict resolved: keeping local version for ${noteId}, marked for sync`);
const localContent = copy?.content ?? pending?.localContent;
if (localContent != null && needsLocalRestore(note.content, localContent)) {
const title = this.extractTitle(localContent);
await this.noteRepository.save({
...(copy ?? note),
id,
content: localContent,
title,
metadata: {
...(copy ?? note).metadata,
title,
updatedAt: createTimestamp(new Date()),
},
});
}
this.noteRepository.resetSyncTracking(id);
} else if (pending) {
const remoteContent = chosenConflictContent('remote', pending);
if (note.content !== remoteContent) {
const title = this.extractTitle(remoteContent);
await this.noteRepository.save({
...note,
content: remoteContent,
title,
metadata: {
...note.metadata,
title,
updatedAt: createTimestamp(new Date()),
},
});
}
this.noteRepository.markAsSynced(id);
} else {
// Keep remote version (already applied during pull)
// Just mark as synced to clear the conflict state
this.noteRepository.markAsSynced(createNoteId(noteId));
console.warn(`Conflict resolved: keeping remote version for ${noteId}`);
this.noteRepository.markAsSynced(id);
}

if (copy) {
await this.noteRepository.delete(copy.id);
}
this.pendingConflicts.delete(noteId);
}

/**
Expand Down Expand Up @@ -881,21 +921,23 @@ export class SyncService {
hasLocalEdits && change.deviceId !== this.apiClient['deviceInfo'].deviceId;

if (isConflict) {
// Store conflict for user resolution
conflicts.push({
const localCopyId = `${change.noteId}-conflict-${Date.now()}`;
const conflict: SyncConflict = {
noteId: change.noteId,
localContent: existingNote.content,
remoteContent: payload.content,
localVersion: change.version - 1, // Estimate
remoteVersion: change.version,
timestamp: new Date().toISOString(),
});
localCopyId,
};
conflicts.push(conflict);
this.pendingConflicts.set(change.noteId, conflict);

// Create a conflict copy
const conflictTitle = `${existingNote.title} (Conflict ${new Date().toLocaleString()})`;
await this.noteRepository.save({
...existingNote,
id: createNoteId(`${change.noteId}-conflict-${Date.now()}`),
id: createNoteId(localCopyId),
title: conflictTitle,
metadata: {
...existingNote.metadata,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { chosenConflictContent, needsLocalRestore } from '../resolveNoteConflict';

const versions = {
localContent: '# Meeting\n\nLocal',
remoteContent: '# Meeting\n\nRemote',
};

describe('chosenConflictContent', () => {
it('returns the local body when keeping this device', () => {
expect(chosenConflictContent('local', versions)).toBe(versions.localContent);
});

it('returns the remote body when keeping the other device', () => {
expect(chosenConflictContent('remote', versions)).toBe(versions.remoteContent);
});
});

describe('needsLocalRestore', () => {
it('is true after pull overwrote the note with remote', () => {
expect(needsLocalRestore(versions.remoteContent, versions.localContent)).toBe(true);
});

it('is false when the note still has the local body', () => {
expect(needsLocalRestore(versions.localContent, versions.localContent)).toBe(false);
});
});
20 changes: 20 additions & 0 deletions apps/desktop/src/main/services/sync/resolveNoteConflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ConflictVersions {
localContent: string;
remoteContent: string;
}

/** Content that should live on the original note after the user picks a side. */
export function chosenConflictContent(
resolution: 'local' | 'remote',
versions: ConflictVersions
): string {
return resolution === 'local' ? versions.localContent : versions.remoteContent;
}

/**
* Pull already writes the remote body onto the note. Keep-local must restore
* the captured local body before we mark the note dirty for push.
*/
export function needsLocalRestore(currentContent: string, localContent: string): boolean {
return currentContent !== localContent;
}
9 changes: 8 additions & 1 deletion apps/desktop/src/main/services/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface SyncConflict {
localVersion: number;
remoteVersion: number;
timestamp: string;
/** Disk backup of the local body created before pull overwrote the note. */
localCopyId?: string;
}

export interface SyncResult {
Expand All @@ -31,7 +33,12 @@ export interface SyncState {

export type SyncStatusEvent =
| { type: 'sync-start' }
| { type: 'sync-success'; changesApplied: number; changesPushed: number }
| {
type: 'sync-success';
changesApplied: number;
changesPushed: number;
conflicts?: SyncConflict[];
}
| { type: 'sync-error'; error: string; isNetworkError: boolean; consecutiveFailures: number }
| { type: 'needs-setup'; error: string }
| { type: 'auth-expired' };
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NoteWindow } from './components/NoteWindow';
import { Sidebar } from './components/sidebar';
import { GraphView } from './components/GraphView';
import { CommandPalette } from './components/CommandPalette';
import { ConflictResolver } from './components/sync/ConflictResolver';
import { AiPanel } from './components/ai/AiPanel';
import { LicenseProvider } from './contexts/LicenseContext';
import { ToastProvider } from './components/Toast';
Expand Down Expand Up @@ -493,6 +494,7 @@ function NotesApp() {
setTagFilter(name);
}}
/>
<ConflictResolver variant="modal" />
<Toaster />
</div>
</LicenseProvider>
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo, useState, useEffect, useRef, useCallback } from 'react';
import { Cloud, CloudOff, RefreshCw, AlertCircle, Check } from 'lucide-react';
import { Cloud, CloudOff, RefreshCw, AlertCircle, AlertTriangle, Check } from 'lucide-react';
import { useAuthStore } from '../../stores/authStore';
import {
useSyncStore,
Expand All @@ -8,6 +8,7 @@ import {
selectConsecutiveFailures,
selectPendingCount,
selectError,
selectConflicts,
} from '../../stores/syncStore';
import { syncFooterAction, syncFooterErrorLabel } from '../../utils/syncFooterCopy';
import { sc } from './sc';
Expand Down Expand Up @@ -53,6 +54,8 @@ const SyncProgressIndicator = memo(function SyncProgressIndicator({
const syncError = useSyncStore(selectError);
const syncNow = useSyncStore(state => state.syncNow);
const refreshPendingCount = useSyncStore(state => state.refreshPendingCount);
const conflicts = useSyncStore(selectConflicts);
const openConflictScreen = useSyncStore(state => state.openConflictScreen);

// Force re-render every 60s so relative time text stays fresh
const [, forceUpdate] = useState(0);
Expand Down Expand Up @@ -132,6 +135,25 @@ const SyncProgressIndicator = memo(function SyncProgressIndicator({
);
}

if (conflicts.length > 0) {
const n = conflicts.length;
return (
<div className={sc('sidebar-footer-progress', 'sidebar-footer-progress--conflict')}>
<AlertTriangle size={11} />
<span>
{n} conflict{n === 1 ? '' : 's'}
</span>
<button
type="button"
className={sc('sidebar-footer-progress-retry')}
onClick={() => openConflictScreen()}
>
Review
</button>
</div>
);
}

// Just synced flash
if (showSynced) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,10 @@
color: var(--danger);
}

.sidebar-footer-progress--conflict {
color: #f59e0b;
}

.sidebar-footer-progress--offline {
color: var(--text-muted);
}
Expand Down
Loading
Loading