Coverage for vibefpmgui/git_log_view.py: 92%

394 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The Git panel's *History* page (see `vibefpmgui.git_log`): the commits, 

2newest first, each with the graph's lanes (its dot, the lines to its 

3parents: a column of their own, wide enough for every lane), its branches 

4and tags (badges: the branch checked out in bold), subject, author, date 

5and hash; every branch's, or only what's checked out (*All branches*). The 

6newest MAX_COMMITS at first; Load Older Commits lists the rest, back to the 

7first. Choosing a commit shows its card (as git blame's: the 

8whole message, links to its web page and issues), the files it changed, and 

9the chosen file's diff (double-click a line: the file, there). 

10""" 

11 

12from __future__ import annotations 

13 

14import time 

15from pathlib import Path 

16 

17from qtpy.QtCore import ( 

18 QAbstractTableModel, 

19 QModelIndex, 

20 QPointF, 

21 QRectF, 

22 QSize, 

23 Qt, 

24 Signal, 

25) 

26from qtpy.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen 

27from qtpy.QtWidgets import ( 

28 QApplication, 

29 QHBoxLayout, 

30 QHeaderView, 

31 QLabel, 

32 QMenu, 

33 QPushButton, 

34 QSplitter, 

35 QStackedLayout, 

36 QStyle, 

37 QStyledItemDelegate, 

38 QTextBrowser, 

39 QToolButton, 

40 QTreeView, 

41 QTreeWidget, 

42 QTreeWidgetItem, 

43 QVBoxLayout, 

44 QWidget, 

45) 

46 

47from . import git_blame as gb 

48from . import git_log as gl 

49from . import git_status as gs 

50from .background import call_in_background 

51from .file_diff import FileDiff 

52 

53COLUMNS = ["Graph", "Commit", "Author", "Date", "Hash"] 

54GRAPH, SUBJECT = 0, 1 # (the columns drawn by GraphDelegate) 

55GRAPH_SHARE = 0.6 # the most of the list's width the graph is given at first (wider: drag its edge) 

56LANE_COLORS = { 

57 "light": ["#4c6ef5", "#e8590c", "#2f9e44", "#ae3ec9", "#1098ad", "#e03131", "#f08c00", "#5c940d"], 

58 "dark": ["#748ffc", "#ff922b", "#51cf66", "#da77f2", "#3bc9db", "#ff6b6b", "#fcc419", "#94d82d"], 

59} 

60REF_COLORS = {gl.HEAD: "#e03131", gl.BRANCH: "#2f9e44", gl.REMOTE: "#1c7ed6", gl.TAG: "#e8a317"} 

61COMMIT_ROLE = Qt.UserRole + 1 # a row's git_log.LogCommit 

62ROW_ROLE = Qt.UserRole + 2 # and its git_log.GraphRow 

63CHANGE_ROLE = Qt.UserRole + 3 # a changed file's git_blame.FileChange 

64 

65 

66def date_words(seconds: int) -> str: 

67 return time.strftime("%Y-%m-%d %H:%M", time.localtime(seconds)) if seconds else "" 

68 

69 

70class LogModel(QAbstractTableModel): 

71 def __init__(self, parent=None): 

72 super().__init__(parent) 

73 self.log: gl.Log | None = None 

74 self.lanes = 1 # the graph's width (lanes), the widest row's (every lane: none cut off) 

75 

76 def set_log(self, log: gl.Log | None): 

77 self.beginResetModel() 

78 self.log = log 

79 self.lanes = max((r.width for r in log.rows), default=1) if log else 1 

80 self.endResetModel() 

81 

82 def rowCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature 

83 return 0 if parent.isValid() or self.log is None else len(self.log.commits) 

84 

85 def columnCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature 

86 return 0 if parent.isValid() else len(COLUMNS) 

87 

88 def headerData(self, section, orientation, role=Qt.DisplayRole): 

89 if orientation == Qt.Horizontal and role == Qt.DisplayRole: 

90 return COLUMNS[section] 

91 return None 

92 

93 def data(self, index, role=Qt.DisplayRole): 

94 if not index.isValid() or self.log is None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 return None 

96 commit = self.log.commits[index.row()] 

97 column = index.column() 

98 if role == Qt.DisplayRole: 

99 return ["", commit.subject, commit.author, date_words(commit.time), commit.sha[:8]][column] 

100 if role == Qt.ToolTipRole: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 refs = ", ".join(r.name for r in commit.refs) 

102 subject = f"{commit.subject}" + (f"\n{refs}" if refs else "") 

103 return [subject, subject, f"{commit.author} <{commit.email}>", None, commit.sha][column] 

104 if role == COMMIT_ROLE: 

105 return commit 

106 if role == ROW_ROLE: 

107 return self.log.rows[index.row()] 

108 return None 

109 

110 

111def lane_width(font: QFont) -> float: 

112 """A lane's width in the graph, for the list's font.""" 

113 return max(12.0, QFontMetrics(font).height() * 0.8) 

114 

115 

116def graph_width(lanes: int, font: QFont) -> int: 

117 """The Graph column's width for every one of ``lanes``.""" 

118 return round(8 + lane_width(font) * lanes) 

119 

120 

121class GraphDelegate(QStyledItemDelegate): 

122 """The Graph column (the lanes, the commit's dot) and the Commit column 

123 (its branch and tag badges, its subject).""" 

124 

125 def __init__(self, view: GitLogView): 

126 super().__init__(view.table) 

127 self.view = view 

128 

129 def sizeHint(self, option, index): 

130 size = super().sizeHint(option, index) 

131 return QSize(size.width(), max(size.height(), round(QFontMetrics(option.font).height() * 1.5))) 

132 

133 def paint(self, painter, option, index): 

134 commit: gl.LogCommit = index.data(COMMIT_ROLE) 

135 row: gl.GraphRow = index.data(ROW_ROLE) 

136 style = option.widget.style() if option.widget else QApplication.style() 

137 style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget) 

138 painter.save() 

139 painter.setRenderHint(QPainter.Antialiasing) 

140 rect = QRectF(option.rect) 

141 if index.column() == SUBJECT: 

142 self._paint_subject(painter, option, commit, rect) 

143 painter.restore() 

144 return 

145 lane = lane_width(option.font) 

146 colors = LANE_COLORS["dark" if self.view.dark else "light"] 

147 top, bottom, middle = rect.top(), rect.bottom() + 1, rect.center().y() 

148 

149 def x(i: int) -> float: 

150 return rect.left() + 4 + lane * (i + 0.5) 

151 

152 def pen(color: int) -> QPen: 

153 line = QPen(QColor(colors[color % len(colors)]), 2) 

154 line.setCapStyle(Qt.RoundCap) 

155 return line 

156 

157 painter.setClipRect(rect) # (the column made narrower than the graph: the rest cut off) 

158 for i, color in row.through: 

159 painter.setPen(pen(color)) 

160 painter.drawLine(QPointF(x(i), top), QPointF(x(i), bottom)) 

161 for i, color in row.joining: # (from its lane above, curving into the commit) 

162 painter.setPen(pen(color)) 

163 path = QPainterPath(QPointF(x(i), top)) 

164 path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(row.lane), middle)) 

165 painter.drawPath(path) 

166 if row.from_above: 

167 painter.setPen(pen(row.color)) 

168 painter.drawLine(QPointF(x(row.lane), top), QPointF(x(row.lane), middle)) 

169 for i, color in row.parents: 

170 painter.setPen(pen(color)) 

171 if i == row.lane: 

172 painter.drawLine(QPointF(x(i), middle), QPointF(x(i), bottom)) 

173 else: # (a merge's other parent, or a lane waiting for it: curving out to it) 

174 path = QPainterPath(QPointF(x(row.lane), middle)) 

175 path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(i), bottom)) 

176 painter.drawPath(path) 

177 radius = lane * 0.28 

178 dot = QColor(colors[row.color % len(colors)]) 

179 center = QPointF(x(row.lane), middle) 

180 is_head = self.view.model.log is not None and commit.sha == self.view.model.log.head 

181 if len(commit.parents) > 1: # (a merge: a ring) 

182 painter.setPen(QPen(dot, 2)) 

183 painter.setBrush(QBrush(QColor(self.view.pal.get("bg", "#ffffff")))) 

184 else: 

185 painter.setPen(Qt.NoPen) 

186 painter.setBrush(QBrush(dot)) 

187 painter.drawEllipse(center, radius, radius) 

188 if is_head: # (what's checked out: a ring around it) 

189 painter.setPen(QPen(QColor(self.view.pal.get("text", "#000000")), 1.5)) 

190 painter.setBrush(Qt.NoBrush) 

191 painter.drawEllipse(center, radius + 3, radius + 3) 

192 painter.restore() 

193 

194 def _paint_subject(self, painter, option, commit: gl.LogCommit, rect: QRectF): 

195 """The commit's branches and tags (badges), then its subject.""" 

196 selected = bool(option.state & QStyle.State_Selected) 

197 middle = rect.center().y() 

198 left = rect.left() + 4 

199 small = QFont(option.font) 

200 small.setPointSizeF(max(7.0, option.font.pointSizeF() - 1.5)) 

201 badge_metrics = QFontMetrics(small) 

202 for ref in commit.refs: 

203 if left > rect.right() - 40: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 break 

205 bold = QFont(small) 

206 bold.setBold(ref.current) 

207 text_width = QFontMetrics(bold).horizontalAdvance(ref.name) 

208 badge = QRectF(left, middle - badge_metrics.height() / 2 - 1, text_width + 10, badge_metrics.height() + 2) 

209 color = QColor(REF_COLORS[ref.kind]) 

210 solid = ref.current or selected # (on the selection's color: solid, so it shows) 

211 painter.setPen(QPen(QColor("white") if selected else color, 1.2 if ref.current else 1)) 

212 fill = QColor(color) 

213 fill.setAlphaF(0.9 if solid else 0.18) 

214 painter.setBrush(fill) 

215 painter.drawRoundedRect(badge, 4, 4) 

216 painter.setFont(bold) 

217 painter.setPen(QColor("white") if solid else color if not self.view.dark else color.lighter(130)) 

218 painter.drawText(badge, Qt.AlignCenter, ref.name) 

219 left = badge.right() + 4 

220 painter.setFont(option.font) 

221 painter.setPen(option.palette.color(option.palette.ColorRole.HighlightedText if selected 

222 else option.palette.ColorRole.Text)) 

223 text_rect = QRectF(left + 2, rect.top(), rect.right() - left - 4, rect.height()) 

224 painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft, 

225 QFontMetrics(option.font).elidedText(commit.subject, Qt.ElideRight, int(text_rect.width()))) 

226 

227 

228class GitLogView(QWidget): 

229 open_requested = Signal(str, int) # a file of the working tree, a line 

230 reveal_requested = Signal(str) # a changed file's "Show in Project Files": its path now 

231 all_branches_changed = Signal(bool) # All branches toggled 

232 reload_requested = Signal() # (All branches toggled, older commits asked for: see reload_key) 

233 

234 def __init__(self, parent=None): 

235 super().__init__(parent) 

236 self.log: gl.Log | None = None 

237 self.pal: dict = {} 

238 self.dark = False 

239 self.icon_for = None # a file's QIcon (set by the panel) 

240 self.everything: set[Path] = set() # repositories whose every commit is listed (Load Older Commits) 

241 self._graph_lanes = 0 # (the lanes the Graph column was last made wide enough for) 

242 self._details: tuple[str, object] | None = None # (a commit, its details: CommitDetails, None 

243 # while they're read, or why git couldn't), the one chosen 

244 

245 self.all_branches = QToolButton() 

246 self.all_branches.setObjectName("GitView") 

247 self.all_branches.setText("All branches") 

248 self.all_branches.setToolTip("Every branch's commits (local and remote) and tags', or only those of what's " 

249 "checked out") 

250 self.all_branches.setCheckable(True) 

251 self.all_branches.setChecked(True) 

252 self.all_branches.setAutoRaise(True) 

253 self.all_branches.clicked.connect(self._all_branches_clicked) 

254 self.count = QLabel() 

255 self.older_button = QPushButton("Load Older Commits") 

256 self.older_button.setToolTip("List every commit, back to the first one (at first, only the newest " 

257 f"{gl.MAX_COMMITS:,} are)") 

258 self.older_button.clicked.connect(self.load_older) 

259 self.older_button.hide() 

260 bar = QHBoxLayout() 

261 bar.setContentsMargins(0, 0, 0, 0) 

262 bar.addWidget(self.count) 

263 bar.addWidget(self.older_button) 

264 bar.addStretch(1) 

265 bar.addWidget(self.all_branches) 

266 

267 self.model = LogModel(self) 

268 self.table = QTreeView() 

269 self.table.setModel(self.model) 

270 self.table.setRootIsDecorated(False) 

271 self.table.setUniformRowHeights(True) 

272 self.table.setAllColumnsShowFocus(True) 

273 delegate = GraphDelegate(self) 

274 self.table.setItemDelegateForColumn(GRAPH, delegate) 

275 self.table.setItemDelegateForColumn(SUBJECT, delegate) 

276 self.table.header().setStretchLastSection(False) 

277 self.table.header().setSectionResizeMode(SUBJECT, QHeaderView.Stretch) 

278 for column in (GRAPH, 2, 3, 4): 

279 self.table.header().setSectionResizeMode(column, QHeaderView.Interactive) 

280 self.table.selectionModel().currentRowChanged.connect(self._commit_chosen) 

281 

282 self.card = QTextBrowser() 

283 self.card.setOpenLinks(False) 

284 self.card.anchorClicked.connect(self._link) 

285 self.files = QTreeWidget() 

286 self.files.setHeaderHidden(True) 

287 self.files.setRootIsDecorated(False) 

288 self.files.setColumnCount(3) # (the file, its lines added and deleted, its letter: M, A, D…) 

289 self.files.setUniformRowHeights(True) 

290 self.files.currentItemChanged.connect(self._file_chosen) 

291 self.files.setContextMenuPolicy(Qt.CustomContextMenu) 

292 self.files.customContextMenuRequested.connect(self._file_menu) 

293 self.file_menu: QMenu | None = None # (kept while it's open) 

294 self.file_diff = FileDiff() 

295 self.file_diff.open_requested.connect(self._open_line) 

296 details = QSplitter(Qt.Vertical) 

297 details.addWidget(self.card) 

298 details.addWidget(self.files) 

299 details.setStretchFactor(0, 3) 

300 details.setStretchFactor(1, 2) 

301 self.bottom = QSplitter(Qt.Horizontal) 

302 self.bottom.addWidget(details) 

303 self.bottom.addWidget(self.file_diff) 

304 self.bottom.setStretchFactor(0, 2) 

305 self.bottom.setStretchFactor(1, 3) 

306 self.splitter = QSplitter(Qt.Vertical) 

307 self.splitter.addWidget(self.table) 

308 self.splitter.addWidget(self.bottom) 

309 self.splitter.setStretchFactor(0, 3) 

310 self.splitter.setStretchFactor(1, 2) 

311 

312 content = QWidget() 

313 layout = QVBoxLayout(content) 

314 layout.setContentsMargins(0, 0, 0, 0) 

315 layout.setSpacing(4) 

316 layout.addLayout(bar) 

317 layout.addWidget(self.splitter, 1) 

318 self.message = QLabel() 

319 self.message.setAlignment(Qt.AlignCenter) 

320 self.message.setWordWrap(True) 

321 self.stack = QStackedLayout(self) 

322 self.stack.addWidget(self.message) 

323 self.stack.addWidget(content) 

324 self.content = content 

325 self.show_message("Reading the repository…") 

326 

327 # ------------------------------------------------------------ styling 

328 def refresh_palette(self, pal: dict, dark: bool): 

329 """New colors: drawn again from what's been read (git isn't asked again; 

330 the commit and file chosen, and the diff's place, stay).""" 

331 self.pal, self.dark = pal, dark 

332 for label in (self.count, self.message): 

333 label.setStyleSheet(f"color: {pal['muted']};") 

334 self.file_diff.refresh_style(pal["muted"]) 

335 self.table.viewport().update() 

336 commit = self.current_commit() 

337 if commit is not None and self._details is not None and self._details[0] == commit.sha: 

338 self._show_card(commit, self._details[1]) 

339 muted = QBrush(QColor(pal["muted"])) 

340 for i in range(self.files.topLevelItemCount()): 

341 self.files.topLevelItem(i).setForeground(1, muted) 

342 

343 def set_code_font(self, font: QFont): 

344 self.file_diff.set_font(font) 

345 

346 # ------------------------------------------------------- read by the panel 

347 # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.) 

348 def reload_key(self, root: Path) -> tuple: 

349 """What, besides the repository and HEAD, the history read depends on: 

350 All branches, and whether every commit is listed (Load Older Commits).""" 

351 return self.all_branches.isChecked(), root in self.everything 

352 

353 def reader(self, root: Path): 

354 """What reads it (on a pool thread).""" 

355 all_branches = self.all_branches.isChecked() 

356 limit = None if root in self.everything else gl.MAX_COMMITS 

357 return lambda: gl.read_log(root, all_branches=all_branches, limit=limit) 

358 

359 def load_older(self): 

360 """Load Older Commits: every commit listed, back to the first (this 

361 repository's, from now on while the app runs).""" 

362 if self.log is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 return 

364 self.everything.add(self.log.root) 

365 self.older_button.setEnabled(False) 

366 self.older_button.setText("Reading every commit…") 

367 self.reload_requested.emit() 

368 

369 def show_result(self, log: gl.Log): 

370 self.show_log(log) 

371 

372 # -------------------------------------------------------------- state 

373 def show_message(self, text: str): 

374 self.log = None 

375 self.model.set_log(None) 

376 self.older_button.hide() 

377 self.message.setText(text) 

378 self.stack.setCurrentWidget(self.message) 

379 

380 def show_log(self, log: gl.Log): 

381 """A repository's history; the commit chosen before stays chosen if it's still listed.""" 

382 before = self.current_commit() 

383 self.log = log 

384 self.model.set_log(log) 

385 self.all_branches.setChecked(log.all_branches) 

386 n = len(log.commits) 

387 self.count.setText(f"{'The newest ' if log.limited else ''}{n:,} commit{'s' * (n != 1)}" 

388 + (f" of {log.total:,}" if log.limited else "") 

389 + (" of every branch" if log.all_branches else " of what's checked out")) 

390 older = log.total - n 

391 self.older_button.setVisible(log.limited) 

392 self.older_button.setEnabled(True) 

393 self.older_button.setText(f"Load the {older:,} Older Commit{'s' * (older != 1)}") 

394 metrics = QFontMetrics(self.table.font()) 

395 self.table.setColumnWidth(2, 160) 

396 self.table.setColumnWidth(3, metrics.horizontalAdvance("0000-00-00 00:00") + 16) 

397 self.table.setColumnWidth(4, metrics.horizontalAdvance("00000000") + 16) 

398 if self.model.lanes != self._graph_lanes: # (more lanes, or fewer: room for each; dragged since: kept) 

399 self._graph_lanes = self.model.lanes 

400 width = graph_width(self.model.lanes, self.table.font()) 

401 room = self.table.viewport().width() 

402 self.table.setColumnWidth(GRAPH, min(width, round(room * GRAPH_SHARE)) if room > 200 else width) 

403 self.stack.setCurrentWidget(self.content) 

404 row = next((i for i, c in enumerate(log.commits) if before and c.sha == before.sha), None) 

405 if row is None: 

406 row = next((i for i, c in enumerate(log.commits) if c.sha == log.head), 0) 

407 self.table.setCurrentIndex(self.model.index(row, 0)) 

408 self.table.scrollTo(self.model.index(row, 0), QTreeView.PositionAtCenter) 

409 

410 def current_commit(self) -> gl.LogCommit | None: 

411 index = self.table.currentIndex() 

412 return index.data(COMMIT_ROLE) if index.isValid() else None 

413 

414 def _all_branches_clicked(self, on: bool): 

415 self.all_branches_changed.emit(on) 

416 self.reload_requested.emit() 

417 

418 # ------------------------------------------------------------ a commit 

419 def _commit_chosen(self, index, _previous=None): 

420 commit = index.data(COMMIT_ROLE) if index.isValid() else None 

421 self.files.clear() 

422 self.file_diff.clear() 

423 if commit is None or self.log is None: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 self._details = None 

425 self.card.clear() 

426 return 

427 self._details = (commit.sha, None) 

428 self._show_card(commit, None) 

429 root = self.log.root 

430 

431 def read(): 

432 try: 

433 return gb.commit_details(root, commit.sha) 

434 except (gs.GitError, OSError) as error: 

435 return str(error) or type(error).__name__ 

436 

437 call_in_background(read, lambda result: self._details_read(commit, result), self) 

438 

439 def _show_card(self, commit: gl.LogCommit, details): 

440 shown = details 

441 if isinstance(details, gb.CommitDetails): # (its files: in the list under it) 

442 shown = gb.CommitDetails(details.email, details.message, details.committer, details.commit_time, None) 

443 text = QColor(self.pal.get("text", "#000000")) 

444 html = gb.commit_card_html(gb.Commit(commit.sha, commit.author, commit.time, commit.subject), shown, 

445 self.log.remote if self.log else "", text) 

446 self.card.setHtml(html) 

447 

448 def _details_read(self, commit: gl.LogCommit, result): 

449 if self._details is None or self._details[0] != commit.sha: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true

450 return # (another commit chosen meanwhile) 

451 self._details = (commit.sha, result) 

452 self._show_card(commit, result) 

453 if not isinstance(result, gb.CommitDetails): 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 return 

455 for change in result.files or []: 

456 name = f"{change.old_path} → {change.path}" if change.old_path else change.path 

457 counts = "binary" if change.added is None else f"+{change.added} −{change.deleted}" 

458 item = QTreeWidgetItem([name, counts, change.status]) 

459 item.setData(0, CHANGE_ROLE, change) 

460 word, color = gb.STATUS_CHIPS.get(change.status, (change.status, "#868e96")) 

461 item.setToolTip(0, f"{change.path}\n{word}") 

462 item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter) 

463 item.setForeground(1, QBrush(QColor(self.pal.get("muted", "#888888")))) 

464 item.setForeground(2, QBrush(QColor(color))) 

465 item.setToolTip(2, word) 

466 if self.icon_for is not None and self.log is not None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 item.setIcon(0, self.icon_for(self.log.root / change.path)) 

468 self.files.addTopLevelItem(item) 

469 header = self.files.header() 

470 header.setStretchLastSection(False) 

471 header.setSectionResizeMode(0, QHeaderView.Stretch) 

472 header.setSectionResizeMode(1, QHeaderView.ResizeToContents) 

473 header.setSectionResizeMode(2, QHeaderView.ResizeToContents) 

474 if self.files.topLevelItemCount(): 474 ↛ exitline 474 didn't return from function '_details_read' because the condition on line 474 was always true

475 self.files.setCurrentItem(self.files.topLevelItem(0)) 

476 

477 # -------------------------------------------------------------- a file 

478 def _file_chosen(self, item, _previous=None): 

479 change = item.data(0, CHANGE_ROLE) if item is not None else None 

480 commit = self.current_commit() 

481 if change is None or commit is None or self.log is None: 

482 return 

483 root = self.log.root 

484 

485 def read(): 

486 before, after = gl.file_versions(root, commit, change) 

487 parent = commit.parents[0][:8] if commit.parents else "" 

488 return before, after, f"{parent} → {commit.sha[:8]}" if parent else f"added in {commit.sha[:8]}" 

489 

490 self.file_diff.load((commit.sha, change.path), change.path, read) 

491 

492 def _file_menu(self, pos): 

493 """A changed file's menu (a right-click on it): Show in Project Files, 

494 if it's still there (where the commit left it) in the working tree.""" 

495 item = self.files.itemAt(pos) 

496 change = item.data(0, CHANGE_ROLE) if item is not None else None 

497 if change is None or self.log is None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 return 

499 path = self.log.root / change.path 

500 menu = self.file_menu = QMenu(self) 

501 show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path))) 

502 if change.status == "D" or not path.exists(): 

503 show.setEnabled(False) 

504 show.setText("Show in Project Files (not there now)") 

505 menu.aboutToHide.connect(menu.deleteLater) 

506 menu.popup(self.files.viewport().mapToGlobal(pos)) 

507 

508 def _open_line(self, line: int): 

509 item = self.files.currentItem() 

510 change = item.data(0, CHANGE_ROLE) if item is not None else None 

511 if change is not None and self.log is not None and (self.log.root / change.path).is_file(): 511 ↛ exitline 511 didn't return from function '_open_line' because the condition on line 511 was always true

512 self.open_requested.emit(str(self.log.root / change.path), line) 

513 

514 def _link(self, url): 

515 target = url.toString() 

516 if target.startswith("copy:"): 

517 QApplication.clipboard().setText(target.removeprefix("copy:")) 

518 elif target.startswith(("http:", "https:")): 

519 gb.open_web_page(target)