Skip to content

Commit f405b31

Browse files
committed
fix(terminal,output): fix PowerShell cd command, output encoding, and process error handling
- features/terminal.py: fix working directory change in PowerShell by omitting unsupported /d flag; use dynamic _output_encoding() for cmd vs PowerShell/POSIX; connect errorOccurred signal - core/output.py: connect errorOccurred to _on_error in OutputPanel to prevent hanging stop button and silent launch failures; add waitForFinished(1000) after kill and closeEvent cleanup - tests: add regression tests in test_terminal_encoding.py and test_output_panel.py (115 passed) - docs: update CHANGELOG.md, README.md, README_de.md, and llms.txt
1 parent a81a2f4 commit f405b31

8 files changed

Lines changed: 132 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1313

1414
## [0.1.2] - 2026-08-16
1515

16+
### Terminal- & Prozess-Streaming-Härtung (2026-08-22)
17+
18+
- `features/terminal.py`: Verzeichniswechsel via `set_working_dir()` für PowerShell korrigiert (PowerShell unterstützt keinen `/d`-Schalter von cmd.exe; `cd` führte zuvor zu `PositionalParameterNotFound`-Fehlern und verweigerte das Wechseln des Arbeitsordners). Output-Dekodierung in `_on_stdout` und `_on_stderr` auf dynamisches `_output_encoding()` (cp1252 für cmd unter Windows, sonst utf-8) umgestellt. Signalverwaltung um `errorOccurred` und sauberes Exception-Handling erweitert.
19+
- `core/output.py`: `OutputPanel` mit `errorOccurred`-Signalbehandlung (`_on_error`) gegen hängende Stop-Buttons und unterdrückte Fehlermeldungen bei nicht auffindbaren Compilern/Programmen (`FailedToStart`, `Crashed`) abgesichert. `waitForFinished(1000)` nach `kill()` und `closeEvent` für zuverlässige Prozessbereinigung ergänzt.
20+
- `tests/test_terminal_encoding.py` & `tests/test_output_panel.py`: 6 neue Regressionstests für PowerShell-Verzeichniswechsel, Output-Encoding, Signal-Entkopplung, Fehlerausgabe und Prozessbeendigung ergänzt (115 passed, 1 skipped).
21+
1622
### UX, Barrierefreiheit & Accessibility-Härtung (2026-08-21)
1723

1824
- `ui/main_window.py`: StatusTips auf allen Menü- und Toolbar-Aktionen (`Neu`, `Öffnen`, `Speichern`, `Beenden`, `Rückgängig`, `Wiederherstellen`, `Suchen`, `Gehe zu Zeile`, `Plugins`, `Einstellungen`, `Ausführen`, `Stoppen`, `Projektbaum`, `Terminal`, `Tastenkürzel`, `Über`); `setToolTip()`, `setWhatsThis()`, `setAccessibleName()` und `setAccessibleDescription()` für Hauptleiste, Sprach-Auswahl (`lang_combo`), Statusleisten-Widgets (`pos_label`, `lang_label`, `enc_label`) und Reiter im unteren Bedienpanel (`bottom_tabs`).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![Ecosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
99
[![Part of: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1010
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
11-
[![Tests](https://img.shields.io/badge/tests-109%20passed-brightgreen.svg)]()
11+
[![Tests](https://img.shields.io/badge/tests-115%20passed-brightgreen.svg)]()
1212
[![llms.txt](https://img.shields.io/badge/llms.txt-available-green.svg)](llms.txt)
1313

1414
[Deutsch](README_de.md) | English

README_de.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![Ecosystem: dev-bricks](https://img.shields.io/badge/ecosystem-dev--bricks-blue.svg)](https://github.com/dev-bricks)
99
[![Part of: open-bricks](https://img.shields.io/badge/part%20of-open--bricks-blue.svg)](https://github.com/open-bricks)
1010
[![LSP Ready](https://img.shields.io/badge/LSP-ready-purple.svg)]()
11-
[![Tests](https://img.shields.io/badge/tests-109%20passed-brightgreen.svg)]()
11+
[![Tests](https://img.shields.io/badge/tests-115%20passed-brightgreen.svg)]()
1212
[![llms.txt](https://img.shields.io/badge/llms.txt-available-green.svg)](llms.txt)
1313

1414
[English](README.md) | Deutsch

core/output.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,15 @@ def run_command(self, command: list):
8989
self.process.readyReadStandardOutput,
9090
self.process.readyReadStandardError,
9191
self.process.finished,
92+
self.process.errorOccurred,
9293
):
9394
try:
9495
sig.disconnect()
9596
except (TypeError, RuntimeError):
9697
pass
9798
if self.process.state() != QProcess.ProcessState.NotRunning:
9899
self.process.kill()
100+
self.process.waitForFinished(1000)
99101

100102
self.output.clear()
101103
self.status_label.setText(f"Ausführung: {' '.join(command)}")
@@ -105,14 +107,17 @@ def run_command(self, command: list):
105107
self.process.readyReadStandardOutput.connect(self._on_stdout)
106108
self.process.readyReadStandardError.connect(self._on_stderr)
107109
self.process.finished.connect(self._on_finished)
110+
self.process.errorOccurred.connect(self._on_error)
108111

109112
program = command[0]
113+
self._current_program = program
110114
args = command[1:] if len(command) > 1 else []
111115
self.process.start(program, args)
112116

113117
def stop_process(self):
114118
if self.process and self.process.state() != QProcess.ProcessState.NotRunning:
115119
self.process.kill()
120+
self.process.waitForFinished(1000)
116121
self.append_text("\n--- Prozess abgebrochen ---\n", color="#ff8888")
117122

118123
def clear(self):
@@ -148,3 +153,23 @@ def _on_finished(self, exit_code, exit_status):
148153
self.append_text(f"\n--- Prozess beendet ({status}) ---\n",
149154
color="#88ff88" if exit_code == 0 else "#ff8888")
150155
self.processFinished.emit(exit_code, self.output.toPlainText())
156+
157+
def _on_error(self, error):
158+
self.stop_btn.setEnabled(False)
159+
program = getattr(self, "_current_program", "Programm")
160+
if error == QProcess.ProcessError.FailedToStart:
161+
self.status_label.setText("Fehler: Programm konnte nicht gestartet werden")
162+
self.append_text(
163+
f"\n--- Fehler: Programm '{program}' konnte nicht gestartet werden (Befehl nicht gefunden oder keine Ausführungsrechte) ---\n",
164+
color="#ff8888",
165+
)
166+
self.processFinished.emit(-1, self.output.toPlainText())
167+
elif error == QProcess.ProcessError.Crashed:
168+
self.status_label.setText("Fehler: Prozess abgestürzt")
169+
self.append_text(f"\n--- Fehler: Prozess '{program}' ist abgestürzt ---\n", color="#ff8888")
170+
171+
def closeEvent(self, event):
172+
if self.process and self.process.state() != QProcess.ProcessState.NotRunning:
173+
self.process.kill()
174+
self.process.waitForFinished(1000)
175+
super().closeEvent(event)

features/terminal.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def _start_shell(self):
164164
self.process.readyReadStandardOutput,
165165
self.process.readyReadStandardError,
166166
self.process.finished,
167+
self.process.errorOccurred,
167168
):
168169
try:
169170
sig.disconnect()
@@ -178,6 +179,7 @@ def _start_shell(self):
178179
self.process.readyReadStandardOutput.connect(self._on_stdout)
179180
self.process.readyReadStandardError.connect(self._on_stderr)
180181
self.process.finished.connect(self._on_finished)
182+
self.process.errorOccurred.connect(self._on_error)
181183

182184
program, args = self._get_shell_command()
183185
self.process.start(program, args)
@@ -196,6 +198,14 @@ def _input_encoding(self) -> str:
196198
return "cp1252"
197199
return "utf-8"
198200

201+
def _output_encoding(self) -> str:
202+
"""Encoding für Shell-Ausgaben — cp1252 für cmd/Windows, sonst utf-8."""
203+
if sys.platform == "win32":
204+
shell = self.shell_combo.currentText() if hasattr(self, "shell_combo") else "cmd"
205+
if shell == "cmd":
206+
return "cp1252"
207+
return "utf-8"
208+
199209
def _execute_command(self):
200210
"""Führt den eingegebenen Befehl aus."""
201211
cmd = self.input.text().strip()
@@ -253,22 +263,27 @@ def set_working_dir(self, path: str):
253263
self.cwd_label.setText(path)
254264
if self.process and self.process.state() == QProcess.ProcessState.Running:
255265
encoding = self._input_encoding()
256-
if sys.platform == "win32":
266+
shell = self.shell_combo.currentText() if hasattr(self, "shell_combo") else "cmd"
267+
if sys.platform == "win32" and shell == "cmd":
257268
self.process.write(f"cd /d \"{path}\"\n".encode(encoding, errors='replace'))
258269
else:
259270
self.process.write(f"cd \"{path}\"\n".encode(encoding, errors='replace'))
260271

261272
def _on_stdout(self):
262-
data = self.process.readAllStandardOutput().data().decode('utf-8', errors='replace')
273+
data = self.process.readAllStandardOutput().data().decode(self._output_encoding(), errors='replace')
263274
self.append_text(data)
264275

265276
def _on_stderr(self):
266-
data = self.process.readAllStandardError().data().decode('utf-8', errors='replace')
277+
data = self.process.readAllStandardError().data().decode(self._output_encoding(), errors='replace')
267278
self.append_text(data, "#ff8888")
268279

269280
def _on_finished(self, exit_code, exit_status):
270281
self.append_text(f"\n--- Shell beendet (Code {exit_code}) ---\n", "#888888")
271282

283+
def _on_error(self, error):
284+
if error == QProcess.ProcessError.FailedToStart:
285+
self.append_text("--- Fehler: Shell konnte nicht gestartet werden ---\n", "#ff8888")
286+
272287
def closeEvent(self, event):
273288
if self.process:
274289
self.process.kill()

llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# CodeBox - local PySide6 desktop code editor
22

3-
## Last-checked: 2026-08-21
3+
## Last-checked: 2026-08-22
44

55
> CodeBox is a local-first desktop IDE for Windows developers who want a lightweight PySide6 code editor with tabs, a project tree, an integrated terminal, Git helpers, syntax highlighting, Language Server Protocol diagnostics, and an extensible JSON/Python language plugin system.
66

@@ -31,7 +31,7 @@ Part of the dev-bricks family. Python, MIT.
3131
- `python main.py`: Launches the CodeBox desktop interface.
3232
- `python main.py --open <file>`: Launches and directly opens a target file path.
3333
- `build_exe.bat`: Uses PyInstaller to bundle the application into a standalone executable.
34-
- `python -m pytest`: Runs the automated test suite (109 passed, 1 skipped).
34+
- `python -m pytest`: Runs the automated test suite (115 passed, 1 skipped).
3535

3636
## Related Projects (dev-bricks & ellmos family)
3737

tests/test_output_panel.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def test_run_command_disconnects_old_process_signals(self):
3737
mock_old.readyReadStandardOutput.disconnect.assert_called_once_with()
3838
mock_old.readyReadStandardError.disconnect.assert_called_once_with()
3939
mock_old.finished.disconnect.assert_called_once_with()
40+
mock_old.errorOccurred.disconnect.assert_called_once_with()
4041
mock_old.kill.assert_called_once()
4142

4243
def test_run_command_no_disconnect_when_no_prior_process(self):
@@ -83,8 +84,41 @@ def test_output_controls_expose_accessible_context(self):
8384
self.assertEqual(self.panel.clear_btn.accessibleName(), "Ausgabe leeren")
8485
self.assertIn("Meldungen", self.panel.clear_btn.accessibleDescription())
8586

86-
self.assertEqual(self.panel.status_label.accessibleName(), "Ausführungsstatus")
87-
self.assertEqual(self.panel.output.accessibleName(), "Programmausgabe")
87+
def test_run_command_failed_to_start_disables_stop_btn_and_emits_finished(self):
88+
"""Wenn ein Programm nicht gestartet werden kann, muss stop_btn deaktiviert,
89+
der Status aktualisiert und ein Fehler ausgegeben werden."""
90+
emitted_results = []
91+
self.panel.processFinished.connect(lambda code, text: emitted_results.append((code, text)))
92+
93+
self.panel._current_program = "non_existent_compiler_xyz"
94+
self.panel.stop_btn.setEnabled(True)
95+
self.panel._on_error(QProcess.ProcessError.FailedToStart)
96+
97+
self.assertFalse(self.panel.stop_btn.isEnabled(), "stop_btn muss nach FailedToStart deaktiviert sein")
98+
self.assertIn("Fehler", self.panel.status_label.text())
99+
self.assertIn("nicht gestartet werden", self.panel.output.toPlainText())
100+
self.assertEqual(len(emitted_results), 1)
101+
self.assertEqual(emitted_results[0][0], -1)
102+
103+
def test_run_command_crashed_updates_status(self):
104+
"""Absturz eines Prozesses muss im Statuslabel und Output protokolliert werden."""
105+
self.panel._current_program = "crashing_app"
106+
self.panel.stop_btn.setEnabled(True)
107+
self.panel._on_error(QProcess.ProcessError.Crashed)
108+
109+
self.assertFalse(self.panel.stop_btn.isEnabled())
110+
self.assertIn("abgestürzt", self.panel.status_label.text())
111+
self.assertIn("abgestürzt", self.panel.output.toPlainText())
112+
113+
def test_output_panel_close_event_terminates_running_process(self):
114+
"""Beim Schließen des Panels muss ein aktiver Prozess beendet werden."""
115+
mock_proc = MagicMock()
116+
mock_proc.state.return_value = QProcess.ProcessState.Running
117+
self.panel.process = mock_proc
118+
119+
self.panel.close()
120+
mock_proc.kill.assert_called_once()
121+
mock_proc.waitForFinished.assert_called_once_with(1000)
88122

89123

90124
if __name__ == "__main__":

tests/test_terminal_encoding.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,40 @@ def test_set_working_dir_uses_same_encoding_as_execute_command(self):
8585
"set_working_dir() muss denselben cp1252-Encoding-Pfad wie _execute_command() nutzen")
8686
widget.close()
8787

88+
@unittest.skipUnless(sys.platform == "win32", "Windows-only")
89+
def test_set_working_dir_powershell_does_not_use_d_flag(self):
90+
"""Regression: PowerShell kennt keinen /d Schalter für Set-Location/cd.
91+
set_working_dir() muss für PowerShell 'cd \"path\"' ohne '/d' senden."""
92+
widget = self._make_widget("powershell")
93+
94+
from PySide6.QtCore import QProcess
95+
written = []
96+
mock_proc = MagicMock()
97+
mock_proc.state.return_value = QProcess.ProcessState.Running
98+
mock_proc.write.side_effect = lambda data: written.append(data)
99+
widget.process = mock_proc
100+
101+
path = "C:/Projects/CodeBox"
102+
widget.set_working_dir(path)
103+
104+
self.assertEqual(len(written), 1)
105+
encoded_cmd = written[0]
106+
expected = f'cd "{path}"\n'.encode("utf-8", errors="replace")
107+
self.assertEqual(encoded_cmd, expected,
108+
"PowerShell darf keinen /d-Schalter erhalten")
109+
widget.close()
110+
111+
@unittest.skipUnless(sys.platform == "win32", "Windows-only")
112+
def test_output_encoding_matches_shell(self):
113+
"""_output_encoding() muss für cmd cp1252 und für PowerShell utf-8 liefern."""
114+
widget_cmd = self._make_widget("cmd")
115+
self.assertEqual(widget_cmd._output_encoding(), "cp1252")
116+
widget_cmd.close()
117+
118+
widget_ps = self._make_widget("powershell")
119+
self.assertEqual(widget_ps._output_encoding(), "utf-8")
120+
widget_ps.close()
121+
88122

89123
class TerminalStartShellTests(unittest.TestCase):
90124
"""Regressionstests für _start_shell() Signal-Verwaltung (B-012)."""
@@ -117,6 +151,7 @@ def test_start_shell_disconnects_old_process_signals(self):
117151
mock_old.readyReadStandardOutput.disconnect.assert_called_once_with()
118152
mock_old.readyReadStandardError.disconnect.assert_called_once_with()
119153
mock_old.finished.disconnect.assert_called_once_with()
154+
mock_old.errorOccurred.disconnect.assert_called_once_with()
120155
mock_old.kill.assert_called_once()
121156
widget.close()
122157

@@ -151,6 +186,14 @@ def test_start_shell_no_disconnect_when_no_prior_process(self):
151186
# Kein AttributeError, kein Kill-Aufruf — Test besteht wenn kein Fehler
152187
widget.close()
153188

189+
def test_terminal_on_error_failed_to_start(self):
190+
"""errorOccurred mit FailedToStart soll eine Fehlermeldung ausgeben."""
191+
from PySide6.QtCore import QProcess
192+
widget = self._make_widget_no_shell()
193+
widget._on_error(QProcess.ProcessError.FailedToStart)
194+
self.assertIn("Fehler: Shell konnte nicht gestartet werden", widget.output.toPlainText())
195+
widget.close()
196+
154197

155198
if __name__ == "__main__":
156199
unittest.main()

0 commit comments

Comments
 (0)