77from pathlib import Path
88from typing import TYPE_CHECKING
99
10+ import xxhash
1011from 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 = "\x00 P" 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