"""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 # # 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 # 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"+{result.added_count} " f"-{result.removed_count} " f"M {result.modified_count} " f"= {result.same_count}" ) 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 "") 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 "") 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