Coverage for vibefpmgui/git_blame.py: 89%

438 statements  

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

1"""View → Show Git Blame: beside the editor (left of the minimap), for each 

2line of the file, the commit that last changed it — its short hash, author 

3and date — scrolling with the text. Each run of lines from one commit is a 

4card spanning them, labelled at its top, tinted and with a bar in the 

5color of the commit's age (see AGE_BANDS: this week, this month, this 

6year, older — the same in every file). A card lights up under the mouse and 

7sinks when pressed, as a button: click it to open the commit on GitHub or 

8GitLab (the repository's ``origin``), or, with no web page for it, to copy 

9its hash; hover it for the commit's message. 

10 

11The blame is of the text in the editor (``git blame --contents``), so its 

12lines stay matched while you edit: lines changed since the last commit say 

13"Not committed yet". It's read off the window's thread, again once typing 

14pauses and when the file is saved. 

15""" 

16 

17from __future__ import annotations 

18 

19import os 

20import re 

21import tempfile 

22import time 

23from dataclasses import dataclass 

24from pathlib import Path 

25 

26from qtpy.QtCore import QEvent, QPoint, QRect, QRectF, QSize, Qt, QUrl 

27from qtpy.QtGui import ( 

28 QColor, 

29 QDesktopServices, 

30 QFont, 

31 QFontMetrics, 

32 QPainter, 

33 QPalette, 

34 QPen, 

35) 

36from qtpy.QtWidgets import QApplication, QToolTip, QWidget 

37 

38from . import git_status as gs 

39from .background import call_in_background 

40 

41NOT_COMMITTED = "0" * 40 # (git's hash for lines changed since the last commit) 

42HEADER_RE = re.compile(r"^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$") 

43CHARACTERS = 34 # the column's width, in characters ("a1b2c3d Jacob Williams 2026-10-12") 

44DAY = 86_400 # seconds 

45# A commit's age: (younger than this many days, its name, its color, how strongly shown). The same in every file. 

46AGE_BANDS = ((7, "this week", "#f08c00", 1.0), (31, "this month", "#e3b341", 0.9), 

47 (365, "this year", "#7b9cc4", 0.8), (None, "older", "#9a9a9a", 0.55)) 

48 

49 

50def age_band(commit_time: int, now: float | None = None) -> tuple[str, str, float]: 

51 """A commit's age band: (its name, its color, how strongly it's shown).""" 

52 days = ((now if now is not None else time.time()) - commit_time) / DAY 

53 for limit, name, color, strength in AGE_BANDS: 53 ↛ 56line 53 didn't jump to line 56 because the loop on line 53 didn't complete

54 if limit is None or days < limit: 

55 return name, color, strength 

56 raise AssertionError("the last band has no limit") 

57 

58 

59def age_words(commit_time: int, now: float | None = None) -> str: 

60 """ "this month (12 days ago)", "older (3 years ago)"…""" 

61 days = max(0, int(((now if now is not None else time.time()) - commit_time) // DAY)) 

62 ago = ("today" if days == 0 else "yesterday" if days == 1 else f"{days} days ago" if days < 60 

63 else f"{days // 30} months ago" if days < 730 else f"{days // 365} years ago") 

64 return f"{age_band(commit_time, now)[0]} ({ago})" 

65 

66 

67@dataclass 

68class Commit: 

69 sha: str 

70 author: str = "" 

71 time: int = 0 # seconds since 1970 (its author's) 

72 summary: str = "" # the first line of its message 

73 

74 @property 

75 def committed(self) -> bool: 

76 return self.sha != NOT_COMMITTED 

77 

78 def date(self) -> str: 

79 return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else "" 

80 

81 

82@dataclass 

83class Blame: 

84 lines: list[str] # each line's commit (its hash), the file's lines in order 

85 commits: dict[str, Commit] 

86 remote: str = "" # the repository's origin (for the commits' web pages) 

87 root: Path | None = None # the repository's top folder (for a commit's details) 

88 

89 

90@dataclass 

91class FileChange: 

92 status: str # A, M, D, R, C, T (git's letters) 

93 path: str 

94 old_path: str = "" # a renamed or copied file's name before 

95 added: int | None = None # lines (None: a binary file) 

96 deleted: int | None = None 

97 

98 

99@dataclass 

100class CommitDetails: 

101 """What the tooltip shows of a commit, besides what the blame says.""" 

102 email: str = "" 

103 message: str = "" # the whole of it 

104 committer: str = "" # (when not the author: a rebase, a patch applied by someone else) 

105 commit_time: int = 0 

106 files: list[FileChange] | None = None 

107 

108 

109MAX_FILES = 30 # files listed in the tooltip (more: "and N more") 

110 

111 

112def commit_details(root: Path, sha: str) -> CommitDetails: 

113 """A commit's message, its author's email, its committer, and the files it 

114 changed (each with its lines added and deleted; a merge's: what it 

115 brought into its first parent, not git's "combined" list, which leaves 

116 out what came from one side only). Raises GitError.""" 

117 header = gs._check(root, "show", "-s", "--no-color", "--format=%ae%x00%cn%x00%ct%x00%an%x00%B", sha) 

118 email, committer, commit_time, author, message = (header.decode("utf-8", "replace").split("\0", 4) + [""] * 5)[:5] 

119 statuses = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--name-status", 

120 "-M", "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 

121 counts = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--numstat", "-M", 

122 "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 

123 files = [] 

124 for status_line, count_line in zip(statuses, counts, strict=False): 

125 parts = status_line.split("\t") 

126 letter = parts[0][:1] 

127 path, old = (parts[2], parts[1]) if letter in "RC" and len(parts) > 2 else (parts[-1], "") 

128 added, deleted, *_ = count_line.split("\t") + ["", ""] 

129 files.append(FileChange(letter, path, old, int(added) if added.isdigit() else None, 

130 int(deleted) if deleted.isdigit() else None)) 

131 return CommitDetails(email.strip(), message.strip(), committer.strip() if committer.strip() != author.strip() 

132 else "", int(commit_time) if commit_time.strip().isdigit() else 0, files) 

133 

134 

135def parse_porcelain(output: str) -> tuple[list[str], dict[str, Commit]]: 

136 """``git blame --porcelain`` read: each line's commit, and the commits.""" 

137 lines: dict[int, str] = {} 

138 commits: dict[str, Commit] = {} 

139 current = None 

140 for row in output.split("\n"): 

141 if row.startswith("\t"): 

142 continue # (the line's text) 

143 match = HEADER_RE.match(row) 

144 if match: 

145 current = commits.setdefault(match.group(1), Commit(match.group(1))) 

146 lines[int(match.group(3))] = current.sha 

147 continue 

148 if current is None: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 continue 

150 key, _, value = row.partition(" ") 

151 if key == "author": 

152 current.author = "You" if current.sha == NOT_COMMITTED else value 

153 elif key == "author-time" and value.isdigit(): 

154 current.time = int(value) 

155 elif key == "summary": 

156 current.summary = value 

157 return [lines.get(n, NOT_COMMITTED) for n in range(1, max(lines, default=0) + 1)], commits 

158 

159 

160def blame(path: Path, contents: str | bytes) -> Blame | None: 

161 """The blame of ``contents`` as the file ``path`` (None: it isn't in a git 

162 repository, or git doesn't know it). ``contents``: the bytes the file would 

163 have, saved (its line endings and encoding: the editor's text has "\n", 

164 and a file committed with "\r\n" would otherwise differ on every line). 

165 Raises GitError if git fails.""" 

166 root = gs.find_repository(path.parent) # (a folder: the file's) 

167 if root is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 return None 

169 relative = path.resolve().relative_to(root.resolve()).as_posix() 

170 data = contents.encode("utf-8") if isinstance(contents, str) else contents 

171 with tempfile.NamedTemporaryFile("wb", suffix=path.suffix, delete=False) as file: 

172 file.write(data) 

173 try: 

174 code, output, message = gs._git(root, "blame", "--porcelain", "--contents", file.name, "--", relative) 

175 finally: 

176 os.unlink(file.name) 

177 if code != 0: 

178 if "no such path" in message or "not tracked" in message or "fatal: no such ref" in message: 178 ↛ 180line 178 didn't jump to line 180 because the condition on line 178 was always true

179 return None # (a file git doesn't know yet) 

180 raise gs.GitError(message.strip().splitlines()[-1] if message.strip() else "git blame failed") 

181 lines, commits = parse_porcelain((output or b"").decode("utf-8", "replace")) 

182 _code, remote, _message = gs._git(root, "config", "--get", "remote.origin.url") 

183 return Blame(lines, commits, (remote or b"").decode().strip(), root) 

184 

185 

186def short_name(author: str, room: int = 14) -> str: 

187 """An author's name, cut to fit: "Jacob Williams" or "Jacob W.".""" 

188 if len(author) <= room: 

189 return author 

190 first, *rest = author.split() 

191 return f"{first} {rest[-1][0]}." if rest and len(first) + 3 <= room else author[:room - 1] + "…" 

192 

193 

194class BlameColumn(QWidget): 

195 """The column (see the module's description).""" 

196 

197 def __init__(self, editor, parent=None): 

198 super().__init__(parent) 

199 self.editor = editor 

200 self.blame: Blame | None = None 

201 self.message = "" # (why there's no blame: shown at the top) 

202 self._hover: int | None = None # the card under the mouse (its first line) 

203 self._pressed: int | None = None # the card being clicked (its first line) 

204 self._details: dict[str, CommitDetails | str] = {} # a commit's details (or why git couldn't), once read 

205 self._loading: set[str] = set() 

206 self._card_at = QPoint() # (where the mouse rested: the card is shown there) 

207 self.setMouseTracking(True) 

208 self.setAttribute(Qt.WA_OpaquePaintEvent) 

209 editor.updateRequest.connect(lambda *_: self.update()) # (scrolled, edited: drawn again with it) 

210 self.refresh_font() 

211 

212 # ------------------------------------------------------------ contents 

213 def set_blame(self, blame: Blame | None, message: str = ""): 

214 self.blame, self.message = blame, message 

215 if self._hover is not None: # (its lines may have moved: tinted again as the mouse moves) 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true

216 self._hover = None 

217 self.editor.show_commit_lines(None) 

218 self.update() 

219 

220 def refresh_font(self): 

221 font = QFont(QApplication.font()) 

222 line = self.editor.fontMetrics().height() 

223 font.setPixelSize(max(8, int(line * 0.72))) 

224 self.setFont(font) 

225 self.setFixedWidth(QFontMetrics(font).horizontalAdvance("0" * CHARACTERS) + 12) 

226 

227 def sizeHint(self) -> QSize: 

228 return QSize(self.width(), 100) 

229 

230 # ------------------------------------------------------------ where 

231 def _visible_lines(self): 

232 """(line index, top y, height) of each line on view.""" 

233 editor = self.editor 

234 block = editor.firstVisibleBlock() 

235 offset = editor.contentOffset() 

236 top = editor.blockBoundingGeometry(block).translated(offset).top() 

237 while block.isValid() and top <= self.height(): 

238 height = editor.blockBoundingRect(block).height() 

239 if block.isVisible(): 239 ↛ 241line 239 didn't jump to line 241 because the condition on line 239 was always true

240 yield block.blockNumber(), top, height 

241 top += height 

242 block = block.next() 

243 

244 def runs(self): 

245 """The cards on view: (first line, commit, top y, bottom y) for each run 

246 of lines from one commit (a run starting above the view: from its top).""" 

247 if self.blame is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 return 

249 run = None 

250 for line, top, height in self._visible_lines(): 

251 sha = self.blame.lines[line] if line < len(self.blame.lines) else None 

252 commit = self.blame.commits.get(sha) if sha else None 

253 if run is not None and commit is run[1] and line == run[4] + 1: 

254 run[3], run[4] = top + height, line 

255 continue 

256 if run is not None: 

257 yield tuple(run[:4]) 

258 run = [line, commit, top, top + height, line] if commit is not None else None 

259 if run is not None: 

260 yield tuple(run[:4]) 

261 

262 def run_at(self, y: float): 

263 return next((r for r in self.runs() if r[2] <= y < r[3]), None) 

264 

265 def commit_at(self, y: float) -> Commit | None: 

266 if self.blame is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 return None 

268 for line, top, height in self._visible_lines(): 268 ↛ 271line 268 didn't jump to line 271 because the loop on line 268 didn't complete

269 if top <= y < top + height and line < len(self.blame.lines): 269 ↛ 268line 269 didn't jump to line 268 because the condition on line 269 was always true

270 return self.blame.commits.get(self.blame.lines[line]) 

271 return None 

272 

273 # ------------------------------------------------------------ drawing 

274 @staticmethod 

275 def _age_color(commit: Commit, base: QColor, now: float | None = None) -> QColor: 

276 """The color of a commit's age band (see AGE_BANDS); not committed: red.""" 

277 if not commit.committed: 

278 return QColor("#e5484d") 

279 _name, name, strength = age_band(commit.time, now) 

280 color = QColor(name) 

281 color.setAlphaF(strength) 

282 return color if base.lightness() < 128 else color.darker(112) 

283 

284 def paintEvent(self, _event): 

285 painter = QPainter(self) 

286 painter.setRenderHint(QPainter.Antialiasing) 

287 palette = self.editor.palette() 

288 base = palette.color(QPalette.Base) 

289 light = base.lightness() >= 128 

290 background = base.darker(106) if light else base.lighter(110) 

291 painter.fillRect(self.rect(), background) 

292 text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text)) 

293 dim.setAlpha(150) 

294 if self.blame is None: 

295 if self.message: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true

296 painter.setPen(dim) 

297 painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message) 

298 painter.end() 

299 return 

300 metrics = QFontMetrics(self.font()) 

301 line_height = self.editor.fontMetrics().height() 

302 for first, commit, top, bottom in self.runs(): 

303 accent = self._age_color(commit, base) 

304 hovered = commit.committed and first == self._hover 

305 pressed = hovered and first == self._pressed 

306 card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4)) 

307 if pressed: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 card.translate(0, 1) # (sunk: pressed in) 

309 elif commit.committed: # (a soft shadow under it: raised) 

310 shadow = QColor(0, 0, 0, 40 if light else 90) 

311 painter.setPen(Qt.NoPen) 

312 painter.setBrush(shadow) 

313 painter.drawRoundedRect(card.translated(0, 1.2), 5, 5) 

314 fill = QColor(base.lighter(103) if light else base.lighter(135)) 

315 tint = QColor(accent) 

316 tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0)) 

317 painter.setBrush(fill) 

318 if commit.committed: 

319 painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1)) 

320 else: # (not committed: dashed, red, not clickable) 

321 painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine)) 

322 painter.drawRoundedRect(card, 5, 5) 

323 painter.setPen(Qt.NoPen) 

324 painter.setBrush(tint) 

325 painter.drawRoundedRect(card, 5, 5) 

326 bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6) # (the age bar, in the card) 

327 painter.setBrush(accent) 

328 painter.drawRoundedRect(bar, 1.5, 1.5) 

329 if card.height() < line_height * 0.6: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true

330 continue # (too little of it on view for its label) 

331 label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2) 

332 x, room = card.left() + 11, card.width() - 15 

333 font = QFont(self.font()) 

334 if not commit.committed: 

335 font.setItalic(True) 

336 painter.setFont(font) 

337 painter.setPen(dim) 

338 painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 

339 "Not committed yet") 

340 continue 

341 parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)] 

342 for part, bold, color in parts: 

343 font.setBold(bold) 

344 painter.setFont(font) 

345 width = QFontMetrics(font).horizontalAdvance(part) 

346 if width > room: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true

347 part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room)) 

348 width = room 

349 painter.setPen(color) # (the hash bold, in the text's color: the age is the bar's and tint's) 

350 painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 

351 part) 

352 x += width + metrics.horizontalAdvance(" ") 

353 room = card.right() - 4 - x 

354 if room <= 8: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 break 

356 painter.end() 

357 

358 # ------------------------------------------------------------ the mouse 

359 def event(self, event): 

360 if event.type() == QEvent.ToolTip: # (the mouse rested on a card: its card, as fortitude's) 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 commit = self.commit_at(event.pos().y()) 

362 popup = self.editor.hover_popup 

363 if commit is None: 

364 if (popup.anchor or ("",))[0] == "blame": 

365 popup.schedule_hide() 

366 else: 

367 self._card_at = event.globalPos() 

368 self.show_card(commit) 

369 if commit.committed and commit.sha not in self._details: 

370 self._load_details(commit) 

371 return True 

372 return super().event(event) 

373 

374 def show_card(self, commit: Commit): 

375 """A commit's card in the editor's hover popup (it stays while the mouse 

376 goes into it, so its links can be clicked).""" 

377 palette = self.editor.palette() 

378 html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "", 

379 palette.color(QPalette.Text)) 

380 self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha)) 

381 

382 def _load_details(self, commit: Commit): 

383 """Read a commit's message and files (off the window's thread, once): 

384 its card then shows them, if it's still open.""" 

385 if commit.sha in self._loading or self.blame is None or self.blame.root is None: 

386 return 

387 self._loading.add(commit.sha) 

388 root, sha = self.blame.root, commit.sha 

389 

390 def work(): 

391 try: 

392 return commit_details(root, sha) 

393 except gs.GitError as error: 

394 return str(error) 

395 

396 def done(details): 

397 self._loading.discard(sha) 

398 self._details[sha] = details 

399 popup = self.editor.hover_popup 

400 if popup.isVisible() and popup.anchor == ("blame", sha): 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true

401 self.show_card(commit) 

402 

403 call_in_background(work, done, self) 

404 

405 def _show_commit_lines(self, commit: Commit | None): 

406 """Every line of the file from ``commit`` tinted in its card's color 

407 (None: none), while the mouse is on its card.""" 

408 if commit is None or self.blame is None: 

409 self.editor.show_commit_lines(None) 

410 return 

411 tint = self._age_color(commit, self.editor.palette().color(QPalette.Base)) 

412 tint.setAlphaF(0.22 if self.editor.palette().color(QPalette.Base).lightness() < 128 else 0.28) 

413 lines = {n for n, sha in enumerate(self.blame.lines) if sha == commit.sha} 

414 self.editor.show_commit_lines(lines, tint) 

415 

416 def wheelEvent(self, event): 

417 # Scroll the editor, as if the wheel were over the text (as the minimap does): the 

418 # column, and the minimap, follow it. 

419 self.editor.wheelEvent(event) 

420 

421 def mouseMoveEvent(self, event): 

422 run = self.run_at(event.position().y()) 

423 hover = run[0] if run is not None and run[1].committed else None 

424 if hover != self._hover: 424 ↛ 428line 424 didn't jump to line 428 because the condition on line 424 was always true

425 self._hover = hover 

426 self.update() 

427 self._show_commit_lines(run[1] if hover is not None else None) 

428 self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor) 

429 

430 def leaveEvent(self, event): 

431 self._hover = self._pressed = None 

432 self.update() 

433 self._show_commit_lines(None) 

434 if (self.editor.hover_popup.anchor or ("",))[0] == "blame": 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 

436 super().leaveEvent(event) 

437 

438 def mousePressEvent(self, event): 

439 if event.button() == Qt.LeftButton: 439 ↛ exitline 439 didn't return from function 'mousePressEvent' because the condition on line 439 was always true

440 run = self.run_at(event.position().y()) 

441 self._pressed = run[0] if run is not None and run[1].committed else None 

442 self.update() 

443 

444 def mouseReleaseEvent(self, event): 

445 if event.button() != Qt.LeftButton: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true

446 return 

447 pressed, self._pressed = self._pressed, None 

448 self.update() 

449 run = self.run_at(event.position().y()) 

450 if run is None or run[0] != pressed: # (released off the card pressed: no click, as a button) 

451 return 

452 commit = run[1] 

453 url = self._commit_url(commit) 

454 if url: 

455 open_web_page(url) 

456 else: 

457 QApplication.clipboard().setText(commit.sha) 

458 QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)), 

459 f"No web page for this repository's commits (its origin isn't on GitHub or " 

460 f"GitLab): the hash {commit.sha[:10]} is copied.", self) 

461 

462 def _commit_url(self, commit: Commit) -> str | None: 

463 if self.blame is None or not self.blame.remote: 

464 return None 

465 from .window.packages import ( 

466 commit_web_url, # (here: the window's module imports the editor's) 

467 ) 

468 

469 return commit_web_url(self.blame.remote, commit.sha) 

470 

471 

472STATUS_CHIPS = {"A": ("added", "#2f9e44"), "M": ("modified", "#1c7ed6"), "D": ("deleted", "#e03131"), 

473 "R": ("renamed", "#7048e8"), "C": ("copied", "#7048e8"), "T": ("type changed", "#868e96")} 

474 

475 

476def web_base(remote: str) -> str | None: 

477 """The repository's web page (GitHub, GitLab, Codeberg…), None: none known.""" 

478 if not remote: 

479 return None 

480 from .window.packages import ( 

481 repo_web_url, # (here: the window's module imports the editor's) 

482 ) 

483 

484 return repo_web_url(remote) 

485 

486 

487def issue_url(base: str, number: str, merge_request: bool = False) -> str | None: 

488 """An issue's ("#33") or merge request's ("!34", GitLab's) page.""" 

489 if "gitlab." in base: 

490 return f"{base}/-/{'merge_requests' if merge_request else 'issues'}/{number}" 

491 if merge_request: 

492 return None # (only GitLab writes them so) 

493 return f"{base}/issues/{number}" # (GitHub sends a pull request's number on to it; Codeberg too) 

494 

495 

496def file_url(base: str, sha: str, path: str) -> str: 

497 """A file as it was in a commit.""" 

498 if "gitlab." in base: 

499 return f"{base}/-/blob/{sha}/{path}" 

500 if "codeberg." in base: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 return f"{base}/src/commit/{sha}/{path}" 

502 if "bitbucket." in base: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true

503 return f"{base}/src/{sha}/{path}" 

504 return f"{base}/blob/{sha}/{path}" 

505 

506 

507def linkify(text: str, base: str | None) -> str: 

508 """Text for the card: escaped, its web addresses links, and its issue 

509 ("#33") and merge request ("!34") numbers links to their pages.""" 

510 out, last = [], 0 

511 pattern = re.compile(r"(?P<url>https?://[^\s<>\"')\]]+)|(?<![\w&/])(?P<kind>[#!])(?P<number>\d+)\b") 

512 for match in pattern.finditer(text): 

513 out.append(_escape(text[last:match.start()])) 

514 last = match.end() 

515 if match.group("url"): 

516 url = match.group("url").rstrip(".,;:") 

517 out.append(f"<a href='{_escape(url)}'>{_escape(url)}</a>{_escape(match.group('url')[len(url):])}") 

518 continue 

519 url = issue_url(base, match.group("number"), match.group("kind") == "!") if base else None 

520 out.append(f"<a href='{url}'>{_escape(match.group(0))}</a>" if url else _escape(match.group(0))) 

521 return "".join(out) + _escape(text[last:]) 

522 

523 

524def commit_card_html(commit: Commit, details: CommitDetails | str | None, remote: str, text: QColor) -> str: 

525 """A commit, as the editor's other hover cards (fortitude's): its hash 

526 (a link: its page, or "copy"), age band, author (and committer), date, 

527 the whole message (issue and merge request numbers, web addresses: 

528 links), and the files it changed (each a link to it as it was then), 

529 with their lines added and deleted. ``details``: None while they're 

530 read; a string: why git couldn't.""" 

531 muted = QColor(text) 

532 muted.setAlpha(150) 

533 muted = muted.name(QColor.HexArgb) 

534 dark = text.lightness() > 128 

535 green, red = ("#57d163", "#ff6b6b") if dark else ("#1a7f37", "#cf222e") 

536 small = "font-size:small" 

537 base = web_base(remote) 

538 host = "GitHub" if base and "github." in base else "GitLab" if base and "gitlab." in base else "the web" 

539 page = f"{base}/{'-/commit' if base and 'gitlab.' in base else 'commit'}/{commit.sha}" if base else None 

540 if not commit.committed: 

541 return (f"<span style='color:{muted}; {small}'>GIT BLAME</span><br>" 

542 "<i>Not committed yet</i>: changed since the last commit.") 

543 band, color, _strength = age_band(commit.time) 

544 when = time.strftime("%Y-%m-%d %H:%M", time.localtime(commit.time)) if commit.time else "" 

545 chip = f"background-color:{color}; color:white; font-weight:700; {small}" 

546 hash_link = page or f"copy:{commit.sha}" 

547 out = [(f"<span style='color:{muted}; {small}'>COMMIT</span><br>" 

548 f"<a href='{hash_link}' style='text-decoration:none'><b><code>{commit.sha[:12]}</code></b></a>" 

549 f"&nbsp;&nbsp;<span style='{chip}'>&nbsp;{band}&nbsp;</span>")] 

550 email = f" &lt;{_escape(details.email)}&gt;" if isinstance(details, CommitDetails) and details.email else "" 

551 ago = age_words(commit.time).split(" (")[-1][:-1] 

552 out.append(f"<br><b>{_escape(commit.author)}</b><span style='color:{muted}'>{email}</span><br>" 

553 f"<span style='color:{muted}; {small}'>{when} · {ago}</span>") 

554 if isinstance(details, CommitDetails) and details.committer: 

555 out.append(f"<br><span style='color:{muted}; {small}'>committed by {_escape(details.committer)}</span>") 

556 message = details.message if isinstance(details, CommitDetails) and details.message else commit.summary 

557 subject, _, body = message.partition("\n") 

558 out.append(f"<p style='margin-top:8px; margin-bottom:0'><b>{linkify(subject, base)}</b></p>") 

559 if body.strip(): 

560 lines = (linkify(line, base) if line.strip() else "" for line in body.strip("\n").split("\n")) 

561 out.append(f"<p style='margin-top:4px; margin-bottom:0'>{'<br>'.join(lines)}</p>") 

562 if details is None: 

563 out.append(f"<p style='margin-top:8px; color:{muted}'><i>Reading the commit…</i></p>") 

564 elif isinstance(details, str): 

565 out.append(f"<p style='margin-top:8px; color:{muted}'><i>git couldn't read it: {_escape(details)}</i></p>") 

566 elif details.files is not None: 

567 files = details.files 

568 added = sum(f.added or 0 for f in files) 

569 deleted = sum(f.deleted or 0 for f in files) 

570 rows = [(f"<p style='margin-top:10px; margin-bottom:2px'><span style='color:{muted}; {small}'>" 

571 f"FILES CHANGED · {len(files)}</span>&nbsp; <span style='color:{green}'>+{added}</span> " 

572 f"<span style='color:{red}'>−{deleted}</span></p><table cellspacing='0' cellpadding='1'>")] 

573 for change in files[:MAX_FILES]: 

574 word, status_color = STATUS_CHIPS.get(change.status, (change.status, "#868e96")) 

575 name = (f"{_escape(change.old_path)} → {_escape(change.path)}" if change.old_path 

576 else _escape(change.path)) 

577 if base and change.status != "D": 

578 name = f"<a href='{_escape(file_url(base, commit.sha, change.path))}'>{name}</a>" 

579 if change.added is None: 

580 counts, bar = f"<span style='color:{muted}'>binary</span>", "" 

581 elif change.added + change.deleted == 0: # (renamed, or its mode changed: no lines) 

582 counts, bar = f"<span style='color:{muted}'>—</span>", "" 

583 else: 

584 counts = (f"<span style='color:{green}'>+{change.added}</span>&nbsp;" 

585 f"<span style='color:{red}'>−{change.deleted}</span>") 

586 total = change.added + change.deleted 

587 plus = round(5 * change.added / total) if total else 0 

588 bar = (f"<span style='color:{green}'>{'■' * plus}</span>" 

589 f"<span style='color:{red}'>{'■' * (5 - plus if total else 0)}</span>" 

590 f"<span style='color:{muted}'>{'□' * (0 if total else 5)}</span>") 

591 rows.append(f"<tr><td style='padding-right:6px'><span style='background-color:{status_color}; " 

592 f"color:white; font-weight:700; {small}' title='{word}'>&nbsp;{change.status}&nbsp;</span>" 

593 f"</td><td style='padding-right:10px'>{name}</td><td align='right'>{counts}&nbsp;</td>" 

594 f"<td>{bar}</td></tr>") 

595 rows.append("</table>") 

596 if len(files) > MAX_FILES: 

597 rows.append(f"<span style='color:{muted}; {small}'>and {len(files) - MAX_FILES} more</span>") 

598 out += rows 

599 actions = [f"<a href='{page}'>Open on {host}</a>"] if page else [] 

600 actions.append(f"<a href='copy:{commit.sha}'>Copy hash</a>") 

601 out.append(f"<p style='margin-top:10px; margin-bottom:0; {small}'>{' &nbsp;·&nbsp; '.join(actions)}</p>") 

602 return "".join(out) 

603 

604 

605def open_web_page(url: str): 

606 """Open a page in the browser.""" 

607 QDesktopServices.openUrl(QUrl(url)) 

608 

609 

610def _escape(text: str) -> str: 

611 return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")