diff --git a/apps/desktop/src/main/services/sync/SyncService.ts b/apps/desktop/src/main/services/sync/SyncService.ts index 5cc0f57f..5310a20d 100644 --- a/apps/desktop/src/main/services/sync/SyncService.ts +++ b/apps/desktop/src/main/services/sync/SyncService.ts @@ -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 @@ -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(); constructor( apiClient: ApiClient, @@ -610,6 +612,7 @@ export class SyncService { type: 'sync-success', changesApplied: totalApplied, changesPushed: totalPushed, + conflicts: pullResult.conflicts, }); return { @@ -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); } /** @@ -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, diff --git a/apps/desktop/src/main/services/sync/__tests__/resolveNoteConflict.test.ts b/apps/desktop/src/main/services/sync/__tests__/resolveNoteConflict.test.ts new file mode 100644 index 00000000..ed24e4ac --- /dev/null +++ b/apps/desktop/src/main/services/sync/__tests__/resolveNoteConflict.test.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/main/services/sync/resolveNoteConflict.ts b/apps/desktop/src/main/services/sync/resolveNoteConflict.ts new file mode 100644 index 00000000..290fdc6d --- /dev/null +++ b/apps/desktop/src/main/services/sync/resolveNoteConflict.ts @@ -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; +} diff --git a/apps/desktop/src/main/services/sync/types.ts b/apps/desktop/src/main/services/sync/types.ts index a828588a..c8f11c28 100644 --- a/apps/desktop/src/main/services/sync/types.ts +++ b/apps/desktop/src/main/services/sync/types.ts @@ -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 { @@ -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' }; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index b31f6a9d..d3f4cd5f 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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'; @@ -493,6 +494,7 @@ function NotesApp() { setTagFilter(name); }} /> + diff --git a/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx b/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx index 77859c70..10416b55 100644 --- a/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx +++ b/apps/desktop/src/renderer/components/sidebar/SidebarFooter.tsx @@ -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, @@ -8,6 +8,7 @@ import { selectConsecutiveFailures, selectPendingCount, selectError, + selectConflicts, } from '../../stores/syncStore'; import { syncFooterAction, syncFooterErrorLabel } from '../../utils/syncFooterCopy'; import { sc } from './sc'; @@ -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); @@ -132,6 +135,25 @@ const SyncProgressIndicator = memo(function SyncProgressIndicator({ ); } + if (conflicts.length > 0) { + const n = conflicts.length; + return ( +
+ + + {n} conflict{n === 1 ? '' : 's'} + + +
+ ); + } + // Just synced flash if (showSynced) { return ( diff --git a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css index e583fffb..1e41642b 100644 --- a/apps/desktop/src/renderer/components/sidebar/sidebar.module.css +++ b/apps/desktop/src/renderer/components/sidebar/sidebar.module.css @@ -1178,6 +1178,10 @@ color: var(--danger); } +.sidebar-footer-progress--conflict { + color: #f59e0b; +} + .sidebar-footer-progress--offline { color: var(--text-muted); } diff --git a/apps/desktop/src/renderer/components/sync/ConflictResolver.module.css b/apps/desktop/src/renderer/components/sync/ConflictResolver.module.css index 7cd133fc..b8352388 100644 --- a/apps/desktop/src/renderer/components/sync/ConflictResolver.module.css +++ b/apps/desktop/src/renderer/components/sync/ConflictResolver.module.css @@ -1,285 +1,275 @@ -.container { - background: var(--bg-secondary); - border: 2px solid #f59e0b; - border-radius: 0.75rem; - padding: 1.5rem; - margin: 1.5rem 0; +.overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, #000 55%, transparent); + backdrop-filter: blur(4px); +} + +.dialog, +.inline { + width: min(920px, 100%); + max-height: min(82vh, 760px); + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 22px 18px; + background: var(--bg-elevated, var(--bg-surface)); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 20px 60px color-mix(in srgb, #000 45%, transparent); + color: var(--text-primary); +} + +.inline { + max-height: none; + box-shadow: none; + margin: 8px 0 16px; } .header { display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.75rem; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.headerLeft { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; } .warningIcon { color: #f59e0b; flex-shrink: 0; + margin-top: 2px; } -.title { - font-size: 1.125rem; +.queue { + margin: 0 0 2px; + font-size: 11px; font-weight: 600; - color: var(--text-primary); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted, var(--text-secondary)); +} + +.title { margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .description { + margin: 0; + font-size: 13px; + line-height: 1.45; color: var(--text-secondary); - font-size: 0.875rem; - margin: 0 0 1rem 0; - line-height: 1.5; } -.conflictsList { - display: flex; - flex-direction: column; - gap: 1rem; +.later { + flex-shrink: 0; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; } -.conflict { - background: var(--bg-primary); - border: 1px solid var(--border-primary); - border-radius: 0.5rem; - overflow: hidden; +.later:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); } -.conflictHeader { - width: 100%; - padding: 1rem; - background: none; - border: none; +.viewToggle { display: flex; - justify-content: space-between; + gap: 6px; +} + +.toggleButton, +.toggleActive { + display: inline-flex; align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 12px; + font-weight: 500; cursor: pointer; - color: var(--text-primary); - font-size: 0.875rem; - transition: background 0.2s; } -.conflictHeader:hover { - background: var(--bg-tertiary); +.toggleButton { + background: var(--bg-surface, var(--bg-base)); + color: var(--text-secondary); } -.noteId { - font-weight: 500; +.toggleButton:hover { + background: var(--bg-hover); + color: var(--text-primary); } -.timestamp { - color: var(--text-secondary); - font-size: 0.8125rem; +.toggleActive { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: var(--accent); + color: var(--accent); } -.conflictDetails { +.versions { display: grid; grid-template-columns: 1fr auto 1fr; - gap: 1rem; - padding: 1rem; - border-top: 1px solid var(--border-primary); + gap: 10px; + min-height: 0; + flex: 1; } .version { display: flex; + min-width: 0; flex-direction: column; - gap: 0.75rem; + gap: 8px; } .versionHeader { display: flex; - justify-content: space-between; align-items: center; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-primary); + justify-content: space-between; + gap: 8px; } .versionLabel { - font-size: 0.8125rem; + font-size: 11px; font-weight: 600; - color: var(--text-primary); + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.05em; + color: var(--text-secondary); } .versionNumber { - font-size: 0.75rem; - color: var(--text-tertiary); - font-family: monospace; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--text-muted, var(--text-secondary)); } -.content { - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: 0.375rem; - padding: 0.75rem; - font-size: 0.8125rem; +.content, +.diffContent { + margin: 0; + min-height: 160px; + max-height: 320px; + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); color: var(--text-secondary); + font-size: 12px; line-height: 1.5; - max-height: 200px; - overflow-y: auto; - margin: 0; white-space: pre-wrap; - word-wrap: break-word; -} - -.keepButton { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--accent-primary); - color: white; - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.keepButton:hover:not(:disabled) { - background: var(--accent-hover); -} - -.keepButton:disabled { - opacity: 0.5; - cursor: not-allowed; + overflow-wrap: anywhere; } .divider { display: flex; align-items: center; - justify-content: center; - padding: 1rem 0; + color: var(--text-muted, var(--text-secondary)); } -.versusIcon { - color: var(--text-tertiary); +.diffAdded { + background: color-mix(in srgb, var(--success, #22c55e) 18%, transparent); + color: var(--success, #22c55e); } -/* View Toggle */ -.viewToggle { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; - padding: 0 1rem; +.diffRemoved { + background: color-mix(in srgb, var(--danger, #ef4444) 16%, transparent); + color: var(--danger, #ef4444); + text-decoration: line-through; } -.toggleButton { - flex: 1; - padding: 0.5rem 1rem; - background: var(--bg-tertiary); +.diffUnchanged { color: var(--text-secondary); - border: 1px solid var(--border-primary); - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; } -.toggleButton:hover { - background: var(--bg-secondary); - border-color: var(--accent-primary); -} - -.toggleActive { - flex: 1; - padding: 0.5rem 1rem; - background: var(--accent-primary); - color: white; - border: 1px solid var(--accent-primary); - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; +.error { + margin: 0; + font-size: 12px; + color: var(--danger, #ef4444); } -/* Unified Diff View */ -.unifiedDiff { - grid-column: 1 / -1; +.actions { display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 0 1rem; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; } -.diffHeader { - display: flex; - justify-content: space-between; +.keepLocal, +.keepRemote, +.openBoth { + display: inline-flex; align-items: center; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-primary); -} - -.diffLabel { - font-size: 0.8125rem; - font-weight: 600; - color: var(--text-primary); - text-transform: uppercase; - letter-spacing: 0.05em; + gap: 6px; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; } -.diffContent { - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: 0.375rem; - padding: 0.75rem; - font-size: 0.8125rem; - line-height: 1.6; - max-height: 400px; - overflow-y: auto; - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; +.keepLocal:disabled, +.keepRemote:disabled, +.openBoth:disabled { + opacity: 0.5; + cursor: not-allowed; } -.diffAdded { - background: rgba(34, 197, 94, 0.2); - color: #22c55e; - padding: 0 0.125rem; +.keepLocal { + border: none; + background: var(--accent); + color: var(--bg-base); } -.diffRemoved { - background: rgba(239, 68, 68, 0.2); - color: #ef4444; - text-decoration: line-through; - padding: 0 0.125rem; +.keepLocal:hover:not(:disabled) { + background: var(--accent-hover, var(--accent)); } -.diffUnchanged { - color: var(--text-secondary); +.keepRemote, +.openBoth { + border: 1px solid var(--border); + background: var(--bg-surface, var(--bg-base)); + color: var(--text-primary); } -.actionsRow { - display: flex; - gap: 1rem; - justify-content: center; - padding: 1rem; - grid-column: 1 / -1; +.keepRemote:hover:not(:disabled), +.openBoth:hover:not(:disabled) { + background: var(--bg-hover); } -@media (max-width: 768px) { - .conflictDetails { +@media (max-width: 720px) { + .versions { grid-template-columns: 1fr; - grid-template-rows: auto auto auto; } - .divider { - padding: 0.5rem 0; - transform: rotate(90deg); - } - - .actionsRow { - flex-direction: column; + .content, + .diffContent { + min-height: 96px; + max-height: 200px; } - .viewToggle { - padding: 0; - } - - .unifiedDiff { - padding: 0; + .divider { + justify-content: center; + transform: rotate(90deg); } } diff --git a/apps/desktop/src/renderer/components/sync/ConflictResolver.tsx b/apps/desktop/src/renderer/components/sync/ConflictResolver.tsx index 222f976d..8ee6eefc 100644 --- a/apps/desktop/src/renderer/components/sync/ConflictResolver.tsx +++ b/apps/desktop/src/renderer/components/sync/ConflictResolver.tsx @@ -1,19 +1,13 @@ -/** - * Conflict Resolver Component - * - * Shows sync conflicts and allows user to choose which version to keep. - * Displays visual diff with highlighted additions/deletions. - */ - -import { useState, useCallback, useMemo } from 'react'; -import { AlertTriangle, Check, X } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, Check, Columns2, GitCompare, X } from 'lucide-react'; import { diffLines, type Change } from 'diff'; -import { useSyncStore, selectConflicts } from '../../stores/syncStore'; +import { useQueryClient } from '@tanstack/react-query'; +import { noteKeys } from '../../hooks/useNotes'; +import { useSyncStore, selectConflicts, type Conflict } from '../../stores/syncStore'; +import { conflictNoteTitle, conflictQueueLabel } from '../../utils/conflictCopy'; +import { openBothConflict } from '../../utils/openBothConflict'; import styles from './ConflictResolver.module.css'; -/** - * Render a single diff change with appropriate styling - */ function DiffChange({ change }: { change: Change }) { if (change.added) { return {change.value}; @@ -24,9 +18,6 @@ function DiffChange({ change }: { change: Change }) { return {change.value}; } -/** - * Render unified diff view - */ function UnifiedDiff({ localContent, remoteContent, @@ -34,179 +25,245 @@ function UnifiedDiff({ localContent: string; remoteContent: string; }) { - const diff = useMemo(() => { - // Use line diff for better readability - return diffLines(localContent, remoteContent); - }, [localContent, remoteContent]); + const diff = useMemo(() => diffLines(localContent, remoteContent), [localContent, remoteContent]); return ( -
-
- Unified Diff (Local → Remote) -
-
-        {diff.map((change, idx) => (
-          
-        ))}
-      
-
+
+      {diff.map((change, idx) => (
+        
+      ))}
+    
); } -export function ConflictResolver() { +interface ConflictResolverProps { + variant?: 'modal' | 'inline'; +} + +export function ConflictResolver({ variant = 'inline' }: ConflictResolverProps) { const conflicts = useSyncStore(selectConflicts); const resolveConflict = useSyncStore(state => state.resolveConflict); - const [expandedConflict, setExpandedConflict] = useState(null); - const [showUnifiedDiff, setShowUnifiedDiff] = useState>({}); - const [isResolving, setIsResolving] = useState(false); + const dismissed = useSyncStore(state => state.conflictScreenDismissed); + const dismissConflictScreen = useSyncStore(state => state.dismissConflictScreen); + const queryClient = useQueryClient(); + const dialogRef = useRef(null); + const [view, setView] = useState<'side-by-side' | 'unified'>('side-by-side'); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const conflict = conflicts[0] ?? null; + + useEffect(() => { + setView('side-by-side'); + setError(null); + }, [conflict?.noteId]); + + const invalidateNotes = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: noteKeys.all }); + }, [queryClient]); const handleResolve = useCallback( async (noteId: string, resolution: 'local' | 'remote') => { - setIsResolving(true); + setBusy(true); + setError(null); try { await resolveConflict(noteId, resolution); - } catch (error) { - console.error('Failed to resolve conflict:', error); + invalidateNotes(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to resolve conflict'); } finally { - setIsResolving(false); + setBusy(false); } }, - [resolveConflict] + [invalidateNotes, resolveConflict] ); - if (conflicts.length === 0) { - return null; - } + const handleOpenBoth = useCallback( + async (current: Conflict) => { + setBusy(true); + setError(null); + try { + await openBothConflict(current, { + getNote: id => window.dripnex.notes.get(id), + createNote: input => window.dripnex.notes.create(input), + updateTitle: input => window.dripnex.notes.updateTitle(input), + openNote: (id, title) => window.dripnex.windows.openNote(id, title), + resolveLocal: id => resolveConflict(id, 'local'), + }); + invalidateNotes(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to open both versions'); + } finally { + setBusy(false); + } + }, + [invalidateNotes, resolveConflict] + ); - return ( -
-
- -

Sync Conflicts ({conflicts.length})

-
+ useEffect(() => { + if (variant !== 'modal' || !conflict || dismissed) return; + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusables = () => { + const root = dialogRef.current; + if (!root) return []; + return [ + ...root.querySelectorAll( + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ), + ]; + }; + focusables()[0]?.focus(); + + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + dismissConflictScreen(); + return; + } + if (event.key !== 'Tab') return; + const items = focusables(); + const first = items[0]; + const last = items[items.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + previous?.focus(); + }; + }, [conflict, dismissed, dismissConflictScreen, variant]); + + if (!conflict) return null; + if (variant === 'modal' && dismissed) return null; + + const title = conflictNoteTitle(conflict.localContent); + const queue = conflictQueueLabel(0, conflicts.length); + const titleId = `conflict-title-${variant}-${conflict.noteId}`; + const screen = ( +
+
+
+
+ {variant === 'modal' ? ( + + ) : null} +

- These notes were modified on multiple devices. Choose which version to keep. + Edited on this device and another. Keep one version, or open both and merge yourself.

-
- {conflicts.map(conflict => ( -
- - - {expandedConflict === conflict.noteId && ( -
- {/* Toggle between side-by-side and unified diff */} -
- - -
- - {showUnifiedDiff[conflict.noteId] ? ( - // Unified diff view - <> - -
- - -
- - ) : ( - // Side-by-side view - <> -
-
- Local Version - v{conflict.localVersion} -
-
{conflict.localContent}
- -
- -
- -
- -
-
- Remote Version - v{conflict.remoteVersion} -
-
{conflict.remoteContent}
- -
- - )} -
- )} +
+ + +
+ + {view === 'unified' ? ( + + ) : ( +
+
+
+ This device + v{conflict.localVersion} +
+
{conflict.localContent}
+
+ - ))} +
+
+ Other device + v{conflict.remoteVersion} +
+
{conflict.remoteContent}
+
+
+ )} + + {error ?

{error}

: null} + +
+ + + +
+
+ ); + + if (variant === 'inline') return screen; + + return ( +
+
event.stopPropagation()} onKeyDown={event => event.stopPropagation()}> + {screen}
); diff --git a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx index a8d9a224..7690a70c 100644 --- a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx +++ b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx @@ -23,6 +23,7 @@ export function SyncStatusIndicator() { const hasConflicts = useSyncStore(selectHasConflicts); const conflicts = useSyncStore(selectConflicts); const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const openConflictScreen = useSyncStore(state => state.openConflictScreen); const [showTooltip, setShowTooltip] = useState(false); const getStatusInfo = () => { @@ -38,7 +39,7 @@ export function SyncStatusIndicator() { if (hasConflicts && status !== 'syncing') { return { icon: , - label: `${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''} — resolve in Settings`, + label: `${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''} — Review`, className: styles.statusWarning, }; } @@ -96,6 +97,19 @@ export function SyncStatusIndicator() { return (
{ + if (hasConflicts) openConflictScreen(); + }} + onKeyDown={event => { + if (!hasConflicts) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openConflictScreen(); + } + }} onMouseEnter={() => setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} > diff --git a/apps/desktop/src/renderer/stores/syncStore.ts b/apps/desktop/src/renderer/stores/syncStore.ts index b41b8a25..c68af47e 100644 --- a/apps/desktop/src/renderer/stores/syncStore.ts +++ b/apps/desktop/src/renderer/stores/syncStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { hasNewConflict, mergeConflicts } from '../utils/conflictCopy'; // ============================================================================ // Types @@ -13,6 +14,7 @@ export interface SyncStatusEvent { consecutiveFailures?: number; changesApplied?: number; changesPushed?: number; + conflicts?: Conflict[]; } export interface Conflict { @@ -22,6 +24,7 @@ export interface Conflict { localVersion: number; remoteVersion: number; timestamp: string; + localCopyId?: string; } // ============================================================================ @@ -45,10 +48,14 @@ interface SyncState { consecutiveFailures: number; /** Number of local changes waiting to be pushed */ pendingCount: number; + /** User hid the conflict screen until the next new conflict */ + conflictScreenDismissed: boolean; // Actions syncNow: () => Promise; resolveConflict: (noteId: string, resolution: 'local' | 'remote') => Promise; + dismissConflictScreen: () => void; + openConflictScreen: () => void; clearError: () => void; setEnabled: (enabled: boolean) => void; updateLastSyncAt: (timestamp: number) => void; @@ -73,6 +80,7 @@ export const useSyncStore = create()((set, get) => ({ isEnabled: false, consecutiveFailures: 0, pendingCount: 0, + conflictScreenDismissed: false, // Actions @@ -97,13 +105,16 @@ export const useSyncStore = create()((set, get) => ({ // Get updated status from server const statusResult = await window.dripnex.sync.status(); - // Update state with results - set({ + const incoming = syncResult.conflicts || []; + set(state => ({ status: 'idle', cursor: statusResult.cursor || 0, - conflicts: syncResult.conflicts || [], + conflicts: mergeConflicts(state.conflicts, incoming), + conflictScreenDismissed: hasNewConflict(state.conflicts, incoming) + ? false + : state.conflictScreenDismissed, lastSyncAt: Date.now(), - }); + })); } catch (error) { let errorMessage = 'Sync failed'; let status: SyncStatus = 'error'; @@ -170,6 +181,10 @@ export const useSyncStore = create()((set, get) => ({ } }, + dismissConflictScreen: () => set({ conflictScreenDismissed: true }), + + openConflictScreen: () => set({ conflictScreenDismissed: false }), + /** * Clear error message */ @@ -253,16 +268,22 @@ export const useSyncStore = create()((set, get) => ({ set({ status: 'syncing', error: null }); break; - case 'sync-success': - set({ + case 'sync-success': { + const incoming = event.conflicts ?? []; + set(state => ({ status: 'idle', error: null, consecutiveFailures: 0, lastSyncAt: Date.now(), - }); + conflicts: mergeConflicts(state.conflicts, incoming), + conflictScreenDismissed: hasNewConflict(state.conflicts, incoming) + ? false + : state.conflictScreenDismissed, + })); // Refresh pending count after successful sync void get().refreshPendingCount(); break; + } case 'sync-error': { const isNetwork = event.isNetworkError ?? false; @@ -309,5 +330,6 @@ export const selectError = (state: SyncState) => state.error; export const selectIsEnabled = (state: SyncState) => state.isEnabled; export const selectIsSyncing = (state: SyncState) => state.status === 'syncing'; export const selectHasConflicts = (state: SyncState) => state.conflicts.length > 0; +export const selectConflictScreenDismissed = (state: SyncState) => state.conflictScreenDismissed; export const selectConsecutiveFailures = (state: SyncState) => state.consecutiveFailures; export const selectPendingCount = (state: SyncState) => state.pendingCount; diff --git a/apps/desktop/src/renderer/utils/__tests__/conflictCopy.test.ts b/apps/desktop/src/renderer/utils/__tests__/conflictCopy.test.ts new file mode 100644 index 00000000..8f2d2ab4 --- /dev/null +++ b/apps/desktop/src/renderer/utils/__tests__/conflictCopy.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + conflictNoteTitle, + conflictQueueLabel, + hasNewConflict, + mergeConflicts, + remoteCopyTitle, +} from '../conflictCopy'; + +describe('conflictNoteTitle', () => { + it('uses the first heading', () => { + expect(conflictNoteTitle('# Meeting notes\n\nBody')).toBe('Meeting notes'); + }); + + it('falls back to Untitled', () => { + expect(conflictNoteTitle(' \n')).toBe('Untitled'); + }); +}); + +describe('remoteCopyTitle', () => { + it('appends (remote)', () => { + expect(remoteCopyTitle('Meeting notes')).toBe('Meeting notes (remote)'); + }); + + it('does not double the suffix', () => { + expect(remoteCopyTitle('Meeting notes (remote)')).toBe('Meeting notes (remote)'); + }); + + it('uses Untitled when empty', () => { + expect(remoteCopyTitle(' ')).toBe('Untitled (remote)'); + }); +}); + +describe('conflictQueueLabel', () => { + it('singular for one', () => { + expect(conflictQueueLabel(0, 1)).toBe('1 conflict'); + }); + + it('numbers a queue', () => { + expect(conflictQueueLabel(1, 3)).toBe('Conflict 2 of 3'); + }); +}); + +describe('mergeConflicts', () => { + it('keeps unresolved when incoming is empty', () => { + const existing = [{ noteId: 'a' }]; + expect(mergeConflicts(existing, [])).toBe(existing); + }); + + it('adds new ids and replaces the same id', () => { + const existing = [{ noteId: 'a', localContent: 'old' }]; + const next = mergeConflicts(existing, [ + { noteId: 'a', localContent: 'new' }, + { noteId: 'b', localContent: 'other' }, + ]); + expect(next).toEqual([ + { noteId: 'a', localContent: 'new' }, + { noteId: 'b', localContent: 'other' }, + ]); + }); +}); + +describe('hasNewConflict', () => { + it('is true only for unseen note ids', () => { + expect(hasNewConflict([{ noteId: 'a' }], [{ noteId: 'a' }])).toBe(false); + expect(hasNewConflict([{ noteId: 'a' }], [{ noteId: 'b' }])).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/utils/__tests__/openBothConflict.test.ts b/apps/desktop/src/renderer/utils/__tests__/openBothConflict.test.ts new file mode 100644 index 00000000..a9af247c --- /dev/null +++ b/apps/desktop/src/renderer/utils/__tests__/openBothConflict.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { openBothConflict } from '../openBothConflict'; +import type { Conflict } from '../../stores/syncStore'; + +const conflict: Conflict = { + noteId: 'note-1', + localContent: '# Meeting\n\nLocal edits', + remoteContent: '# Meeting\n\nRemote edits', + localVersion: 3, + remoteVersion: 4, + timestamp: '2026-08-19T12:00:00.000Z', +}; + +function deps(overrides: Partial[1]> = {}) { + return { + getNote: vi.fn(async () => ({ ok: true as const, data: { id: 'note-1', notebookId: 'work' } })), + createNote: vi.fn(async () => ({ ok: true as const, data: { id: 'note-remote' } })), + updateTitle: vi.fn(async () => ({ ok: true })), + openNote: vi.fn(async () => ({ ok: true })), + resolveLocal: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe('openBothConflict', () => { + it('saves the other version, keeps this device, opens both', async () => { + const api = deps(); + const result = await openBothConflict(conflict, api); + + expect(result).toEqual({ remoteNoteId: 'note-remote', remoteTitle: 'Meeting (remote)' }); + expect(api.createNote).toHaveBeenCalledWith({ + content: conflict.remoteContent, + notebookId: 'work', + }); + expect(api.updateTitle).toHaveBeenCalledWith({ + id: 'note-remote', + title: 'Meeting (remote)', + }); + expect(api.resolveLocal).toHaveBeenCalledWith('note-1'); + expect(api.openNote).toHaveBeenNthCalledWith(1, 'note-1', 'Meeting'); + expect(api.openNote).toHaveBeenNthCalledWith(2, 'note-remote', 'Meeting (remote)'); + }); + + it('does not resolve when renaming the copy fails', async () => { + const api = deps({ + updateTitle: vi.fn(async () => ({ ok: false })), + }); + + await expect(openBothConflict(conflict, api)).rejects.toThrow( + 'Could not rename the other version.' + ); + expect(api.resolveLocal).not.toHaveBeenCalled(); + }); + + it('creates the copy without a notebook when the note lookup fails', async () => { + const api = deps({ + getNote: vi.fn(async () => ({ ok: false as const })), + }); + + await openBothConflict(conflict, api); + + expect(api.createNote).toHaveBeenCalledWith({ + content: conflict.remoteContent, + notebookId: undefined, + }); + }); + + it('does not resolve when the copy cannot be created', async () => { + const api = deps({ + createNote: vi.fn(async () => ({ ok: false as const })), + }); + + await expect(openBothConflict(conflict, api)).rejects.toThrow( + 'Could not save the other version as a new note.' + ); + expect(api.resolveLocal).not.toHaveBeenCalled(); + expect(api.openNote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/utils/conflictCopy.ts b/apps/desktop/src/renderer/utils/conflictCopy.ts new file mode 100644 index 00000000..952b6b8b --- /dev/null +++ b/apps/desktop/src/renderer/utils/conflictCopy.ts @@ -0,0 +1,31 @@ +import { extractTitle } from '@dripnex/core'; + +export function conflictNoteTitle(content: string): string { + return extractTitle(content); +} + +export function remoteCopyTitle(title: string): string { + const base = title.trim() || 'Untitled'; + return /\(remote\)\s*$/i.test(base) ? base : `${base} (remote)`; +} + +export function conflictQueueLabel(index: number, total: number): string { + if (total <= 1) return '1 conflict'; + return `Conflict ${index + 1} of ${total}`; +} + +export function mergeConflicts(existing: T[], incoming: T[]): T[] { + if (incoming.length === 0) return existing; + const byId = new Map(existing.map(conflict => [conflict.noteId, conflict])); + for (const conflict of incoming) { + byId.set(conflict.noteId, conflict); + } + return [...byId.values()]; +} + +export function hasNewConflict( + existing: T[], + incoming: T[] +): boolean { + return incoming.some(conflict => !existing.some(current => current.noteId === conflict.noteId)); +} diff --git a/apps/desktop/src/renderer/utils/openBothConflict.ts b/apps/desktop/src/renderer/utils/openBothConflict.ts new file mode 100644 index 00000000..95ff6e47 --- /dev/null +++ b/apps/desktop/src/renderer/utils/openBothConflict.ts @@ -0,0 +1,48 @@ +import type { Conflict } from '../stores/syncStore'; +import { conflictNoteTitle, remoteCopyTitle } from './conflictCopy'; + +export interface OpenBothNote { + id: string; + notebookId?: string; +} + +export interface OpenBothDeps { + getNote: (id: string) => Promise<{ ok: true; data: OpenBothNote } | { ok: false }>; + createNote: (input: { + content: string; + notebookId?: string; + }) => Promise<{ ok: true; data: OpenBothNote } | { ok: false }>; + updateTitle: (input: { id: string; title: string }) => Promise<{ ok: boolean }>; + openNote: (id: string, title: string) => Promise; + resolveLocal: (noteId: string) => Promise; +} + +/** Keep this device, save the other version as `{title} (remote)`, open both. */ +export async function openBothConflict( + conflict: Conflict, + deps: OpenBothDeps +): Promise<{ remoteNoteId: string; remoteTitle: string }> { + const localTitle = conflictNoteTitle(conflict.localContent); + const copyTitle = remoteCopyTitle(localTitle); + + const existing = await deps.getNote(conflict.noteId); + const notebookId = existing.ok ? existing.data.notebookId : undefined; + + const created = await deps.createNote({ + content: conflict.remoteContent, + notebookId, + }); + if (!created.ok) { + throw new Error('Could not save the other version as a new note.'); + } + + const renamed = await deps.updateTitle({ id: created.data.id, title: copyTitle }); + if (!renamed.ok) { + throw new Error('Could not rename the other version.'); + } + await deps.resolveLocal(conflict.noteId); + await deps.openNote(conflict.noteId, localTitle); + await deps.openNote(created.data.id, copyTitle); + + return { remoteNoteId: created.data.id, remoteTitle: copyTitle }; +} diff --git a/docs/NOW.md b/docs/NOW.md index a2ca0027..c66778d0 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -31,6 +31,7 @@ Shipped: - Scoped FTS and template picker. - MCP writes off unless `DRIPNEX_MCP_WRITES=1`. - Command palette Quick Open. +- Sync conflicts: one screen, keep this device / keep other / open both. - Emergency Kit + 1Password integration (`Settings → Integrations`). - Dev renderer waits for Vite (no `chrome-error` hop).