Files
lintunes/lintunes/gui/conflict_dialog.py
trav 9575f0400f Live multi-machine sync: watch files, reconcile, alert on conflicts
Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.

- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
  emits a single `changed`; the manager decides if it was external.
- library_manager: `reload_from_disk()` re-reads and reconciles disk into memory
  (max play/skip counts, newest edit wins, playlist membership unioned), keeping
  local unsaved edits and object identity so open views stay valid, then refreshes
  the UI without touching the player. `flush()` records each file's (mtime, size)
  so our own writes are never mistaken for an external change. New signals
  library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
  before merging, return list[ConflictSummary], add restore_backup(); runs at
  startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
  shows it from a status-bar notice and preserves scroll+selection on reload.

Tests: tests/test_round16.py (9) + full suite green (225).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:55:45 -04:00

80 lines
2.9 KiB
Python

"""A read-only summary of Syncthing conflicts LinTunes auto-merged, with the
option to open or restore the pre-merge backup."""
from pathlib import Path
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QDesktopServices
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton,
QMessageBox)
class ConflictSummaryDialog(QDialog):
def __init__(self, summaries, manager, parent=None):
super().__init__(parent)
self._summaries = summaries
self._manager = manager
self._backup_dir = summaries[0].backup_dir if summaries else ""
self.setWindowTitle("Synced changes merged")
self.resize(560, 440)
layout = QVBoxLayout(self)
intro = QLabel(
"LinTunes found changes made on more than one machine and merged "
"them (play counts kept highest, newest edits win, nothing removed). "
"Both versions were backed up first — restore them if a merge isn't "
"what you wanted.")
intro.setWordWrap(True)
layout.addWidget(intro)
body = QTextEdit()
body.setReadOnly(True)
body.setPlainText(self._format(summaries))
layout.addWidget(body, 1)
buttons = QHBoxLayout()
open_btn = QPushButton("Open backup folder")
open_btn.clicked.connect(self._open_backup)
restore_btn = QPushButton("Restore pre-merge backup")
restore_btn.clicked.connect(self._restore_backup)
keep_btn = QPushButton("Keep merged")
keep_btn.setDefault(True)
keep_btn.clicked.connect(self.accept)
buttons.addWidget(open_btn)
buttons.addWidget(restore_btn)
buttons.addStretch(1)
buttons.addWidget(keep_btn)
layout.addLayout(buttons)
open_btn.setEnabled(bool(self._backup_dir))
restore_btn.setEnabled(bool(self._backup_dir))
@staticmethod
def _format(summaries) -> str:
blocks = []
for s in summaries:
lines = [f"{s.file}"]
lines.extend(f" {line}" for line in s.lines)
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
def _open_backup(self):
if self._backup_dir:
QDesktopServices.openUrl(QUrl.fromLocalFile(self._backup_dir))
def _restore_backup(self):
if not self._backup_dir:
return
if QMessageBox.question(
self, "Restore backup",
"Replace the current files with the pre-merge versions from this "
"backup? The other machine's changes may re-sync as a new "
"conflict later.") != QMessageBox.StandardButton.Yes:
return
from lintunes.storage import conflict_resolver
conflict_resolver.restore_backup(Path(self._backup_dir), self._manager.data_dir)
self._manager.reload_from_disk()
QMessageBox.information(self, "Restored", "Restored the pre-merge backup.")
self.accept()