Skip to content

Commit d528a85

Browse files
author
Lukas Geiger
committed
Preserve editor files when saving fails
Signed-off-by: Lukas Geiger <lukas@um-bruch.org>
1 parent 4116f36 commit d528a85

5 files changed

Lines changed: 128 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ Alle wesentlichen Änderungen an CodeBox werden hier dokumentiert.
1212

1313
## [Unreleased]
1414

15+
### Fixed (2026-10-02: Editor-Dateien bei Speicherfehlern erhalten)
16+
- Editor-Tabs kodieren UTF-8 vor dem Öffnen der Ausgabe und verwenden QSaveFile ohne direkten Schreib-Fallback. Teilwrites und fehlgeschlagene Commits überschreiben den bisherigen Code nicht.
17+
- Ungültiges UTF-8 wird als Speicherfehler gemeldet; geänderte Dokumente und geteilte Ansichten werden erst nach erfolgreichem Commit als unverändert markiert.
18+
- Regressionen prüfen Öffnungs-, Schreib- und Commitfehler, leere Dokumente, native Zeilenenden und geteilte Dokumente. Grenzen stehen in SAVE_SAFETY.md; Version und bestehender EXE-Build bleiben unverändert.
19+
1520
### Added (I18N Expansion & Tier-2 6-Sprachen-Architektur 2026-09-30)
1621
- **Policy P-006 Tier-2 6-Sprachen-Standard (`translator.py`, `manage_translations.py`, `locales/translations.json`)**:
1722
- `translator.py`: Vollwertiges `TranslationSystem` v2.0 mit 6 Standardsprachen (`de`, `en`, `es`, `zh`, `ja`, `ru`), deterministischer 4-Stufen-Fallback-Kette (`target -> en -> de -> key`), Systemsprachenerkennung (`detect_system_language()`), thread-sicherem Singleton `get_translator()` und globalem Helper `t(key, **kwargs)` mit kwargs-Interpolation und Fehlerisolation.

‎SAVE_SAFETY.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Speichern von Editor-Dateien
2+
3+
`EditorTab.save()` kodiert den vollständigen Text vor dem Öffnen einer Ausgabe
4+
als UTF-8. Die bisherige Umwandlung in native Zeilenenden bleibt erhalten.
5+
Qt `QSaveFile` schreibt einen privaten Zwischenstand und übernimmt ihn erst,
6+
wenn Schreiben und `commit()` erfolgreich waren. Der direkte Schreib-Fallback
7+
ist ausdrücklich deaktiviert; bei eingeschränkten Verzeichnisrechten kann das
8+
Speichern deshalb fehlschlagen, obwohl direktes Überschreiben möglich wäre.
9+
10+
Fehler beim Öffnen, Teilwrites, Schreibfehler, ungültiges UTF-8 und ein
11+
fehlgeschlagenes Commit melden einen Fehler und lassen den bisherigen
12+
Dateiinhalt bestehen. Das Dokument bleibt geändert; Schließen und Ausführen
13+
nutzen weiterhin den bestehenden Erfolgsstatus. Geteilte Ansichten werden
14+
erst nach erfolgreichem Speichern gemeinsam als unverändert markiert.
15+
Auch ein bewusst leeres Dokument darf erfolgreich gespeichert werden.
16+
17+
Diese Änderung betrifft lokale Editor-Dateien. Sie ist keine Abnahme von
18+
SFTP-Uploads, Workspace-/Snippet-/Einstellungsspeichern, Datei-Lade-Encoding,
19+
parallelen externen Änderungen, einer In-place-Erhaltung von Hardlinks oder
20+
mehrteiligen Transaktionen. Ein neuer
21+
EXE-Build und Geräte-/Office-/Store-Prüfungen sind nicht Teil der Quelländerung.
22+
23+
API-Verhalten: [Qt-QSaveFile-Dokumentation](https://doc.qt.io/qtforpython-6/PySide6/QtCore/QSaveFile.html).

‎core/tabs.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
from __future__ import annotations
66

7+
import os
78
from pathlib import Path
89
from typing import Optional
910

1011
from PySide6.QtWidgets import QTabWidget, QMessageBox
11-
from PySide6.QtCore import Signal
12+
from PySide6.QtCore import Signal, QIODevice, QSaveFile
1213
from PySide6.QtGui import QTextDocument
1314

1415
from .editor import CodeEditor
@@ -78,11 +79,23 @@ def _on_modified(self, modified):
7879
def save(self) -> bool:
7980
"""Speichert die Datei"""
8081
if self.file_path:
82+
output = None
8183
try:
82-
self.file_path.write_text(
83-
self.editor.toPlainText(), encoding='utf-8'
84-
)
85-
except OSError as e:
84+
# Preserve the native newline conversion of Path.write_text, but
85+
# validate UTF-8 before opening any output device.
86+
payload = self.editor.toPlainText().replace('\n', os.linesep).encode('utf-8')
87+
output = QSaveFile(str(self.file_path))
88+
output.setDirectWriteFallback(False)
89+
if not output.open(QIODevice.OpenModeFlag.WriteOnly):
90+
raise OSError(output.errorString())
91+
if output.write(payload) != len(payload):
92+
raise OSError(output.errorString() or 'Datei nicht vollständig geschrieben')
93+
if not output.commit():
94+
raise OSError(output.errorString())
95+
except (OSError, UnicodeError) as e:
96+
if output is not None and output.isOpen():
97+
output.cancelWriting()
98+
output.commit() # Discard and close the private output.
8699
QMessageBox.critical(None, "Speichern fehlgeschlagen",
87100
f"Konnte nicht speichern:\n{e}")
88101
return False

‎tests/test_editor_atomic_save.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Verify target preservation and document state at the real Qt save boundary."""
2+
import os
3+
4+
import pytest
5+
from PySide6.QtCore import QSaveFile
6+
from PySide6.QtWidgets import QApplication, QMessageBox
7+
8+
import core.tabs as tabs
9+
10+
11+
@pytest.fixture
12+
def document(tmp_path, monkeypatch):
13+
app = QApplication.instance() or QApplication([])
14+
target = tmp_path / 'Grüße.py'
15+
target.write_bytes(b'original code\r\n')
16+
tab = tabs.EditorTab(target)
17+
tab.editor.setPlainText("print('Grüße')\n")
18+
tab.editor.document().setModified(True)
19+
errors = []
20+
monkeypatch.setattr(QMessageBox, 'critical', lambda *args: errors.append(args))
21+
yield tab, target, errors, app
22+
tab.editor.close()
23+
24+
25+
@pytest.mark.parametrize('failure', ['open', 'short', 'exception', 'commit'])
26+
def test_save_failure_preserves_existing_code_and_dirty_state(document, monkeypatch, failure):
27+
tab, target, errors, _ = document
28+
original_files = set(target.parent.iterdir())
29+
30+
class BrokenSaveFile(QSaveFile):
31+
def open(self, mode):
32+
assert not self.directWriteFallback()
33+
return False if failure == 'open' else super().open(mode)
34+
35+
def write(self, payload):
36+
assert target.read_bytes() == b'original code\r\n'
37+
if failure in ('short', 'exception'):
38+
written = super().write(payload[:5])
39+
if failure == 'exception':
40+
raise OSError('disk full after partial write')
41+
return written
42+
return super().write(payload)
43+
44+
def commit(self):
45+
if failure == 'commit':
46+
self.cancelWriting()
47+
return super().commit()
48+
49+
monkeypatch.setattr(tabs, 'QSaveFile', BrokenSaveFile)
50+
assert tab.save() is False
51+
assert target.read_bytes() == b'original code\r\n'
52+
assert tab.is_modified and tab.editor.document().isModified()
53+
assert len(errors) == 1
54+
assert set(target.parent.iterdir()) == original_files
55+
56+
57+
def test_invalid_utf8_does_not_open_output(document, monkeypatch):
58+
tab, target, errors, _ = document
59+
monkeypatch.setattr(tab.editor, 'toPlainText', lambda: 'bad\ud800text')
60+
monkeypatch.setattr(tabs, 'QSaveFile', lambda *_: pytest.fail('output opened before encoding'))
61+
assert tab.save() is False
62+
assert target.read_bytes() == b'original code\r\n'
63+
assert tab.is_modified and tab.editor.document().isModified()
64+
assert len(errors) == 1
65+
66+
67+
@pytest.mark.parametrize('text', ['', 'Grüße\nzweite Zeile\n'])
68+
def test_success_preserves_native_newlines_and_cleans_shared_document(document, text):
69+
tab, target, errors, _ = document
70+
clone = tabs.EditorTab.create_clone(tab)
71+
try:
72+
tab.editor.setPlainText(text)
73+
tab.editor.document().setModified(True)
74+
assert tab.save() is True
75+
assert target.read_bytes() == text.replace('\n', os.linesep).encode('utf-8')
76+
assert clone.editor.toPlainText() == text
77+
assert not tab.is_modified and not clone.is_modified
78+
assert not tab.editor.document().isModified()
79+
assert not errors
80+
finally:
81+
clone.editor.close()

‎tests/test_save_failure_guards.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def test_initial_save_failure_restores_untitled_state():
102102
"ui.main_window.QFileDialog.getSaveFileName",
103103
return_value=("C:/tmp/never-written.py", "Python (*.py)"),
104104
),
105-
patch("pathlib.Path.write_text", side_effect=OSError("disk full")),
105+
patch("core.tabs.QSaveFile.open", return_value=False),
106106
patch("PySide6.QtWidgets.QMessageBox.critical", return_value=None),
107107
):
108108
window.save_file()

0 commit comments

Comments
 (0)