Skip to content

Commit 24c7744

Browse files
Use xxHash comparison to check for unsaved changes
1 parent 73488e7 commit 24c7744

4 files changed

Lines changed: 216 additions & 8 deletions

File tree

‎NOTICE.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4046,6 +4046,35 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
40464046
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
40474047
```
40484048

4049+
## xxhash 3.8.1
4050+
4051+
**License:** UNKNOWN
4052+
4053+
```
4054+
Copyright (c) 2014-2024, Yue Du
4055+
All rights reserved.
4056+
4057+
Redistribution and use in source and binary forms, with or without modification,
4058+
are permitted provided that the following conditions are met:
4059+
4060+
* Redistributions of source code must retain the above copyright notice,
4061+
this list of conditions and the following disclaimer.
4062+
* Redistributions in binary form must reproduce the above copyright notice,
4063+
this list of conditions and the following disclaimer in the documentation
4064+
and/or other materials provided with the distribution.
4065+
4066+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
4067+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
4068+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
4069+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
4070+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
4071+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
4072+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
4073+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
4074+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
4075+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
4076+
```
4077+
40494078
## zstandard 0.25.0
40504079

40514080
**License:** UNKNOWN

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ dependencies = [
2929
"zxcvbn>=4.5.0",
3030
# Zstandard compression inside MORGUL payloads (BSD).
3131
"zstandard>=0.22.0",
32+
# Fast non-cryptographic hash for dirty-tracking (MIT).
33+
"xxhash>=3.0.0",
3234
]
3335

3436
[project.scripts]

‎src/morgul/app.py‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
from typing import TYPE_CHECKING
99

10+
import xxhash
1011
from PySide6.QtCore import (
1112
QEvent,
1213
QObject,
@@ -983,6 +984,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
983984
self.path: Path | None = None
984985
self.password: str | None = None # session key; None = plaintext file
985986
self.dirty = False
987+
self._baseline_hash: int = 0
986988
self.wrap_on = True
987989
self.preview_on = True
988990
self.session_id = new_tab_id()
@@ -1031,6 +1033,38 @@ def dispose(self) -> None:
10311033
self._editor_caret.dispose()
10321034
self._preview_caret.dispose()
10331035

1036+
def _hash_state(self, text: str) -> int:
1037+
"""Hash covering content and encryption state.
1038+
1039+
Returns:
1040+
64-bit xxHash integer.
1041+
"""
1042+
marker = "\x00P" if self.password else "\x00-"
1043+
return xxhash.xxh3_64((text + marker).encode("utf-8")).intdigest()
1044+
1045+
def _current_hash(self) -> int:
1046+
"""Hash of the current editor content + encryption state.
1047+
1048+
Returns:
1049+
64-bit xxHash integer for the live editor state.
1050+
"""
1051+
return self._hash_state(self.editor.toPlainText())
1052+
1053+
def _set_baseline(self, text: str) -> None:
1054+
"""Record the hash representing the last saved/loaded state."""
1055+
self._baseline_hash = self._hash_state(text)
1056+
1057+
def _sync_dirty_to_hash(self) -> None:
1058+
"""Recompute dirty from hash comparison; emit meta_changed on change."""
1059+
current = self._current_hash()
1060+
if current != self._baseline_hash:
1061+
if not self.dirty:
1062+
self.dirty = True
1063+
self.meta_changed.emit()
1064+
elif self.dirty:
1065+
self.dirty = False
1066+
self.meta_changed.emit()
1067+
10341068
def tab_label(self) -> str:
10351069
"""Short name for the tab bar (with dirty star).
10361070
@@ -1098,10 +1132,9 @@ def _on_text_changed(self) -> None:
10981132
self.editor.toPlainText(),
10991133
self.editor.textCursor().position(),
11001134
)
1101-
if not self.dirty:
1102-
self.dirty = True
1103-
self.meta_changed.emit()
1104-
elif self.path is None and not self.locked:
1135+
if not self._applying_history and not self.locked:
1136+
self._sync_dirty_to_hash()
1137+
if self.path is None and not self.locked:
11051138
# Untitled tab: title reflects the first line, so refresh live.
11061139
self.meta_changed.emit()
11071140
if self._sync_preview_from_source:
@@ -1117,8 +1150,7 @@ def apply_history_frame(self, frame: EditFrame) -> None:
11171150
self.editor.setTextCursor(cursor)
11181151
finally:
11191152
self._applying_history = False
1120-
self.dirty = True
1121-
self.meta_changed.emit()
1153+
self._sync_dirty_to_hash()
11221154
self.refresh_preview()
11231155

11241156
def apply_source_edit(self, start: int, end: int, text: str) -> None:
@@ -1202,13 +1234,14 @@ def load_text(
12021234
self.locked_blob = None
12031235
self.editor.setReadOnly(False)
12041236
self.editor.setPlaceholderText("")
1237+
self.path = path
1238+
self.password = password
1239+
self._set_baseline(text)
12051240
self._applying_history = True
12061241
try:
12071242
self.editor.setPlainText(text)
12081243
finally:
12091244
self._applying_history = False
1210-
self.path = path
1211-
self.password = password
12121245
self.dirty = False
12131246
self.history.seed(text, self.editor.textCursor().position())
12141247
self.refresh_preview()
@@ -1223,6 +1256,7 @@ def apply_payload(self, payload: TabPayload, *, password: str | None) -> None:
12231256
self.path = Path(payload.path) if payload.path else None
12241257
self.password = password
12251258
self.dirty = payload.dirty
1259+
self._set_baseline(self.history.current.text)
12261260
self.set_wrap(on=payload.wrap_on)
12271261
self.set_preview(on=payload.preview_on)
12281262
self._restore_scroll = payload.scroll
@@ -1254,6 +1288,7 @@ def set_locked(self, blob: bytes, *, path: Path | None, dirty: bool) -> None:
12541288
self.editor.setPlainText("")
12551289
finally:
12561290
self._applying_history = False
1291+
self._baseline_hash = self._hash_state("")
12571292
self.editor.setReadOnly(True)
12581293
self.editor.setPlaceholderText("Password required — switch here to unlock.")
12591294
self.preview.setHtml("")
@@ -2215,6 +2250,7 @@ def _save(self) -> None:
22152250
QMessageBox.warning(self, "Morgul", f"Could not save file:\n{exc}")
22162251
return
22172252
self._add_recent(tab.path)
2253+
tab._set_baseline(tab.editor.toPlainText()) # ruff: ignore[private-member-access]
22182254
tab.dirty = False
22192255
self.tab_meta_changed()
22202256

@@ -2252,6 +2288,7 @@ def _save_as(self) -> None:
22522288
return
22532289
tab.path = path
22542290
self._add_recent(path)
2291+
tab._set_baseline(tab.editor.toPlainText()) # ruff: ignore[private-member-access]
22552292
tab.dirty = False
22562293
self.tab_meta_changed()
22572294

0 commit comments

Comments
 (0)