Skip to content

Commit f44ff0c

Browse files
author
Lukas Geiger
committed
fix: harden codebox tab save flows
1 parent bd1d21f commit f44ff0c

11 files changed

Lines changed: 268 additions & 14 deletions

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
* text=auto
2+
*.bat text eol=crlf
3+
*.md text eol=lf
4+
*.py text eol=lf
5+
*.qss text eol=lf
6+
*.json text eol=lf

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ htmlcov/
1111
dist/
1212
build/
1313
*.spec
14+
!CodeBox.spec
1415

1516
# Virtual Environments
1617
venv/

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
2626
auf `PATH` korrekt erkannt.
2727
- LSP-Subprocess-Pipes werden beim Stoppen geschlossen; der Runtime-Test läuft
2828
dadurch ohne ResourceWarnings.
29+
- `close_tab()` bricht jetzt ab, wenn das Speichern eines modifizierten Tabs fehlschlägt,
30+
statt den Tab trotzdem zu schließen.
31+
- `run_current()` startet kein Programm mehr, wenn das automatische Speichern vor dem
32+
Ausführen fehlschlägt.
33+
- Tab-Reordering hält die interne Index-Map jetzt synchron; `current_tab()`,
34+
`close_tab()` und die offenen-Datei-Prüfungen bleiben nach Drag-and-drop korrekt.
2935

3036
### Geändert
3137
- Deutschsprachige Doku sowie Python-Kommentare, Docstrings und naheliegende UI-Texte
3238
verwenden jetzt echte Umlaute statt `ae/oe/ue`
39+
- Windows-Build nutzt jetzt die vorhandene PyInstaller-Spec mit lokalem
40+
Arbeitsverzeichnis außerhalb von OneDrive; `start.bat` startet bevorzugt
41+
`dist\CodeBox.exe` und fällt erst danach auf Release-EXE oder Python zurück.
3342
- README präzisiert die lokale Privacy-Abgrenzung; `.gitignore` schützt
3443
zusätzliche Credential-, SSH- und SQLite-Artefakte.
3544
- `.gitignore` deckt interne Diagnose-/Skill-Dateien, Test-Caches und lokale

CodeBox.spec

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# -*- mode: python ; coding: utf-8 -*-
2+
3+
4+
a = Analysis(
5+
['main.py'],
6+
pathex=[],
7+
binaries=[],
8+
datas=[
9+
('CodeBox.ico', '.'),
10+
('themes/*.qss', 'themes'),
11+
],
12+
hiddenimports=[],
13+
hookspath=[],
14+
hooksconfig={},
15+
runtime_hooks=[],
16+
excludes=[],
17+
noarchive=False,
18+
optimize=0,
19+
)
20+
pyz = PYZ(a.pure)
21+
22+
exe = EXE(
23+
pyz,
24+
a.scripts,
25+
a.binaries,
26+
a.datas,
27+
[],
28+
name='CodeBox',
29+
debug=False,
30+
bootloader_ignore_signals=False,
31+
strip=False,
32+
upx=False,
33+
upx_exclude=[],
34+
runtime_tmpdir=None,
35+
console=False,
36+
disable_windowed_traceback=False,
37+
argv_emulation=False,
38+
target_arch=None,
39+
codesign_identity=None,
40+
entitlements_file=None,
41+
icon=['CodeBox.ico'],
42+
)

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Terminal sowie erste LSP- und API-Grundlagen in einer lokalen IDE.
1616
- Integriertes Terminal mit Shell-Auswahl und History
1717
- Projekt-Dateibaum mit Filter und Kontextmenü
1818
- Mehrere Tabs, Suchfunktion und Gehe-zu-Zeile
19+
- Robuste Tab-Verwaltung mit Drag-and-drop-Reordering und Save-Failure-Guards
1920
- Theme-System über `features/theme_manager.py`
2021
- REST-API-/CLI-Grundlage für spätere Fernsteuerung
2122
- LSP-Diagnostics und Completion-Anbindung für installierte Language Server
@@ -65,8 +66,13 @@ build_exe.bat
6566
```
6667

6768
Das Script nutzt PyInstaller und erstellt lokal eine `CodeBox.exe` mit
68-
`CodeBox.ico`. Build-Ausgaben in `build/`, `dist/` und `releases/` bleiben
69-
lokale Artefakte und werden nicht versioniert.
69+
`CodeBox.ico`. Die versionierte `CodeBox.spec` bündelt Icon und Theme-Dateien,
70+
während temporäre Build-Daten unter `C:\_Local_DEV\codex_build\codebox` liegen.
71+
Build-Ausgaben in `build/`, `dist/` und `releases/` bleiben lokale Artefakte
72+
und werden nicht versioniert.
73+
74+
`start.bat` startet bevorzugt `dist\CodeBox.exe`, nutzt danach eine vorhandene
75+
Release-EXE und fällt erst zuletzt auf `python main.py` zurück.
7076

7177
## Projektstruktur
7278

@@ -90,6 +96,8 @@ Bereits stabil nutzbar:
9096
- Projektbaum und Terminal im MainWindow
9197
- Konsistente Fenstertitel über `version.py`
9298
- Light-/Dark-Theme-Wechsel über den zentralen Theme-Manager
99+
- Speichern-, Schließen- und Ausführen-Flows behalten Tabs offen, wenn ein
100+
Dateisystemfehler das Speichern verhindert.
93101

94102
Noch offen für die nächste größere Ausbaustufe:
95103

build_exe.bat

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
@echo off
2+
chcp 65001 >nul
23
cd /d "%~dp0"
34
python --version >nul 2>&1
45
if errorlevel 1 (
56
echo [FEHLER] Python nicht gefunden!
67
pause
78
exit /b 1
89
)
10+
set "BUILD_ROOT=C:\_Local_DEV\codex_build\codebox"
11+
set "WORK_DIR=%BUILD_ROOT%\work"
12+
set "DIST_DIR=%CD%\dist"
13+
if not exist "%BUILD_ROOT%" mkdir "%BUILD_ROOT%"
914
echo Baue CodeBox.exe...
10-
python -m PyInstaller --noconfirm --clean --windowed --onefile --name CodeBox --icon CodeBox.ico main.py
15+
python -m PyInstaller --noconfirm --clean --workpath "%WORK_DIR%" --distpath "%DIST_DIR%" CodeBox.spec
1116
if errorlevel 1 pause

core/tabs.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ def __init__(self, parent=None):
7878

7979
self.tabCloseRequested.connect(self.close_tab)
8080
self.currentChanged.connect(self._on_tab_changed)
81+
self.tabBar().tabMoved.connect(self._on_tab_moved)
82+
83+
def _rebuild_tab_map(self):
84+
"""Baut die Index-Map anhand der aktuellen Tab-Reihenfolge neu auf."""
85+
old_tabs = list(self.tabs.values())
86+
self.tabs = {}
87+
for i in range(self.count()):
88+
widget = self.widget(i)
89+
for tab in old_tabs:
90+
if tab.editor is widget:
91+
self.tabs[i] = tab
92+
break
8193

8294
def open_file(self, file_path: Path) -> EditorTab:
8395
"""Öffnet eine Datei in einem neuen Tab (oder wechselt zu existierendem)"""
@@ -116,20 +128,17 @@ def close_tab(self, index: int):
116128
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel
117129
)
118130
if reply == QMessageBox.StandardButton.Save:
119-
tab.save()
131+
if not tab.save():
132+
return
120133
elif reply == QMessageBox.StandardButton.Cancel:
121134
return
122135

123136
self.removeTab(index)
124-
# Tab-Dict neu aufbauen
125-
old_tabs = self.tabs.copy()
126-
self.tabs = {}
127-
for i in range(self.count()):
128-
widget = self.widget(i)
129-
for old_idx, old_tab in old_tabs.items():
130-
if old_tab.editor is widget:
131-
self.tabs[i] = old_tab
132-
break
137+
self._rebuild_tab_map()
138+
139+
def _on_tab_moved(self, _from: int, _to: int):
140+
"""Hält die Tab-Map nach Drag-and-drop im Sync."""
141+
self._rebuild_tab_map()
133142

134143
def current_tab(self) -> EditorTab:
135144
"""Gibt den aktuellen EditorTab zurück"""

start.bat

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
@echo off
22
chcp 65001 >nul
33
cd /d "%~dp0"
4+
set "DIST_EXE=dist\CodeBox.exe"
5+
set "RELEASE_EXE=releases\v0.1.0\CodeBox-0.1.0-win64.exe"
6+
if exist "%DIST_EXE%" (
7+
start "" "%DIST_EXE%"
8+
exit /b 0
9+
)
10+
if exist "%RELEASE_EXE%" (
11+
start "" "%RELEASE_EXE%"
12+
exit /b 0
13+
)
14+
python --version >nul 2>&1
15+
if errorlevel 1 (
16+
echo [FEHLER] Python nicht gefunden und keine EXE vorhanden.
17+
pause
18+
exit /b 1
19+
)
420
python main.py
521
pause

tests/test_save_failure_guards.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import Mock, patch
4+
5+
from PySide6.QtWidgets import QApplication, QMessageBox
6+
7+
from core.tabs import TabWidget
8+
from ui.main_window import MainWindow
9+
10+
11+
def _ensure_app():
12+
return QApplication.instance() or QApplication([])
13+
14+
15+
def test_close_tab_keeps_modified_tab_open_when_save_fails():
16+
_ensure_app()
17+
widget = TabWidget()
18+
tab = widget.new_tab()
19+
tab.is_modified = True
20+
21+
with (
22+
patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Save),
23+
patch.object(tab, "save", return_value=False),
24+
):
25+
widget.close_tab(0)
26+
27+
assert widget.count() == 1
28+
assert widget.tabs[0] is tab
29+
assert widget.current_tab() is tab
30+
31+
widget.close()
32+
33+
34+
def test_run_current_does_not_execute_when_save_fails(tmp_path):
35+
_ensure_app()
36+
37+
with patch("features.terminal.TerminalWidget._start_shell", lambda self: None):
38+
window = MainWindow()
39+
40+
tab = window.tab_widget.current_tab()
41+
assert tab is not None
42+
43+
tab.file_path = tmp_path / "script.py"
44+
tab.provider = type(
45+
"DummyProvider",
46+
(),
47+
{"get_run_command": staticmethod(lambda file_path: ["python", file_path])},
48+
)()
49+
window.output.run_command = Mock()
50+
51+
with patch.object(tab, "save", return_value=False):
52+
window.run_current()
53+
54+
window.output.run_command.assert_not_called()
55+
56+
window.close()
57+
58+
59+
def test_initial_save_updates_tab_title_and_enables_run(tmp_path):
60+
_ensure_app()
61+
62+
with patch("features.terminal.TerminalWidget._start_shell", lambda self: None):
63+
window = MainWindow()
64+
65+
tab = window.tab_widget.current_tab()
66+
assert tab is not None
67+
68+
target = tmp_path / "script.py"
69+
tab.editor.setPlainText("print('hi')")
70+
71+
with patch(
72+
"ui.main_window.QFileDialog.getSaveFileName",
73+
return_value=(str(target), "Python (*.py)"),
74+
):
75+
window.save_file()
76+
77+
current_index = window.tab_widget.currentIndex()
78+
assert tab.file_path == target
79+
assert window.tab_widget.tabText(current_index) == "script.py"
80+
assert window.output.run_btn.isEnabled()
81+
82+
window.close()
83+
84+
85+
def test_initial_save_failure_restores_untitled_state():
86+
_ensure_app()
87+
88+
with patch("features.terminal.TerminalWidget._start_shell", lambda self: None):
89+
window = MainWindow()
90+
91+
tab = window.tab_widget.current_tab()
92+
assert tab is not None
93+
original_index = window.tab_widget.currentIndex()
94+
original_label = window.lang_label.text()
95+
96+
with (
97+
patch(
98+
"ui.main_window.QFileDialog.getSaveFileName",
99+
return_value=("C:/tmp/never-written.py", "Python (*.py)"),
100+
),
101+
patch("pathlib.Path.write_text", side_effect=OSError("disk full")),
102+
patch("PySide6.QtWidgets.QMessageBox.critical", return_value=None),
103+
):
104+
window.save_file()
105+
106+
assert tab.file_path is None
107+
assert window.tab_widget.tabText(original_index) == "Unbenannt"
108+
assert window.lang_label.text() == original_label
109+
assert not window.output.run_btn.isEnabled()
110+
111+
window.close()

tests/test_tab_reordering.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pathlib import Path
2+
3+
from PySide6.QtWidgets import QApplication
4+
5+
from core.tabs import TabWidget
6+
7+
8+
def _ensure_app():
9+
return QApplication.instance() or QApplication([])
10+
11+
12+
def test_current_tab_tracks_tab_reordering():
13+
_ensure_app()
14+
widget = TabWidget()
15+
16+
first = widget.new_tab()
17+
first.file_path = Path("first.py")
18+
second = widget.new_tab()
19+
second.file_path = Path("second.py")
20+
21+
widget.tabBar().moveTab(0, 1)
22+
23+
current_widget = widget.widget(widget.currentIndex())
24+
expected = next(tab for tab in widget.tabs.values() if tab.editor is current_widget)
25+
26+
assert widget.current_tab() is expected
27+
assert widget.tabs[widget.currentIndex()] is expected
28+
29+
widget.close()

0 commit comments

Comments
 (0)