Coverage for vibefpmgui/git_panel.py: 94%
294 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The *Git* tab, with three views to switch between in its header:
3* *Diff*: the project's repository's status (its branch, and the changed
4 files: staged, not staged, untracked, in conflict), and the diff of the
5 selected file (see `vibefpmgui.git_status`, `vibefpmgui.diff_view`).
6 Double-clicking a line of the diff opens the file there.
7* *History*: the commits, with their branches and tags and the graph of
8 how they join; a commit's details, files and diffs (see
9 `vibefpmgui.git_log`, `vibefpmgui.git_log_view`).
10* *Stats*: numbers about the repository and each person's commits over time
11 (see `vibefpmgui.git_stats`, `vibefpmgui.git_stats_view`).
13The History and the Stats are read when they're on show and HEAD has moved
14since (or Refresh is clicked; or something of their own changed, such as
15the History's All branches: see `reload_key`). Each of those pages has the
16same few methods for this (`reload_key`, `reader`, `show_result`,
17`show_message`), so the panel needs nothing of either's own.
19What git says is read on a pool thread.
20"""
22from __future__ import annotations
24from pathlib import Path
26from qtpy.QtCore import QSize, Qt, Signal
27from qtpy.QtGui import QBrush, QColor, QFont
28from qtpy.QtWidgets import (
29 QApplication,
30 QButtonGroup,
31 QHBoxLayout,
32 QLabel,
33 QMenu,
34 QSplitter,
35 QStackedWidget,
36 QToolButton,
37 QTreeWidget,
38 QTreeWidgetItem,
39 QVBoxLayout,
40 QWidget,
41)
43from . import git_status as gs
44from .background import call_in_background
45from .build_monitor import PALETTES, TitleIcon, app_is_light, panel_stylesheet
46from .file_diff import FileDiff
47from .git_log_view import GitLogView
48from .git_stats_view import GitStatsView
49from .icons import make_icon
50from .widgets import StatusLabel
52CHANGE_ROLE = Qt.UserRole + 1 # a file's row: its git_status.Change
53DIFF, HISTORY, STATS = "diff", "history", "stats" # the panel's views
54VIEW_TITLES = {DIFF: "Diff", HISTORY: "History", STATS: "Stats"}
55VIEW_TIPS = {DIFF: "The changed files, and the selected one's diff",
56 HISTORY: "The commits, with their branches and tags and the graph of how they join",
57 STATS: "Statistics about the repository, and everyone's commits over time"}
58LETTER_COLORS = {"M": "#e8a317", "A": "#2f9e44", "D": "#e03131", "R": "#4c6ef5", "T": "#4c6ef5",
59 "U": "#2f9e44", "!": "#e03131"}
62class GitPanel(QWidget):
63 """Its header (the views' buttons, the branch, Unified and Refresh) over
64 the view chosen: Diff (the changed files, left, and the selected one's
65 diff, right), History or Stats."""
67 refresh_requested = Signal()
68 open_requested = Signal(str, int) # file, line
69 reveal_requested = Signal(str) # a changed file's "Show in Project Files": its path
70 discard_requested = Signal(object) # a changed file's "Discard Changes…": its gs.Change
71 view_changed = Signal(str) # DIFF, HISTORY or STATS chosen in the header
73 def __init__(self, parent=None):
74 super().__init__(parent)
75 self.status: gs.RepoStatus | None = None
76 self.icon_for = None # a file's QIcon, as in Project files (set by the window)
77 self.is_light = True
78 self.palette_colors = PALETTES["light"]
79 self._shown: dict[str, tuple] = {} # HISTORY, STATS: what's on show ((root, HEAD…): see _key)
80 self._reading: dict[str, tuple] = {} # and what's being read
81 self.setObjectName("GitPanel")
82 self.setAttribute(Qt.WA_StyledBackground, True)
84 self.title_icon = TitleIcon("git")
85 self.title = QLabel("Git")
86 self.subtitle = StatusLabel()
87 # Icon buttons (as the Debug panel's: see refresh_palette), their tooltips saying what they do.
88 self.unified_button = QToolButton()
89 self.unified_button.setText("Unified")
90 self.unified_button.setCheckable(True)
91 self.unified_button.setToolTip("Show the diff as one list (removed lines, then added ones) "
92 "instead of side by side")
93 self.unified_button.toggled.connect(self._set_unified)
94 refresh = self.refresh_button = QToolButton()
95 refresh.setText("Refresh")
96 refresh.setToolTip("Read the repository's status again")
97 refresh.clicked.connect(self._refresh_clicked)
98 # The views: a pair of buttons, one of them on.
99 self.view_buttons: dict[str, QToolButton] = {}
100 self.view_group = QButtonGroup(self)
101 for view in (DIFF, HISTORY, STATS):
102 button = self.view_buttons[view] = QToolButton()
103 button.setObjectName("GitView")
104 button.setText(VIEW_TITLES[view])
105 button.setToolTip(VIEW_TIPS[view])
106 button.setCheckable(True)
107 button.setAutoRaise(True)
108 button.clicked.connect(lambda _checked=False, v=view: self.set_view(v, user=True))
109 self.view_group.addButton(button)
110 self.view_buttons[DIFF].setChecked(True)
111 for button in (self.unified_button, refresh):
112 button.setToolButtonStyle(Qt.ToolButtonIconOnly)
113 button.setIconSize(QSize(18, 18))
114 button.setAutoRaise(True)
115 header = QHBoxLayout()
116 header.setSpacing(6)
117 header.addWidget(self.title_icon)
118 header.addWidget(self.title)
119 header.addSpacing(8)
120 for button in self.view_buttons.values():
121 header.addWidget(button)
122 header.addSpacing(8)
123 header.addWidget(self.subtitle, 1)
124 header.addWidget(self.unified_button)
125 header.addWidget(refresh)
127 self.files = QTreeWidget()
128 self.files.setHeaderHidden(True)
129 self.files.setRootIsDecorated(False)
130 self.files.setIndentation(10)
131 self.files.setColumnCount(2)
132 self.files.setUniformRowHeights(True)
133 self.files.currentItemChanged.connect(self._show_selected)
134 self.files.setContextMenuPolicy(Qt.CustomContextMenu)
135 self.files.customContextMenuRequested.connect(self._file_menu)
136 self.file_menu: QMenu | None = None # (kept while it's open)
137 self.file_diff = FileDiff()
138 self.file_diff.open_requested.connect(self._open_line)
139 self.splitter = QSplitter(Qt.Horizontal)
140 self.splitter.addWidget(self.files)
141 self.splitter.addWidget(self.file_diff)
142 self.splitter.setStretchFactor(0, 1)
143 self.splitter.setStretchFactor(1, 3)
145 self.empty = QLabel()
146 self.empty.setAlignment(Qt.AlignCenter)
147 self.empty.setWordWrap(True)
149 diff_page = QWidget()
150 diff_layout = QVBoxLayout(diff_page)
151 diff_layout.setContentsMargins(0, 0, 0, 0)
152 diff_layout.addWidget(self.splitter, 1)
153 diff_layout.addWidget(self.empty, 1)
154 self.stats_view = GitStatsView()
155 self.log_view = GitLogView()
156 self.log_view.open_requested.connect(self.open_requested)
157 self.log_view.reveal_requested.connect(self.reveal_requested)
158 # The pages read from the repository (see the module's description).
159 self.read_pages: dict[str, GitLogView | GitStatsView] = {HISTORY: self.log_view, STATS: self.stats_view}
160 for page in self.read_pages.values():
161 page.reload_requested.connect(self._read_view)
162 self.pages = QStackedWidget()
163 self.views: dict[str, QWidget] = {DIFF: diff_page, **self.read_pages}
164 for page in self.views.values():
165 self.pages.addWidget(page)
167 layout = QVBoxLayout(self)
168 layout.setContentsMargins(14, 10, 14, 6)
169 layout.setSpacing(8)
170 layout.addLayout(header)
171 layout.addWidget(self.pages, 1)
172 self.refresh_fonts()
173 self.show_message("Open a project in a git repository to see its changes here.")
175 # ------------------------------------------------------------ styling
176 def refresh_palette(self):
177 self.is_light = app_is_light()
178 self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
179 # (Unified, while on: a background, so it shows.)
180 self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
181 + f"#{self.objectName()} QToolButton:checked {{ background: rgba(128,128,128,70);"
182 " border: none; border-radius: 5px; }"
183 + f"#{self.objectName()} QToolButton#GitView {{ padding: 2px 10px; color: {pal['muted']}; }}"
184 + f"#{self.objectName()} QToolButton#GitView:checked {{ color: {pal['text']}; }}")
185 self.title_icon.draw(pal["text"], self.title)
186 self.unified_button.setIcon(make_icon("unified", pal["text"]))
187 self.refresh_button.setIcon(make_icon("refresh", pal["text"]))
188 for label in (self.subtitle, self.empty):
189 label.setStyleSheet(f"color: {pal['muted']};")
190 self.file_diff.refresh_style(pal["muted"])
191 for page in self.read_pages.values():
192 page.refresh_palette(pal, not self.is_light)
194 def refresh_fonts(self):
195 font = QFont(QApplication.font())
196 font.setPointSizeF(font.pointSizeF() + 2)
197 font.setWeight(QFont.Bold)
198 self.title.setFont(font)
199 self.refresh_palette()
201 def set_code_font(self, font: QFont):
202 """The diffs' font (the editor's)."""
203 self.file_diff.set_font(font)
204 self.log_view.set_code_font(font)
206 def _set_unified(self, on: bool):
207 """Unified: the diffs (the Diff page's, the History's) as one list."""
208 for diff in (self.file_diff, self.log_view.file_diff):
209 diff.set_unified(on)
211 # -------------------------------------------------------------- views
212 def view(self) -> str:
213 page = self.pages.currentWidget()
214 return next((view for view, shown in self.views.items() if shown is page), DIFF)
216 def set_view(self, view: str, user: bool = False):
217 """Show the Diff, the History or the Stats (``user``: chosen in the
218 header: said with view_changed, to be remembered)."""
219 view = view if view in self.views else DIFF
220 self.view_buttons[view].setChecked(True)
221 self.pages.setCurrentWidget(self.views[view])
222 self.unified_button.setVisible(view in (DIFF, HISTORY)) # (both have a diff)
223 self._read_view()
224 if user:
225 self.view_changed.emit(view)
227 def _refresh_clicked(self):
228 # (Read again even if HEAD hasn't moved: a new branch, a tag, a fetch… And a read under way
229 # since before: its answer may be from before, so it's dropped.)
230 self._shown.clear()
231 self._reading.clear()
232 self.refresh_requested.emit()
234 def _key(self, view: str) -> tuple:
235 """What the view's on show for: the repository, HEAD, and what the page adds (its reload_key)."""
236 root = self.status.root
237 return (root, self.status.head, *self.read_pages[view].reload_key(root))
239 def _read_view(self):
240 """The view on show (History or Stats), read again if what it's for
241 has changed (see _key)."""
242 view = self.view()
243 if view not in self.read_pages or self.status is None:
244 return
245 page, key = self.read_pages[view], self._key(view)
246 if key in (self._shown.get(view), self._reading.get(view)):
247 return
248 if self.status.head is None:
249 page.show_message(f"No commits in {self.status.root.name} yet.")
250 self._shown[view] = key
251 return
252 if self._shown.get(view) is None or self._shown[view][0] != key[0]:
253 page.show_message("Reading the repository…")
254 self._reading[view] = key
255 read = page.reader(self.status.root)
256 call_in_background(lambda: self._safely(read), lambda result: self._view_read(view, key, result), self)
258 @staticmethod
259 def _safely(read):
260 try:
261 return read()
262 except (gs.GitError, OSError) as error:
263 return str(error) or type(error).__name__
265 def _view_read(self, view: str, key: tuple, result):
266 if self._reading.get(view) != key: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 return # (another one asked for meanwhile, Refresh clicked, or the project closed)
268 self._reading.pop(view)
269 self._shown[view] = key
270 page = self.read_pages[view]
271 if isinstance(result, str): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 page.show_message(f"Couldn't read the repository's history: {result}")
273 elif result is None: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 page.show_message(f"No commits in {key[0].name} yet.")
275 else:
276 page.show_result(result)
277 if self.status is None or self._key(view) != key: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 self._read_view() # (it moved on meanwhile)
280 # -------------------------------------------------------------- state
281 def show_message(self, message: str):
282 """Nothing to list: why."""
283 self.status = None
284 self._shown.clear()
285 self._reading.clear()
286 for page in self.read_pages.values():
287 page.show_message(message)
288 self.subtitle.setText("")
289 self.empty.setText(message)
290 self.empty.show()
291 self.splitter.hide()
292 self.files.clear()
293 self.file_diff.clear()
295 def show_status(self, status: gs.RepoStatus):
296 """A repository's status; the file shown before stays selected if it's
297 still changed."""
298 current = self.selected()
299 self.status = status
300 self.subtitle.setText(status.summary())
301 self.subtitle.setToolTip(str(status.root))
302 self.files.blockSignals(True)
303 self.files.clear()
304 select = None
305 for area in gs.AREAS:
306 changes = status.in_area(area)
307 if not changes:
308 continue
309 group = QTreeWidgetItem([f"{gs.AREA_TITLES[area]} ({len(changes)})"])
310 group.setFlags(Qt.ItemIsEnabled)
311 bold = QFont(self.files.font())
312 bold.setWeight(QFont.DemiBold)
313 group.setFont(0, bold)
314 self.files.addTopLevelItem(group)
315 for change in changes:
316 item = QTreeWidgetItem([change.path, change.letter])
317 item.setData(0, CHANGE_ROLE, change)
318 item.setToolTip(0, f"{change.path}\n{gs.KIND_WORDS[change.kind]} · {gs.AREA_TITLES[area].lower()}")
319 item.setForeground(1, QBrush(QColor(LETTER_COLORS.get(change.letter, "#868e96"))))
320 item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
321 if self.icon_for is not None:
322 item.setIcon(0, self.icon_for(status.root / change.path))
323 group.addChild(item)
324 if change == current:
325 select = item
326 group.setExpanded(True)
327 self.files.header().setStretchLastSection(False)
328 self.files.header().setSectionResizeMode(0, self.files.header().ResizeMode.Stretch)
329 self.files.header().setSectionResizeMode(1, self.files.header().ResizeMode.ResizeToContents)
330 self.files.blockSignals(False)
331 self._read_view()
332 if not status.changes:
333 self.empty.setText(f"No changes in {status.root.name} (on {status.summary()}).")
334 self.empty.show()
335 self.splitter.hide()
336 self.file_diff.clear()
337 return
338 self.empty.hide()
339 self.splitter.show()
340 if select is None:
341 select = self._first_file()
342 self.files.setCurrentItem(select)
343 self._show_selected(select)
345 def selected(self) -> gs.Change | None:
346 item = self.files.currentItem()
347 return item.data(0, CHANGE_ROLE) if item is not None else None
349 def _first_file(self) -> QTreeWidgetItem | None:
350 for i in range(self.files.topLevelItemCount()): 350 ↛ 354line 350 didn't jump to line 354 because the loop on line 350 didn't complete
351 group = self.files.topLevelItem(i)
352 if group.childCount(): 352 ↛ 350line 352 didn't jump to line 350 because the condition on line 352 was always true
353 return group.child(0)
354 return None
356 def _show_selected(self, item, _previous=None):
357 change = item.data(0, CHANGE_ROLE) if item is not None else None
358 if change is None or self.status is None: 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
359 return
360 root = self.status.root
362 def read():
363 old, new, old_label, new_label = gs.versions(root, change)
364 return old, new, f"{old_label} → {new_label}" if old_label else f"new file ({new_label})"
366 self.file_diff.load((root, change), change.path, read)
368 def _file_menu(self, pos):
369 """A changed file's menu (a right-click on it): Show in Project Files,
370 Discard Changes… (asked first: see window.git)."""
371 item = self.files.itemAt(pos)
372 change = item.data(0, CHANGE_ROLE) if item is not None else None
373 if change is None or self.status is None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 return # (a group's title)
375 path = self.status.root / change.path
376 menu = self.file_menu = QMenu(self)
377 show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
378 if not path.exists():
379 show.setEnabled(False)
380 show.setText("Show in Project Files (deleted)")
381 menu.addSeparator()
382 discard = menu.addAction("Delete File…" if change.area == gs.UNTRACKED else "Discard Changes…",
383 lambda: self.discard_requested.emit(change))
384 if change.area == gs.CONFLICT: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 discard.setEnabled(False)
386 discard.setText("Discard Changes… (resolve the conflict, or abort the merge, first)")
387 menu.aboutToHide.connect(menu.deleteLater)
388 menu.popup(self.files.viewport().mapToGlobal(pos))
390 def _open_line(self, line: int):
391 change = self.selected()
392 if change is not None and self.status is not None: 392 ↛ exitline 392 didn't return from function '_open_line' because the condition on line 392 was always true
393 path = self.status.root / change.path
394 if Path(path).is_file(): 394 ↛ exitline 394 didn't return from function '_open_line' because the condition on line 394 was always true
395 self.open_requested.emit(str(path), line)