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

1"""The *Git* tab, with three views to switch between in its header: 

2 

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`). 

12 

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. 

18 

19What git says is read on a pool thread. 

20""" 

21 

22from __future__ import annotations 

23 

24from pathlib import Path 

25 

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) 

42 

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 

51 

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"} 

60 

61 

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.""" 

66 

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 

72 

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) 

83 

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) 

126 

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) 

144 

145 self.empty = QLabel() 

146 self.empty.setAlignment(Qt.AlignCenter) 

147 self.empty.setWordWrap(True) 

148 

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) 

166 

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.") 

174 

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) 

193 

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() 

200 

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) 

205 

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) 

210 

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) 

215 

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) 

226 

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() 

233 

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)) 

238 

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) 

257 

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__ 

264 

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) 

279 

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() 

294 

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) 

344 

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 

348 

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 

355 

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 

361 

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})" 

365 

366 self.file_diff.load((root, change), change.path, read) 

367 

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)) 

389 

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)