Skip to content
Merged
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
35 changes: 34 additions & 1 deletion doorstop/gui/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""Graphical interface for Doorstop."""

import functools
import hashlib
import logging
import sys
from itertools import chain
Expand All @@ -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()


Expand Down Expand Up @@ -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 "")
Expand Down Expand Up @@ -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 != "":
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
70 changes: 70 additions & 0 deletions doorstop/gui/tests/test_application.py
Original file line number Diff line number Diff line change
@@ -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()
Loading