iso-scalpel/iso_scalpel/dialogs/diff_dialog.py

234 lines
8.5 KiB
Python

"""GUI diff viewer dialog.
Opens two ISO images and displays their filesystem differences in a
unified tree with status indicators. Shows only the filesystem diff
(which entries were added / removed / modified) — not file-content
diffs.
"""
# This file is part of ISO Scalpel.
# Copyright (C) 2025 Jeremy Anderson <info@dcos.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>
# or write to the Free Software Foundation, Inc., 51 Franklin Street,
# Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import annotations
import os
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDialog,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from ..diff import DiffEntry, DiffResult, DiffStatus, diff_images
from ..iso_handler import IsoHandler
from ..iso_record import NAME_TYPE_LABELS, NameType, _human_size
# Status → (label, foreground colour)
_STATUS_STYLE = {
DiffStatus.SAME: ("=", QColor("#6b7280")), # grey
DiffStatus.MODIFIED: ("M", QColor("#b45309")), # amber
DiffStatus.ADDED: ("+", QColor("#047857")), # emerald
DiffStatus.REMOVED: ("-", QColor("#b91c1c")), # red
}
class DiffDialog(QDialog):
"""Compare two ISO images' filesystems."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Compare Images")
self.setMinimumSize(820, 520)
self._ha = IsoHandler()
self._hb = IsoHandler()
# --- file pickers ------------------------------------------------
picker_group = QGroupBox("Images")
pf = QFormLayout(picker_group)
self._a_edit = QLineEdit()
self._a_edit.setPlaceholderText("Image A (the 'from')")
a_browse = QPushButton("Browse…")
a_browse.clicked.connect(lambda: self._pick_file(self._a_edit, "Image A"))
a_row = QHBoxLayout()
a_row.addWidget(self._a_edit, 1)
a_row.addWidget(a_browse)
pf.addRow("A:", _wrap(a_row))
self._b_edit = QLineEdit()
self._b_edit.setPlaceholderText("Image B (the 'to')")
b_browse = QPushButton("Browse…")
b_browse.clicked.connect(lambda: self._pick_file(self._b_edit, "Image B"))
b_row = QHBoxLayout()
b_row.addWidget(self._b_edit, 1)
b_row.addWidget(b_browse)
pf.addRow("B:", _wrap(b_row))
self._view = QComboBox()
self._view.addItem("Auto (richest common)", None)
for nt in (NameType.UDF, NameType.ROCK_RIDGE, NameType.JOLIET, NameType.ISO9660):
self._view.addItem(NAME_TYPE_LABELS[nt], nt)
pf.addRow("Compare in:", self._view)
self._show_all = QCheckBox("Show unchanged entries too")
pf.addRow(self._show_all)
compare_btn = QPushButton("Compare")
compare_btn.setDefault(True)
compare_btn.clicked.connect(self._do_compare)
pf.addRow(compare_btn)
# --- summary label -----------------------------------------------
self._summary = QLabel("Pick two images and click Compare.")
self._summary.setStyleSheet("padding:4px; color:#444;")
# --- diff tree ---------------------------------------------------
self._tree = QTreeWidget()
self._tree.setColumnCount(6)
self._tree.setHeaderLabels(["", "Path", "Size (A)", "Size (B)", "Date (A)", "Date (B)"])
self._tree.setAlternatingRowColors(True)
self._tree.setUniformRowHeights(True)
self._tree.setRootIsDecorated(False)
self._tree.setColumnWidth(0, 32)
self._tree.setColumnWidth(1, 360)
self._tree.header().setStretchLastSection(False)
# --- buttons -----------------------------------------------------
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
export_btn = QPushButton("Copy to clipboard")
export_btn.clicked.connect(self._copy_text)
btn_row = QHBoxLayout()
btn_row.addWidget(export_btn)
btn_row.addStretch(1)
btn_row.addWidget(close_btn)
layout = QVBoxLayout(self)
layout.addWidget(picker_group)
layout.addWidget(self._summary)
layout.addWidget(self._tree, 1)
layout.addLayout(btn_row)
# ------------------------------------------------------------------ helpers
def _pick_file(self, edit: QLineEdit, title: str) -> None:
path, _ = QFileDialog.getOpenFileName(
self, title, "", "ISO images (*.iso *.bin);;All files (*)")
if path:
edit.setText(path)
def set_images(self, a: str, b: str) -> None:
"""Pre-fill the two image paths (used when launched from the menu)."""
self._a_edit.setText(a or "")
self._b_edit.setText(b or "")
if a and b:
self._do_compare()
# ------------------------------------------------------------------ compare
def _do_compare(self) -> None:
a_path = self._a_edit.text().strip()
b_path = self._b_edit.text().strip()
if not a_path or not b_path:
self._summary.setText("Pick both images first.")
return
if a_path == b_path:
self._summary.setText("Pick two different images.")
return
try:
self._ha.open(a_path)
self._hb.open(b_path)
except Exception as exc: # noqa: BLE001 -- user-facing error boundary
self._summary.setText(f"Error: {exc}")
return
nt = self._view.currentData()
try:
result = diff_images(self._ha, self._hb, name_type=nt)
except Exception as exc: # noqa: BLE001 -- user-facing error boundary
self._summary.setText(f"Diff failed: {exc}")
return
self._populate(result)
self._summary.setText(
f"{os.path.basename(a_path)}{os.path.basename(b_path)}: "
f"<b style='color:#047857'>+{result.added_count}</b> "
f"<b style='color:#b91c1c'>-{result.removed_count}</b> "
f"<b style='color:#b45309'>M {result.modified_count}</b> "
f"<span style='color:#6b7280'>= {result.same_count}</span>"
)
def _populate(self, result: DiffResult) -> None:
self._tree.clear()
show_all = self._show_all.isChecked()
for e in result.entries:
if not show_all and e.status == DiffStatus.SAME:
continue
self._add_entry(e)
def _add_entry(self, e: DiffEntry) -> None:
label, color = _STATUS_STYLE[e.status]
name = e.path
if e.is_dir:
name += "/"
item = QTreeWidgetItem([label, name, "", "", "", ""])
item.setForeground(0, color)
f = QFont()
f.setBold(True)
item.setFont(0, f)
item.setForeground(1, color if e.status != DiffStatus.SAME else QColor("#374151"))
if e.a:
item.setText(2, _human_size(e.a.size) if not e.a.is_dir else "<DIR>")
item.setText(4, e.a.date_label)
if e.b:
item.setText(3, _human_size(e.b.size) if not e.b.is_dir else "<DIR>")
item.setText(5, e.b.date_label)
self._tree.addTopLevelItem(item)
# ------------------------------------------------------------------ export
def _copy_text(self) -> None:
from ..diff import format_diff_text
if not self._ha.is_open or not self._hb.is_open:
return
result = diff_images(self._ha, self._hb)
QApplication.clipboard().setText(format_diff_text(result))
self._summary.setText("Diff copied to clipboard.")
# ------------------------------------------------------------------ cleanup
def closeEvent(self, event):
if self._ha.is_open:
self._ha.close()
if self._hb.is_open:
self._hb.close()
super().closeEvent(event)
def _wrap(layout) -> QWidget:
w = QWidget()
w.setLayout(layout)
return w