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
« 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.
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"""
17from __future__ import annotations
19import os
20import re
21import tempfile
22import time
23from dataclasses import dataclass
24from pathlib import Path
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
38from . import git_status as gs
39from .background import call_in_background
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))
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")
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})"
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
74 @property
75 def committed(self) -> bool:
76 return self.sha != NOT_COMMITTED
78 def date(self) -> str:
79 return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else ""
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)
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
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
109MAX_FILES = 30 # files listed in the tooltip (more: "and N more")
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)
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
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)
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] + "…"
194class BlameColumn(QWidget):
195 """The column (see the module's description)."""
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()
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()
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)
227 def sizeHint(self) -> QSize:
228 return QSize(self.width(), 100)
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()
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])
262 def run_at(self, y: float):
263 return next((r for r in self.runs() if r[2] <= y < r[3]), None)
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
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)
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()
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)
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))
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
390 def work():
391 try:
392 return commit_details(root, sha)
393 except gs.GitError as error:
394 return str(error)
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)
403 call_in_background(work, done, self)
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)
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)
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)
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)
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()
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)
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 )
469 return commit_web_url(self.blame.remote, commit.sha)
472STATUS_CHIPS = {"A": ("added", "#2f9e44"), "M": ("modified", "#1c7ed6"), "D": ("deleted", "#e03131"),
473 "R": ("renamed", "#7048e8"), "C": ("copied", "#7048e8"), "T": ("type changed", "#868e96")}
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 )
484 return repo_web_url(remote)
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)
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}"
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:])
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" <span style='{chip}'> {band} </span>")]
550 email = f" <{_escape(details.email)}>" 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> <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> "
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}'> {change.status} </span>"
593 f"</td><td style='padding-right:10px'>{name}</td><td align='right'>{counts} </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}'>{' · '.join(actions)}</p>")
602 return "".join(out)
605def open_web_page(url: str):
606 """Open a page in the browser."""
607 QDesktopServices.openUrl(QUrl(url))
610def _escape(text: str) -> str:
611 return text.replace("&", "&").replace("<", "<").replace(">", ">")