Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ project uses [semantic versioning][semver].
the repository. The desktop app now uses the same mark as its window icon,
built in memory, so the shell entry, the window and the report header finally
agree on what this tool looks like.
- **A file list beside the queue, showing both checksums.** The interface could
say a job was "Verified" but never showed the evidence — the source hash and
the destination's, per file — which was only in the CSV and the PDF. The
selected job's files now sit next to the queue: size, both hashes head:tail
(`3F2A:05D1`, because a prefix cannot distinguish a match from a near-miss),
the full values and every destination path in the tooltip, and a doubled
check mark for a copy that was written *and* read back. Destinations that
disagree with one another read `mismatch` rather than showing the first and
hiding the rest.
- **A stall is reported instead of looking like a slow link.** A hung network
handle raises nothing — it just stops returning bytes — so nothing could be
retried and the job sat at a stale throughput figure. Source reads are now
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,16 @@ destination file rather than leaving something that looks complete. A source
that stops delivering bytes reads as `Stalled on …` with the time since the
last one, rather than a throughput figure that has quietly stopped being true.

Beside it, the **file list** for the selected job: every file with its size,
the source checksum and the destination's next to each other, and a doubled
check mark for a copy that was both written and read back. Hashes are shown
head:tail (`3F2A:05D1`) because a prefix alone cannot tell a matching pair from
a near-miss; the full values are in the tooltip, along with every destination
path. Destinations that disagree with each other read `mismatch` — both copies
came from one read, so they cannot legitimately differ. This is the same
evidence the CSV and the PDF carry, in front of the person deciding whether to
reformat the card.

Two guards run before anything is queued:

- **Duplicate offload protection.** The source's file listing — names and sizes,
Expand Down
Binary file modified docs/images/app-preset-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/app-preset-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/app-simple-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ select = ["F", "E9", "I", "B", "UP"]
# Qt's model API is defined with QModelIndex() defaults; matching the framework
# signature matters more here than the general rule against call-in-default.
"src/offloader/gui/queue_view.py" = ["B008"]
"src/offloader/gui/file_list.py" = ["B008"]

[tool.ruff.lint.isort]
known-first-party = ["offloader"]
Expand Down
239 changes: 239 additions & 0 deletions src/offloader/gui/file_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Per-file detail for one job: what landed, and what it hashed to.

The queue answers "did the job succeed". This answers "which file, and prove
it" — the source checksum and the destination's beside each other, which is the
whole claim the tool makes and the one thing the interface never showed. It is
in the CSV and the PDF; an operator deciding whether to erase a card should not
have to open a report to see it.

Hashes are abbreviated head:tail rather than truncated, because the first four
characters alone cannot distinguish a matching pair from a mismatched one that
happens to share a prefix. The full value is in the tooltip.
"""

from __future__ import annotations

from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QAbstractItemView,
QHeaderView,
QTableView,
QVBoxLayout,
QWidget,
)

from ..models import FileStatus, Job
from ..util import format_size
from . import theme
from .widgets import label, row

COLUMNS = ("", "File", "Size", "Source", "Destination")
COL_MARK = 0
COL_SIZE = 2
COL_SOURCE = 3
COL_DESTINATION = 4

#: One glyph per verdict. Doubled for a verified copy, because that is two
#: separate facts — it was written, and it was read back and matched.
MARKS = {
FileStatus.VERIFIED: "✓✓",
FileStatus.COPIED: "✓",
FileStatus.FAILED: "✕",
FileStatus.SKIPPED: "–",
FileStatus.CANCELLED: "⊘",
}


def abbreviate(checksum: str | None) -> str:
"""`28f7e67d9b39ea9b` as `28F7:EA9B`.

Head *and* tail: a prefix on its own is not enough to tell a matching pair
from a near-miss, which is the only thing these two columns exist to show.
"""
if not checksum:
return "—"
clean = checksum.strip().upper()
if len(clean) <= 9:
return clean
return f"{clean[:4]}:{clean[-4:]}"


def _destination_checksum(entry) -> tuple[str, bool]:
"""The destinations' checksum and whether they all agree with each other.

Several destinations are written from the same read, so a disagreement
between them is a real finding and must not be hidden by showing only the
first one.
"""
sums = [d.checksum for d in entry.destinations if d.checksum]
if not sums:
return "—", True
first = sums[0]
return first, all(value == first for value in sums)


class FileModel(QAbstractTableModel):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._job: Job | None = None

def set_job(self, job: Job | None) -> None:
self.beginResetModel()
self._job = job
self.endResetModel()

@property
def job(self) -> Job | None:
return self._job

@property
def files(self) -> list:
return list(self._job.files) if self._job is not None else []

# ---------------------------------------------------------------- Qt API
def rowCount(self, parent=QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.files)

def columnCount(self, parent=QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(COLUMNS)

def headerData(self, section, orientation, role=Qt.DisplayRole): # noqa: N802
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return COLUMNS[section]
return None

def data(self, index: QModelIndex, role=Qt.DisplayRole):
if not index.isValid():
return None
entry = self.files[index.row()]
column = index.column()
destination, agree = _destination_checksum(entry)
matched = bool(entry.checksum) and destination == entry.checksum

if role == Qt.DisplayRole:
return (
MARKS.get(entry.status, "?"),
str(entry.relative),
format_size(entry.size),
abbreviate(entry.checksum),
abbreviate(destination) if agree else "mismatch",
)[column]

if role == Qt.ForegroundRole:
if column == COL_MARK:
return QColor(theme.status_color(entry.status.value.lower()))
if column == COL_DESTINATION:
# Green only when this column has actually proved something:
# the two sides agree, and every destination agrees too.
if not agree:
return QColor(theme.status_color("failed"))
if matched:
return QColor(theme.status_color("verified"))
return None

if role == Qt.ToolTipRole:
lines = [str(entry.source)]
if entry.checksum:
lines.append(f"source: {entry.checksum}")
for destination_entry in entry.destinations:
lines.append(
f"{destination_entry.status.value.lower():<11} "
f"{destination_entry.checksum or '—'} "
f"{destination_entry.path}")
if destination_entry.error:
lines.append(f" error: {destination_entry.error}")
if entry.checksum and not matched and destination != "—":
lines.append("the destination does not match the source")
return "\n".join(lines)

if role == Qt.TextAlignmentRole:
if column == COL_MARK:
return int(Qt.AlignCenter)
if column == COL_SIZE:
return int(Qt.AlignRight | Qt.AlignVCenter)
return None


class FileListPanel(QWidget):
"""The selected job's files, or a line saying why there are none to show."""

def __init__(self, parent=None) -> None:
super().__init__(parent)
self.model = FileModel(self)

self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setAlternatingRowColors(True)
self.table.verticalHeader().setVisible(False)
self.table.setShowGrid(False)
self.table.verticalHeader().setDefaultSectionSize(26)

header = self.table.horizontalHeader()
header.setSectionResizeMode(COL_MARK, QHeaderView.Fixed)
header.resizeSection(COL_MARK, 34)
header.setSectionResizeMode(1, QHeaderView.Stretch)
for column in (COL_SIZE, COL_SOURCE, COL_DESTINATION):
header.setSectionResizeMode(column, QHeaderView.Fixed)
header.resizeSection(COL_SIZE, 76)
# Wide enough for "mismatch" as well as a head:tail pair, so the column
# does not jump width when a job goes wrong. Kept as tight as that
# allows, because everything here is width the file name does not get —
# and a clip name truncated to "A002_08…" identifies nothing.
header.resizeSection(COL_SOURCE, 90)
header.resizeSection(COL_DESTINATION, 90)

self._title = label("Files", "heading")
self._summary = label("", "muted")
self._empty = label(self.DEFAULT_MESSAGE, "muted")

layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(row(self._title, 12, self._summary, None))
layout.addWidget(self._empty)
layout.addWidget(self.table, 1)
self.show_job(None, None)

DEFAULT_MESSAGE = "Select a job to see the files it copied."

def show_job(self, job: Job | None, pending: str | None = None) -> None:
"""Display `job`'s files, or `pending` when there are none yet."""
message = pending or self.DEFAULT_MESSAGE
if job is self.model.job:
# The same job object cannot have grown rows — the engine hands the
# job over once, finished. Only the waiting message can change, and
# rebuilding here would reset the table on every progress event of
# a selected running job.
if job is None:
self._empty.setText(message)
return

self.model.set_job(job)
has_rows = bool(job is not None and job.files)
self.table.setVisible(has_rows)
self._empty.setVisible(not has_rows)
if not has_rows:
self._empty.setText(message)
self._summary.setText("")
return
self._summary.setText(self._describe(job))

@staticmethod
def _describe(job: Job) -> str:
verified = sum(1 for entry in job.files
if entry.status is FileStatus.VERIFIED)
failed = sum(1 for entry in job.files
if entry.status is FileStatus.FAILED)
parts = [f"{len(job.files)} files",
format_size(job.total_bytes),
job.hash_label]
# Counted rather than inferred from the job's verdict: "Verified: 5 of
# 6" is the number that decides whether a card can be erased.
if verified:
parts.append(f"verified {verified} of {len(job.files)}")
if failed:
parts.append(f"{failed} failed")
return " · ".join(parts)
43 changes: 42 additions & 1 deletion src/offloader/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ..util import format_size
from . import theme
from .drives import DrivesPanel
from .file_list import FileListPanel
from .preset_mode import PresetModePanel
from .queue_view import QueuePanel, reveal
from .simple_mode import SimpleModePanel
Expand Down Expand Up @@ -72,6 +73,8 @@ def __init__(self, source: Path | None = None) -> None:
self.drives.useAsDestination.connect(self._add_destination)

self.queue = QueuePanel(self.controller)
self.files = FileListPanel()
self.queue.selectionChanged.connect(self._show_files)

# ----------------------------------------------------------- chrome
self._preset_button = button("Presets")
Expand Down Expand Up @@ -106,9 +109,24 @@ def __init__(self, source: Path | None = None) -> None:
upper.setStretchFactor(1, 1)
upper.setSizes([280, 900])

# Jobs on the left, the selected job's files on the right: the queue is
# the rail and this is its detail pane. Split rather than tabbed, so
# the proof of what landed is visible at the same time as the job it
# belongs to.
# Weighted towards the queue: its six columns include two fixed-width
# ones, and squeezing it clips the throughput figure off the right.
# The detail pane's columns are mostly fixed too, hence the floor.
self.files.setMinimumWidth(330)
lower = QSplitter(Qt.Horizontal)
lower.addWidget(self.queue)
lower.addWidget(self.files)
lower.setStretchFactor(0, 5)
lower.setStretchFactor(1, 2)
lower.setSizes([800, 480])

vertical = QSplitter(Qt.Vertical)
vertical.addWidget(upper)
vertical.addWidget(self.queue)
vertical.addWidget(lower)
vertical.setStretchFactor(0, 3)
vertical.setStretchFactor(1, 2)
vertical.setSizes([520, 300])
Expand Down Expand Up @@ -208,6 +226,29 @@ def _show_about(self) -> None:
"</span>",
)

def _show_files(self, item) -> None:
"""Follow the queue's selection with the file detail.

A job's files only exist once the engine hands the finished job back,
so until then the pane says which state it is waiting on rather than
sitting empty and looking broken.
"""
if item is None:
self.files.show_job(None)
elif item.job is not None:
self.files.show_job(item.job)
elif item.state.is_terminal:
# Terminal with nothing to show: cancelled or failed before the
# engine got as far as handing a job back. Saying it will appear
# once the job finishes would be a promise nothing will keep.
self.files.show_job(
None, f"{item.status_text} — no files were recorded."
+ (f"\n{item.error}" if item.error else ""))
else:
self.files.show_job(
None, f"{item.status_text} — the file list appears "
"once the job finishes.")

# ---------------------------------------------------------------- routing
def _set_source(self, path: Path) -> None:
self.simple.set_source(path)
Expand Down
8 changes: 7 additions & 1 deletion src/offloader/gui/queue_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
from pathlib import Path

from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QAbstractItemView,
Expand Down Expand Up @@ -230,6 +230,11 @@ def reveal(path: Path) -> None:
class QueuePanel(QWidget):
"""Queue table plus the transport controls that act on the selection."""

#: The selected job, or None. Emitted on selection *and* on any change to
#: an item, so a detail view following it sees a job's files the moment it
#: finishes rather than when the operator next clicks.
selectionChanged = Signal(object)

def __init__(self, controller: QueueController, parent=None) -> None:
super().__init__(parent)
self.controller = controller
Expand Down Expand Up @@ -363,6 +368,7 @@ def _sync_buttons(self) -> None:
self._remove.setEnabled(terminal or queued)
self._reports.setEnabled(bool(item and item.reports))
self._clear.setEnabled(any(i.state.is_terminal for i in self.controller.items))
self.selectionChanged.emit(item)

# ---------------------------------------------------------------- actions
def _toggle_pause(self) -> None:
Expand Down
Loading
Loading