234 lines
8.5 KiB
Python
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
|