diff --git a/doorstop/gui/application.py b/doorstop/gui/application.py index 83c20437e..31f1aba78 100644 --- a/doorstop/gui/application.py +++ b/doorstop/gui/application.py @@ -5,6 +5,7 @@ """Graphical interface for Doorstop.""" import functools +import hashlib import logging import sys from itertools import chain @@ -17,10 +18,11 @@ try: import tkinter as tk - from tkinter import filedialog, ttk + from tkinter import filedialog, messagebox, ttk except ImportError as _exc: sys.stderr.write("WARNING: {}\n".format(_exc)) tk = Mock() + messagebox = Mock() ttk = Mock() @@ -55,6 +57,7 @@ def __init__(self, root, cwd, project): self.tree = None self.document = None self.item = None + self.item_file_fingerprint = None # Create string variables self.stringvar_project = tk.StringVar(value=project or "") @@ -521,11 +524,15 @@ def display_item(self, *_): uid = self.stringvar_item.get() if uid == "": self.item = None + self.item_file_fingerprint = None else: try: self.item = self.tree.find_item(uid) except DoorstopError: pass + else: + self.item.load(reload=True) + self.item_file_fingerprint = self._item_file_fingerprint() log.info("displaying item {}...".format(self.item)) if uid != "": @@ -643,6 +650,17 @@ def update_item(self, *_): logging.warning("no item selected") return + if self._item_changed_on_disk(): + messagebox.showwarning( + "Item changed on disk", + "This item was modified outside Doorstop GUI. " + "The external version will be reloaded and this edit will not be saved.", + ) + self.item.load(reload=True) + self.item_file_fingerprint = self._item_file_fingerprint() + self.display_item() + return + # Update the current item log.info("updating {}...".format(self.item)) self.item.auto = False @@ -658,10 +676,25 @@ def update_item(self, *_): if name: self.item.set(name, self.stringvar_extendedvalue.get()) self.item.save() + self.item_file_fingerprint = self._item_file_fingerprint() # Re-select this item self.display_document() + def _item_file_fingerprint(self): + """Return a content fingerprint for the current item's file.""" + if self.item is None: + return None + try: + with open(self.item.path, "rb") as stream: + return hashlib.sha256(stream.read()).digest() + except OSError: + return None + + def _item_changed_on_disk(self): + """Return whether the selected item's file changed since it was displayed.""" + return self.item_file_fingerprint != self._item_file_fingerprint() + @_log def left(self): """Dedent the current item's level.""" diff --git a/doorstop/gui/tests/test_application.py b/doorstop/gui/tests/test_application.py new file mode 100644 index 000000000..5fe61ce60 --- /dev/null +++ b/doorstop/gui/tests/test_application.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# pylint: disable=protected-access + +"""Unit tests for the Doorstop GUI application.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from doorstop.gui.application import Application + + +def application_for(path: Path) -> Application: + """Create the portion of an application needed for file-state tests.""" + application = object.__new__(Application) + application.item = SimpleNamespace(path=str(path)) + application.item_file_fingerprint = None + return application + + +def test_item_file_fingerprint_changes_with_content(tmp_path): + """Verify item fingerprints reflect the complete on-disk content.""" + path = tmp_path / "REQ001.yml" + path.write_text("text: original\n", encoding="utf-8") + application = application_for(path) + + original = application._item_file_fingerprint() + path.write_text("text: external update\n", encoding="utf-8") + + assert original != application._item_file_fingerprint() + + +def test_item_changed_on_disk(tmp_path): + """Verify an external item edit is detected after display.""" + path = tmp_path / "REQ001.yml" + path.write_text("text: original\n", encoding="utf-8") + application = application_for(path) + application.item_file_fingerprint = application._item_file_fingerprint() + + assert not application._item_changed_on_disk() + + path.write_text("text: external update\n", encoding="utf-8") + + assert application._item_changed_on_disk() + + +def test_missing_item_file_has_no_fingerprint(tmp_path): + """Verify a missing item file does not raise an exception.""" + application = application_for(tmp_path / "REQ001.yml") + + assert application._item_file_fingerprint() is None + + +@patch("doorstop.gui.application.messagebox.showwarning") +def test_external_change_blocks_save(showwarning, tmp_path): + """Verify a stale GUI item cannot overwrite an external edit.""" + path = tmp_path / "REQ001.yml" + path.write_text("text: original\n", encoding="utf-8") + application = application_for(path) + application.ignore = False + application.item = Mock(path=str(path)) + application.item_file_fingerprint = b"stale" + application.display_item = Mock() + + application.update_item() + + showwarning.assert_called_once() + application.item.load.assert_called_once_with(reload=True) + application.item.save.assert_not_called() + application.display_item.assert_called_once_with()