Skip to content

Commit 8670719

Browse files
lukischclaude
andcommitted
feat(project-view): Git-Status-Badges (M/S/U/D) neben Dateinamen
- status_for_path(): Qt-freie Hilfsfunktion mit Path.as_posix()-Normalisierung (löst Windows-Backslash vs. Porcelain-Forward-Slash korrekt auf) - GitStatusDelegate: QStyledItemDelegate zeichnet farbige Badges rechts im Baum - _load_git_status(): befüllt den Delegate-Cache bei set_root() und _refresh() - 10 neue Regressionstests in tests/test_git_project_view_status.py - Testsuite: 67 passed, 1 skipped (Baseline: 57/1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 02d0ea4 commit 8670719

3 files changed

Lines changed: 223 additions & 3 deletions

File tree

CHANGELOG.md

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

66
## [Unreleased]
77

8+
### Hinzugefügt
9+
10+
- `features/project_view.py`: Git-Status-Indikatoren (M / S / SM / U / D / R) werden
11+
jetzt rechts neben dem Dateinamen im Projektbaum angezeigt. Neue Hilfsfunktion
12+
`status_for_path()` (Qt-frei, direkt testbar) sucht per `Path.as_posix()`-Normalisierung
13+
auch auf Windows mit Backslash-Pfaden korrekt im Porcelain-Status-Dict. Neuer
14+
`GitStatusDelegate` (`QStyledItemDelegate`) zeichnet farbige Badges; `_load_git_status()`
15+
befüllt den Cache bei `set_root()` und `_refresh()`. 10 neue Regressionstests in
16+
`tests/test_git_project_view_status.py`.
17+
818
### Behoben
919

1020
- `features/remote_editor.py`: SSH/SFTP-Verbindungen laden bekannte Hostkeys und

features/project_view.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55
66
Zeigt eine Baumstruktur des geöffneten Projektordners an.
77
Unterstützt Doppelklick zum Öffnen, Kontextmenü und Filter.
8+
Git-Status-Indikatoren (M/S/U/D) werden rechts neben dem Dateinamen eingeblendet.
89
"""
910

1011
from pathlib import Path
12+
from typing import Optional, Dict, TYPE_CHECKING
1113
from PySide6.QtWidgets import (
1214
QWidget, QVBoxLayout, QHBoxLayout, QTreeView, QFileSystemModel,
1315
QPushButton, QLabel, QLineEdit, QMenu, QFileDialog,
14-
QMessageBox
16+
QMessageBox, QStyledItemDelegate
1517
)
1618
from PySide6.QtCore import Qt, QDir, Signal, QSortFilterProxyModel, QModelIndex
17-
from PySide6.QtGui import QFont
19+
from PySide6.QtGui import QFont, QColor
20+
21+
if TYPE_CHECKING:
22+
from features.git_integration import GitFileStatus
1823

1924

2025
# Dateien/Ordner, die standardmäßig ausgeblendet werden
@@ -25,6 +30,89 @@
2530
}
2631

2732

33+
def status_for_path(
34+
abs_path: str,
35+
repo_root: str,
36+
status_dict: Dict[str, "GitFileStatus"],
37+
) -> Optional["GitFileStatus"]:
38+
"""Sucht GitFileStatus für einen absoluten Dateipfad anhand eines Status-Dicts.
39+
40+
Normalisiert Windows-Backslashes zu Forward-Slashes (Porcelain-Format von
41+
``git status --porcelain``). Gibt None zurück, wenn der Pfad außerhalb des
42+
Repos liegt oder kein Status-Eintrag existiert.
43+
44+
Args:
45+
abs_path: Absoluter Dateipfad (Windows- oder Unix-Trennzeichen).
46+
repo_root: Absoluter Pfad zum Repo-Wurzelordner.
47+
status_dict: Dict aus ``GitRepo.get_status()`` (Keys: Slash-relativ).
48+
49+
Returns:
50+
GitFileStatus oder None.
51+
"""
52+
try:
53+
rel = Path(abs_path).relative_to(repo_root)
54+
except ValueError:
55+
return None
56+
return status_dict.get(rel.as_posix())
57+
58+
59+
class GitStatusDelegate(QStyledItemDelegate):
60+
"""Zeichnet einen farbigen Git-Status-Badge rechts neben dem Dateinamen.
61+
62+
Holt sich den absoluten Dateipfad über ``QFileSystemModel``/Proxy und
63+
delegiert den Lookup an die Qt-freie ``status_for_path``-Funktion.
64+
Ordner werden nicht beschriftet.
65+
"""
66+
67+
_BADGE_FONT_SIZE = 9
68+
69+
def __init__(self, fs_model: QFileSystemModel, proxy: QSortFilterProxyModel,
70+
parent=None):
71+
super().__init__(parent)
72+
self._fs_model = fs_model
73+
self._proxy = proxy
74+
self._status_dict: Dict[str, "GitFileStatus"] = {}
75+
self._repo_root: str = ""
76+
77+
def set_status(self, repo_root: str, status_dict: Dict[str, "GitFileStatus"]):
78+
"""Setzt den aktuellen Git-Status-Cache (wird bei set_root/refresh aufgerufen)."""
79+
self._repo_root = repo_root
80+
self._status_dict = status_dict
81+
82+
def _status_for_index(self, index: QModelIndex) -> Optional["GitFileStatus"]:
83+
source = self._proxy.mapToSource(index)
84+
if not source.isValid():
85+
return None
86+
abs_path = self._fs_model.filePath(source)
87+
if not abs_path or not self._repo_root:
88+
return None
89+
return status_for_path(abs_path, self._repo_root, self._status_dict)
90+
91+
def paint(self, painter, option, index):
92+
super().paint(painter, option, index)
93+
# Ordner nicht beschriften
94+
source = self._proxy.mapToSource(index)
95+
if source.isValid() and self._fs_model.isDir(source):
96+
return
97+
status = self._status_for_index(index)
98+
if not (status and status.status_icon):
99+
return
100+
painter.save()
101+
color = status.color_hint
102+
if color:
103+
painter.setPen(QColor(color))
104+
badge_font = QFont("Consolas", self._BADGE_FONT_SIZE)
105+
badge_font.setBold(True)
106+
painter.setFont(badge_font)
107+
rect = option.rect.adjusted(0, 0, -4, 0)
108+
painter.drawText(
109+
rect,
110+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
111+
status.status_icon,
112+
)
113+
painter.restore()
114+
115+
28116
class FileFilterProxy(QSortFilterProxyModel):
29117
"""Filtert versteckte Dateien und Ordner."""
30118

@@ -183,6 +271,10 @@ def _setup_ui(self):
183271
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
184272
self.tree.customContextMenuRequested.connect(self._on_context_menu)
185273

274+
# Git-Status-Delegate (zeigt M/S/U/D-Badges neben Dateinamen)
275+
self.git_delegate = GitStatusDelegate(self.fs_model, self.proxy, self)
276+
self.tree.setItemDelegateForColumn(0, self.git_delegate)
277+
186278
layout.addWidget(self.tree)
187279

188280
def set_root(self, path: str):
@@ -192,6 +284,7 @@ def set_root(self, path: str):
192284
proxy_root = self.proxy.mapFromSource(root_index)
193285
self.tree.setRootIndex(proxy_root)
194286
self._update_git_info()
287+
self._load_git_status()
195288

196289
def _update_git_info(self):
197290
"""Aktualisiert die Git-Branch-Anzeige im Titel."""
@@ -206,13 +299,29 @@ def _update_git_info(self):
206299
else:
207300
self.title_label.setText(self._root_path.name)
208301

302+
def _load_git_status(self):
303+
"""Lädt den Git-Status aller geänderten Dateien für den aktuellen Root.
304+
305+
Befüllt den Cache des GitStatusDelegate, damit dieser beim Paint-Aufruf
306+
sofort die Badges rendern kann.
307+
"""
308+
if not self._root_path:
309+
self.git_delegate.set_status("", {})
310+
return
311+
from features.git_integration import GitRepo
312+
repo = GitRepo(str(self._root_path))
313+
if repo.is_git_repo():
314+
self.git_delegate.set_status(str(self._root_path), repo.get_status())
315+
else:
316+
self.git_delegate.set_status("", {})
317+
209318
def _open_folder_dialog(self):
210319
path = QFileDialog.getExistingDirectory(self, "Projektordner wählen")
211320
if path:
212321
self.set_root(path)
213322

214323
def _refresh(self):
215-
"""Aktualisiert die Ansicht."""
324+
"""Aktualisiert die Ansicht und den Git-Status-Cache."""
216325
if self._root_path:
217326
self.set_root(str(self._root_path))
218327

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests für status_for_path() in features/project_view.py.
2+
3+
Prüft die Qt-freie Hilfsfunktion, die absoluten Dateipfaden einen
4+
GitFileStatus zuordnet — inkl. Windows-Pfad-Normalisierung.
5+
"""
6+
import os
7+
import sys
8+
import unittest
9+
10+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
11+
12+
from features.project_view import status_for_path
13+
from features.git_integration import GitFileStatus
14+
15+
16+
def _file_status(path: str, *, is_modified=False, is_untracked=False,
17+
is_staged=False, is_deleted=False) -> GitFileStatus:
18+
"""Baut ein minimales GitFileStatus für Tests."""
19+
return GitFileStatus(
20+
path=path,
21+
index_status="M" if is_staged else ("D" if is_deleted else "?"),
22+
work_status="M" if is_modified else ("D" if is_deleted else "?"),
23+
is_staged=is_staged,
24+
is_modified=is_modified,
25+
is_untracked=is_untracked,
26+
is_deleted=is_deleted,
27+
)
28+
29+
30+
class TestStatusForPath(unittest.TestCase):
31+
32+
def test_simple_match_unix(self):
33+
"""Unix-Pfad: einfaches Match gegen Status-Key."""
34+
sd = {"src/main.py": _file_status("src/main.py", is_modified=True)}
35+
result = status_for_path("/repo/src/main.py", "/repo", sd)
36+
self.assertIsNotNone(result)
37+
self.assertTrue(result.is_modified)
38+
39+
def test_windows_backslash_normalization(self):
40+
"""Windows-Backslashes werden transparent zu Forward-Slashes normalisiert."""
41+
sd = {"src/main.py": _file_status("src/main.py", is_modified=True)}
42+
result = status_for_path(
43+
r"C:\project\src\main.py", r"C:\project", sd
44+
)
45+
self.assertIsNotNone(result, "Windows-Pfad muss nach Normalisierung matchen")
46+
self.assertTrue(result.is_modified)
47+
48+
def test_outside_repo_returns_none(self):
49+
"""Pfad außerhalb des Repo-Roots gibt None zurück (keine ValueError-Ausnahme)."""
50+
sd = {"main.py": _file_status("main.py")}
51+
result = status_for_path("/other/main.py", "/repo", sd)
52+
self.assertIsNone(result)
53+
54+
def test_no_status_entry_returns_none(self):
55+
"""Saubere Datei ohne Status-Eintrag im Dict gibt None zurück."""
56+
result = status_for_path("/repo/clean.py", "/repo", {})
57+
self.assertIsNone(result)
58+
59+
def test_nested_path_untracked(self):
60+
"""Tief verschachtelte Pfade werden korrekt aufgelöst."""
61+
sd = {"a/b/c/deep.py": _file_status("a/b/c/deep.py", is_untracked=True)}
62+
result = status_for_path("/repo/a/b/c/deep.py", "/repo", sd)
63+
self.assertIsNotNone(result)
64+
self.assertTrue(result.is_untracked)
65+
66+
def test_staged_file_icon(self):
67+
"""Status-Icon einer rein gestageten Datei ist 'S'."""
68+
sd = {"staged.py": _file_status("staged.py", is_staged=True)}
69+
result = status_for_path("/repo/staged.py", "/repo", sd)
70+
self.assertIsNotNone(result)
71+
self.assertEqual(result.status_icon, "S")
72+
73+
def test_deleted_file_icon(self):
74+
"""Status-Icon einer gelöschten Datei ist 'D'."""
75+
sd = {"gone.py": _file_status("gone.py", is_deleted=True)}
76+
result = status_for_path("/repo/gone.py", "/repo", sd)
77+
self.assertIsNotNone(result)
78+
self.assertEqual(result.status_icon, "D")
79+
80+
def test_root_itself_returns_none(self):
81+
"""Der Repo-Root selbst taucht nicht im Status-Dict auf → None."""
82+
result = status_for_path("/repo", "/repo", {})
83+
self.assertIsNone(result)
84+
85+
def test_empty_status_dict_returns_none(self):
86+
"""Leeres Status-Dict: immer None."""
87+
result = status_for_path("/repo/any.py", "/repo", {})
88+
self.assertIsNone(result)
89+
90+
def test_windows_nested_backslash(self):
91+
"""Windows-Pfad mit mehreren Ebenen und Backslashes."""
92+
sd = {"sub/dir/file.py": _file_status("sub/dir/file.py", is_modified=True)}
93+
result = status_for_path(
94+
r"C:\myproject\sub\dir\file.py", r"C:\myproject", sd
95+
)
96+
self.assertIsNotNone(result)
97+
self.assertTrue(result.is_modified)
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

0 commit comments

Comments
 (0)