From 471a24a29701f885e497131dc3999a895c924cc9 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:18:11 +0300 Subject: [PATCH 1/3] Skip dirtying an entry when a tab save changes nothing A tab flagged unsaved (e.g. edit-then-revert, or an encode round-trip that yields identical bytes) still called edit_file, marking the entry modified and forcing a full repack on the next save. Compare the new bytes against the stored content first and only edit when they differ. --- src/tabs/cah_tab.py | 2 +- src/tabs/generic_tab.py | 12 ++++++++++++ src/tabs/map_tab.py | 2 +- src/tabs/text_tab.py | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/tabs/cah_tab.py b/src/tabs/cah_tab.py index 0845146..e41c980 100644 --- a/src/tabs/cah_tab.py +++ b/src/tabs/cah_tab.py @@ -338,7 +338,7 @@ def save(self): # Generate bytes and save cah_bytes = self.hero.write() - self.archive.edit_file(self.name, cah_bytes) + self.commit_to_archive(cah_bytes) super().save() except Exception as e: QMessageBox.critical(self, "Error", str(e)) diff --git a/src/tabs/generic_tab.py b/src/tabs/generic_tab.py index d448770..1ff54a5 100644 --- a/src/tabs/generic_tab.py +++ b/src/tabs/generic_tab.py @@ -87,6 +87,18 @@ def save(self): self.main.tabs.setTabText(self.main.tabs.currentIndex(), self.name) self.main.update_archive_name() + def commit_to_archive(self, content: bytes): + """Push edited content into the archive, but only mark the entry + modified when the bytes actually changed. Marking an unchanged entry + dirties the archive and forces a full repack on the next save, so a + no-op edit (open + save, or edit-then-revert) would needlessly rewrite + the whole BIG. + """ + if self.archive.file_exists(self.name) and self.archive.read_file(self.name) == content: + return + + self.archive.edit_file(self.name, content) + def search(self): pass diff --git a/src/tabs/map_tab.py b/src/tabs/map_tab.py index fab119a..1f75556 100644 --- a/src/tabs/map_tab.py +++ b/src/tabs/map_tab.py @@ -32,7 +32,7 @@ def generate_layout(self, preview=False): def save(self): with open(self.path, "rb") as f: data = f.read() - self.archive.edit_file(self.name, data) + self.commit_to_archive(data) self.data = data super().save() diff --git a/src/tabs/text_tab.py b/src/tabs/text_tab.py index 8066aa9..6a1324a 100644 --- a/src/tabs/text_tab.py +++ b/src/tabs/text_tab.py @@ -461,7 +461,7 @@ def save(self): data = self.text_widget.text() string = encode_string(data, self.main.settings.encoding) - self.archive.edit_file(self.name, string) + self.commit_to_archive(string) super().save() From 138e0b9a0d0d031243402dd987ad78e02bc9f254 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:18:33 +0300 Subject: [PATCH 2/3] Treat identical re-adds as unchanged instead of overwriting Adding a file whose name already exists reported it as overwritten and re-wrote the entry even when the bytes matched, dirtying the archive and forcing a full repack. Compare incoming bytes against the stored content in add_file_to_archive and _merge_archives; matching content yields a new AddOutcome.UNCHANGED that leaves the archive untouched. The add summary now reports the unchanged count and lists them in the details. --- src/main.py | 142 ++++++++++++++++++++++++++++++++++------------------ src/misc.py | 16 ++++-- 2 files changed, 106 insertions(+), 52 deletions(-) diff --git a/src/main.py b/src/main.py index 731dcce..0c8f6c1 100644 --- a/src/main.py +++ b/src/main.py @@ -124,11 +124,16 @@ class AddOutcome(Enum): ``OVERWRITTEN_ALL`` is an overwrite where the user also asked to apply the choice to every following file ("Yes to All"); loops use it to stop prompting, keeping the Qt button enum out of caller code. + + ``UNCHANGED`` means an entry with that name already held byte-identical + content, so the add was a no-op: the archive is left clean (not dirtied) + so a later save can skip the full repack. """ NEW = auto() OVERWRITTEN = auto() OVERWRITTEN_ALL = auto() + UNCHANGED = auto() SKIPPED = auto() FAILED = auto() @@ -731,15 +736,17 @@ def merge_archives(self): files.reverse() new_names = [] overwritten_names = [] + unchanged_names = [] for file in files: - new, overwritten = self._merge_archives(file) + new, overwritten, unchanged = self._merge_archives(file) new_names.extend(new) overwritten_names.extend(overwritten) + unchanged_names.extend(unchanged) self.settings.last_dir = os.path.dirname(files[0]) self.listwidget.add_files(new_names + overwritten_names) self.update_archive_name() - self._show_add_summary(new_names, overwritten_names) + self._show_add_summary(new_names, overwritten_names, unchanged_names) def _merge_archives(self, path): if self.settings.large_archive: @@ -751,6 +758,7 @@ def _merge_archives(self, path): skip_all = False new_names = [] overwritten_names = [] + unchanged_names = [] files = archive.file_list() length = len(files) text_box = QMessageBox( @@ -771,7 +779,6 @@ def _merge_archives(self, path): f"File: ({index + 1}/{length})
Processing: {file}" ) QApplication.processEvents() - overwrote = False if self.archive.file_exists(file): default = ( OverwriteDefault.OVERWRITE @@ -780,6 +787,14 @@ def _merge_archives(self, path): ) if default is OverwriteDefault.SKIP: continue + + incoming = archive.read_file(file) + if self.archive.read_file(file) == incoming: + # Identical content already present: skip without dirtying + # the archive, so a later save can be avoided entirely. + unchanged_names.append(file) + continue + if default is OverwriteDefault.ASK: ret = QMessageBox.question( self, @@ -796,13 +811,10 @@ def _merge_archives(self, path): skip_all = True self.archive.remove_file(file) - overwrote = True - - self.archive.add_file(file, archive.read_file(file)) - - if overwrote: + self.archive.add_file(file, incoming) overwritten_names.append(file) else: + self.archive.add_file(file, archive.read_file(file)) new_names.append(file) size = self.archive.archive_memory_size() @@ -827,7 +839,7 @@ def _merge_archives(self, path): ) text_box.accept() - return new_names, overwritten_names + return new_names, overwritten_names, unchanged_names def new(self): self._new() @@ -944,6 +956,7 @@ def _add_folder(self, url, *, show_summary=True): common_dir = os.path.dirname(url) new_names = [] overwritten_names = [] + unchanged_names = [] for root, _, files in os.walk(url): for f in files: full_path = os.path.join(root, f) @@ -952,9 +965,10 @@ def _add_folder(self, url, *, show_summary=True): full_path, name, blank=False, skip_all=skip_all, undoable=False ) - new, overwritten = self._tally(outcome, name) + new, overwritten, unchanged = self._tally(outcome, name) new_names.extend(new) overwritten_names.extend(overwritten) + unchanged_names.extend(unchanged) if outcome is AddOutcome.OVERWRITTEN_ALL: skip_all = True @@ -962,52 +976,64 @@ def _add_folder(self, url, *, show_summary=True): self.listwidget.add_files(new_names + overwritten_names) self.update_archive_name() if show_summary: - self._show_add_summary(new_names, overwritten_names) - return new_names, overwritten_names + self._show_add_summary(new_names, overwritten_names, unchanged_names) + return new_names, overwritten_names, unchanged_names def add_file_to_archive(self, url, name, blank=False, skip_all=False, undoable=True): """Add ``url`` to the archive as ``name``. Returns an :class:`AddOutcome` describing what actually happened to the archive. ``OVERWRITTEN_ALL`` additionally tells loops to stop prompting. + ``UNCHANGED`` means the new bytes matched the existing entry, so the + archive was left untouched. """ replaced_data = None overwrite_all = skip_all existed = self.archive.file_exists(name) if existed: replaced_data = self.archive.read_file(name) - if not skip_all: - default = self.settings.add_overwrite_default - if default is OverwriteDefault.SKIP: - return AddOutcome.SKIPPED - if default is OverwriteDefault.OVERWRITE: - overwrite_all = True - else: - ret = QMessageBox.question( - self, - "Overwrite file?", - f"{name} already exists, overwrite?", - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No - | QMessageBox.StandardButton.YesToAll, - QMessageBox.StandardButton.No, - ) - if ret == QMessageBox.StandardButton.No: - return AddOutcome.SKIPPED - overwrite_all = ret == QMessageBox.StandardButton.YesToAll - - self.archive.remove_file(name) + # Honour a "skip existing" default before reading the new bytes, so + # the no-overwrite workflow stays a pure no-op. + if not skip_all and self.settings.add_overwrite_default is OverwriteDefault.SKIP: + return AddOutcome.SKIPPED try: if blank: - self.archive.add_file(name, b"") + new_data = b"" else: with open(url, "rb") as f: - self.archive.add_file(name, f.read()) + new_data = f.read() except Exception as e: QMessageBox.warning(self, "Error", str(e)) return AddOutcome.FAILED + if existed: + if new_data == replaced_data: + # Re-adding identical content: leave the entry alone so the + # archive isn't dirtied and the next save can be skipped. + return AddOutcome.UNCHANGED + + if not skip_all and self.settings.add_overwrite_default is OverwriteDefault.ASK: + ret = QMessageBox.question( + self, + "Overwrite file?", + f"{name} already exists, overwrite?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.YesToAll, + QMessageBox.StandardButton.No, + ) + if ret == QMessageBox.StandardButton.No: + return AddOutcome.SKIPPED + overwrite_all = ret == QMessageBox.StandardButton.YesToAll + else: + # skip_all, or the OVERWRITE default: overwrite without asking. + overwrite_all = True + + self.archive.remove_file(name) + + self.archive.add_file(name, new_data) + if undoable: self.undo_stack.push(AddFileCommand(name, self.archive.read_file(name), replaced_data)) self.update_undo_redo_actions() @@ -1017,24 +1043,39 @@ def add_file_to_archive(self, url, name, blank=False, skip_all=False, undoable=T return AddOutcome.OVERWRITTEN_ALL if overwrite_all else AddOutcome.OVERWRITTEN @staticmethod - def _tally(outcome: AddOutcome, name: str) -> tuple[list[str], list[str]]: - """Map one add's outcome to a ``(new_names, overwritten_names)`` pair.""" + def _tally(outcome: AddOutcome, name: str) -> tuple[list[str], list[str], list[str]]: + """Map one add's outcome to a ``(new, overwritten, unchanged)`` triple.""" if outcome is AddOutcome.NEW: - return [name], [] + return [name], [], [] if outcome.overwrote: - return [], [name] - return [], [] - - def _show_add_summary(self, new_names: list[str], overwritten_names: list[str]) -> None: + return [], [name], [] + if outcome is AddOutcome.UNCHANGED: + return [], [], [name] + return [], [], [] + + def _show_add_summary( + self, + new_names: list[str], + overwritten_names: list[str], + unchanged_names: list[str] = None, + ) -> None: if not self.settings.show_add_summary: return - total = len(new_names) + len(overwritten_names) - if total == 0: + unchanged_names = unchanged_names or [] + added = len(new_names) + len(overwritten_names) + if added + len(unchanged_names) == 0: return message = ( - f"Added {total} file(s) — {len(new_names)} new, {len(overwritten_names)} overwritten." + f"Added {added} file(s) — {len(new_names)} new, {len(overwritten_names)} overwritten." ) - AddSummaryDialog(message, new_names, overwritten_names, parent=self).exec() + if unchanged_names: + message += ( + f" Skipped {len(unchanged_names)} unchanged file(s) " + "(identical content, archive not modified)." + ) + AddSummaryDialog( + message, new_names, overwritten_names, unchanged_names, parent=self + ).exec() def is_file_selected(self): if not self.listwidget.active_list.is_file_selected(): @@ -1411,6 +1452,7 @@ def dropEvent(self, event: QDropEvent): yes_to_all = False new_names = [] overwritten_names = [] + unchanged_names = [] for url in md.urls(): local_file = url.toLocalFile() @@ -1430,17 +1472,19 @@ def dropEvent(self, event: QDropEvent): if suggested_name else self._add_file(local_file, ask_name=not yes_to_all, show_summary=False) ) - new, names = self._tally(outcome, name) + new, names, unchanged = self._tally(outcome, name) new_names.extend(new) overwritten_names.extend(names) + unchanged_names.extend(unchanged) if outcome is AddOutcome.OVERWRITTEN_ALL: yes_to_all = True else: - new, names = self._add_folder(local_file, show_summary=False) + new, names, unchanged = self._add_folder(local_file, show_summary=False) new_names.extend(new) overwritten_names.extend(names) + unchanged_names.extend(unchanged) - self._show_add_summary(new_names, overwritten_names) + self._show_add_summary(new_names, overwritten_names, unchanged_names) event.acceptProposedAction() diff --git a/src/misc.py b/src/misc.py index 1dc8f4f..bdf05af 100644 --- a/src/misc.py +++ b/src/misc.py @@ -268,8 +268,10 @@ def __init__( message: str, new_names: list[str], overwritten_names: list[str], + unchanged_names: list[str] = None, parent=None, ): + unchanged_names = unchanged_names or [] super().__init__(parent) self.setWindowTitle("Files added") self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True) @@ -291,7 +293,7 @@ def __init__( header.addWidget(label, 1) layout.addLayout(header) - details_text = self._build_details(new_names, overwritten_names) + details_text = self._build_details(new_names, overwritten_names, unchanged_names) self.details = QTextEdit() self.details.setReadOnly(True) @@ -311,11 +313,14 @@ def __init__( self.resize(420, 160) @staticmethod - def _build_details(new_names: list[str], overwritten_names: list[str]) -> str: - """New files first, then a separator, then overwritten files. + def _build_details( + new_names: list[str], overwritten_names: list[str], unchanged_names: list[str] = None + ) -> str: + """New files first, then overwritten, then unchanged, separated by rules. Empty sections are omitted. """ + unchanged_names = unchanged_names or [] sections = [] if new_names: sections.append( @@ -326,6 +331,11 @@ def _build_details(new_names: list[str], overwritten_names: list[str]) -> str: f"Overwritten files ({len(overwritten_names)}):\n" + "\n".join(f" {name}" for name in overwritten_names) ) + if unchanged_names: + sections.append( + f"Unchanged files ({len(unchanged_names)}):\n" + + "\n".join(f" {name}" for name in unchanged_names) + ) return ("\n" + "─" * 40 + "\n").join(sections) def _toggle_details(self, shown: bool) -> None: From ebb74fa88e89aff053f91593bcaa5e9b0cecc4a5 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:18:53 +0300 Subject: [PATCH 3/3] Skip the full repack when the archive has no changes Save unconditionally re-wrote the whole BIG even with nothing modified. When writing back to the same file and modified_entries is empty, skip the repack entirely. Save As still always writes to its new path. --- src/main.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main.py b/src/main.py index 0c8f6c1..a2acb68 100644 --- a/src/main.py +++ b/src/main.py @@ -562,6 +562,17 @@ def _save(self, path): if tab.unsaved: tab.save() + # A BIG save is a full repack of the entire archive. When we're writing + # back over the same file with nothing pending, the result is identical + # to what's already on disk, so skip the rewrite. Save As targets a new + # path, so it always writes. + writing_in_place = self.path is not None and os.path.normcase( + os.path.abspath(path) + ) == os.path.normcase(os.path.abspath(self.path)) + if writing_in_place and not self.archive.modified_entries: + QMessageBox.information(self, "Done", "No changes to save.") + return True + try: self.archive.save(path) QMessageBox.information(self, "Done", "Archive has been saved")