One merge window, not fifteen

show_conflict_summary() built a fresh ConflictSummaryDialog every time and
only reassigned self._conflict_dialog — the old dialog was still a child of
the window, so it stayed on screen. check_for_external_changes() runs
resolve_conflicts() on every Syncthing watch tick, so that was one window per
merge, forever. Fifteen had piled up on trav's desktop.

The dialog is now a session-long log: add_event() folds each merge in as its
own timestamped entry, newest first, and the restore button names the merge
it would undo. MainWindow reuses the open dialog and clears its reference on
finished, so closing it lets the next merge open a fresh one instead of
touching a deleted C++ object.

Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
This commit is contained in:
2026-08-20 20:33:14 -04:00
parent 76b941bf68
commit 3037415928
4 changed files with 195 additions and 36 deletions
+71 -30
View File
@@ -1,5 +1,11 @@
"""A read-only summary of Syncthing conflicts LinTunes auto-merged, with the
option to open or restore the pre-merge backup."""
option to open or restore the pre-merge backup.
One dialog per session, not one per merge: Syncthing can deliver conflicts every
few minutes, so ``add_event`` folds each new merge into the open window as a
timestamped entry instead of stacking another window on the desktop.
"""
from datetime import datetime
from pathlib import Path
from PyQt6.QtCore import QUrl
@@ -9,62 +15,97 @@ from PyQt6.QtWidgets import (
QMessageBox)
INTRO = ("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.")
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._events = [] # newest last: (datetime, list[ConflictSummary])
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)
self._intro = QLabel(INTRO)
self._intro.setWordWrap(True)
layout.addWidget(self._intro)
body = QTextEdit()
body.setReadOnly(True)
body.setPlainText(self._format(summaries))
layout.addWidget(body, 1)
self._body = QTextEdit()
self._body.setReadOnly(True)
layout.addWidget(self._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)
self._open_btn = QPushButton("Open backup folder")
self._open_btn.clicked.connect(self._open_backup)
self._restore_btn = QPushButton("Restore pre-merge backup")
self._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.addWidget(self._open_btn)
buttons.addWidget(self._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))
self.add_event(summaries)
@staticmethod
def _format(summaries) -> str:
# ---- events ----
def add_event(self, summaries, when=None):
"""Fold another merge into this window as its own timestamped entry."""
self._events.append((when or datetime.now(), list(summaries or [])))
self._refresh()
@property
def _backup_dir(self) -> str:
"""The newest event's backup snapshot. One resolve_conflicts() call makes
one timestamped dir, so every summary in an event shares it."""
for _when, summaries in reversed(self._events):
if summaries and summaries[0].backup_dir:
return summaries[0].backup_dir
return ""
def _refresh(self):
self._body.setPlainText(self._format())
count = len(self._events)
self._intro.setText(
INTRO if count < 2 else f"{INTRO}\n\n{count} merges this session.")
newest = self._events[-1][0] if self._events else None
self._restore_btn.setText(
f"Restore backup from {newest.strftime('%-I:%M %p')}"
if newest and count > 1 else "Restore pre-merge backup")
self._open_btn.setEnabled(bool(self._backup_dir))
self._restore_btn.setEnabled(bool(self._backup_dir))
def _format(self) -> str:
blocks = []
for s in summaries:
lines = [f"● {s.file}"]
lines.extend(f" {line}" for line in s.lines)
blocks.append("\n".join(lines))
multi = len(self._events) > 1
for when, summaries in reversed(self._events): # newest first
lines = []
if multi:
lines.append(f"── {when.strftime('%-I:%M %p')} " + "─" * 30)
for s in summaries:
lines.append(f"● {s.file}")
lines.extend(f" {line}" for line in s.lines)
lines.append("")
blocks.append("\n".join(lines).rstrip())
return "\n\n".join(blocks)
# ---- actions ----
def _open_backup(self):
if self._backup_dir:
QDesktopServices.openUrl(QUrl.fromLocalFile(self._backup_dir))
def _restore_backup(self):
if not self._backup_dir:
backup_dir = self._backup_dir
if not backup_dir:
return
if QMessageBox.question(
self, "Restore backup",
@@ -73,7 +114,7 @@ class ConflictSummaryDialog(QDialog):
"conflict later.") != QMessageBox.StandardButton.Yes:
return
from lintunes.storage import conflict_resolver
conflict_resolver.restore_backup(Path(self._backup_dir), self._manager.data_dir)
conflict_resolver.restore_backup(Path(backup_dir), self._manager.data_dir)
self._manager.reload_from_disk()
QMessageBox.information(self, "Restored", "Restored the pre-merge backup.")
self.accept()
+18 -3
View File
@@ -745,12 +745,27 @@ class MainWindow(QMainWindow):
self.show_conflict_summary(summaries)
def show_conflict_summary(self, summaries):
"""Show (or extend) the one merge window. Syncthing can deliver conflicts
every few minutes; each merge becomes another entry in the open window
rather than another window."""
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
# Modeless so a merge never blocks playback or what you're doing.
self._conflict_dialog = ConflictSummaryDialog(summaries, self._manager, self)
self._conflict_dialog.show()
if self._conflict_dialog is not None:
self._conflict_dialog.add_event(summaries)
else:
# Modeless so a merge never blocks playback or what you're doing.
self._conflict_dialog = ConflictSummaryDialog(
summaries, self._manager, self)
self._conflict_dialog.finished.connect(self._on_conflict_dialog_closed)
self._conflict_dialog.show()
self._conflict_dialog.raise_()
def _on_conflict_dialog_closed(self, _result):
"""Drop the reference (and the widget) so the next merge opens a fresh
window instead of touching a deleted C++ object."""
dialog, self._conflict_dialog = self._conflict_dialog, None
if dialog is not None:
dialog.deleteLater()
# ---- info dialog ----
def _show_info(self, track_ids: list[int]):