vibefpmgui.git_blame

View → Show Git Blame: beside the editor (left of the minimap), for each line of the file, the commit that last changed it — its short hash, author and date — scrolling with the text. Each run of lines from one commit is a card spanning them, labelled at its top, tinted and with a bar in the color of the commit's age (see AGE_BANDS: this week, this month, this year, older — the same in every file). A card lights up under the mouse and sinks when pressed, as a button: click it to open the commit on GitHub or GitLab (the repository's origin), or, with no web page for it, to copy its hash; hover it for the commit's message.

The blame is of the text in the editor (git blame --contents), so its lines stay matched while you edit: lines changed since the last commit say "Not committed yet". It's read off the window's thread, again once typing pauses and when the file is saved.

  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:
 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:
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:
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:
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)
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():
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:
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:
267            return None
268        for line, top, height in self._visible_lines():
269            if top <= y < top + height and line < len(self.blame.lines):
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:
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:
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:
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:
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:
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)
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):
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:
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":
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:
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:
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:
501        return f"{base}/src/commit/{sha}/{path}"
502    if "bitbucket." in base:
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;")
NOT_COMMITTED = '0000000000000000000000000000000000000000'
HEADER_RE = re.compile('^([0-9a-f]{40}) (\\d+) (\\d+)(?: (\\d+))?$')
CHARACTERS = 34
DAY = 86400
AGE_BANDS = ((7, 'this week', '#f08c00', 1.0), (31, 'this month', '#e3b341', 0.9), (365, 'this year', '#7b9cc4', 0.8), (None, 'older', '#9a9a9a', 0.55))
def age_band(commit_time: int, now: float | None = None) -> tuple[str, str, float]:
51def age_band(commit_time: int, now: float | None = None) -> tuple[str, str, float]:
52    """A commit's age band: (its name, its color, how strongly it's shown)."""
53    days = ((now if now is not None else time.time()) - commit_time) / DAY
54    for limit, name, color, strength in AGE_BANDS:
55        if limit is None or days < limit:
56            return name, color, strength
57    raise AssertionError("the last band has no limit")

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

def age_words(commit_time: int, now: float | None = None) -> str:
60def age_words(commit_time: int, now: float | None = None) -> str:
61    """ "this month (12 days ago)", "older (3 years ago)"…"""
62    days = max(0, int(((now if now is not None else time.time()) - commit_time) // DAY))
63    ago = ("today" if days == 0 else "yesterday" if days == 1 else f"{days} days ago" if days < 60
64           else f"{days // 30} months ago" if days < 730 else f"{days // 365} years ago")
65    return f"{age_band(commit_time, now)[0]} ({ago})"

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

@dataclass
class Commit:
68@dataclass
69class Commit:
70    sha: str
71    author: str = ""
72    time: int = 0       # seconds since 1970 (its author's)
73    summary: str = ""   # the first line of its message
74
75    @property
76    def committed(self) -> bool:
77        return self.sha != NOT_COMMITTED
78
79    def date(self) -> str:
80        return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else ""
Commit(sha: str, author: str = '', time: int = 0, summary: str = '')
sha: str
author: str = ''
time: int = 0
summary: str = ''
committed: bool
75    @property
76    def committed(self) -> bool:
77        return self.sha != NOT_COMMITTED
def date(self) -> str:
79    def date(self) -> str:
80        return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else ""
@dataclass
class Blame:
83@dataclass
84class Blame:
85    lines: list[str]               # each line's commit (its hash), the file's lines in order
86    commits: dict[str, Commit]
87    remote: str = ""               # the repository's origin (for the commits' web pages)
88    root: Path | None = None       # the repository's top folder (for a commit's details)
Blame( lines: list[str], commits: dict[str, Commit], remote: str = '', root: pathlib.Path | None = None)
lines: list[str]
commits: dict[str, Commit]
remote: str = ''
root: pathlib.Path | None = None
@dataclass
class FileChange:
91@dataclass
92class FileChange:
93    status: str                    # A, M, D, R, C, T (git's letters)
94    path: str
95    old_path: str = ""             # a renamed or copied file's name before
96    added: int | None = None       # lines (None: a binary file)
97    deleted: int | None = None
FileChange( status: str, path: str, old_path: str = '', added: int | None = None, deleted: int | None = None)
status: str
path: str
old_path: str = ''
added: int | None = None
deleted: int | None = None
@dataclass
class CommitDetails:
100@dataclass
101class CommitDetails:
102    """What the tooltip shows of a commit, besides what the blame says."""
103    email: str = ""
104    message: str = ""              # the whole of it
105    committer: str = ""            # (when not the author: a rebase, a patch applied by someone else)
106    commit_time: int = 0
107    files: list[FileChange] | None = None

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

CommitDetails( email: str = '', message: str = '', committer: str = '', commit_time: int = 0, files: list[FileChange] | None = None)
email: str = ''
message: str = ''
committer: str = ''
commit_time: int = 0
files: list[FileChange] | None = None
MAX_FILES = 30
def commit_details(root: pathlib.Path, sha: str) -> CommitDetails:
113def commit_details(root: Path, sha: str) -> CommitDetails:
114    """A commit's message, its author's email, its committer, and the files it
115    changed (each with its lines added and deleted; a merge's: what it
116    brought into its first parent, not git's "combined" list, which leaves
117    out what came from one side only). Raises GitError."""
118    header = gs._check(root, "show", "-s", "--no-color", "--format=%ae%x00%cn%x00%ct%x00%an%x00%B", sha)
119    email, committer, commit_time, author, message = (header.decode("utf-8", "replace").split("\0", 4) + [""] * 5)[:5]
120    statuses = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--name-status",
121                         "-M", "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines()
122    counts = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--numstat", "-M",
123                       "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines()
124    files = []
125    for status_line, count_line in zip(statuses, counts, strict=False):
126        parts = status_line.split("\t")
127        letter = parts[0][:1]
128        path, old = (parts[2], parts[1]) if letter in "RC" and len(parts) > 2 else (parts[-1], "")
129        added, deleted, *_ = count_line.split("\t") + ["", ""]
130        files.append(FileChange(letter, path, old, int(added) if added.isdigit() else None,
131                                int(deleted) if deleted.isdigit() else None))
132    return CommitDetails(email.strip(), message.strip(), committer.strip() if committer.strip() != author.strip()
133                         else "", int(commit_time) if commit_time.strip().isdigit() else 0, files)

A commit's message, its author's email, its committer, and the files it changed (each with its lines added and deleted; a merge's: what it brought into its first parent, not git's "combined" list, which leaves out what came from one side only). Raises GitError.

def parse_porcelain(output: str) -> tuple[list[str], dict[str, Commit]]:
136def parse_porcelain(output: str) -> tuple[list[str], dict[str, Commit]]:
137    """``git blame --porcelain`` read: each line's commit, and the commits."""
138    lines: dict[int, str] = {}
139    commits: dict[str, Commit] = {}
140    current = None
141    for row in output.split("\n"):
142        if row.startswith("\t"):
143            continue  # (the line's text)
144        match = HEADER_RE.match(row)
145        if match:
146            current = commits.setdefault(match.group(1), Commit(match.group(1)))
147            lines[int(match.group(3))] = current.sha
148            continue
149        if current is None:
150            continue
151        key, _, value = row.partition(" ")
152        if key == "author":
153            current.author = "You" if current.sha == NOT_COMMITTED else value
154        elif key == "author-time" and value.isdigit():
155            current.time = int(value)
156        elif key == "summary":
157            current.summary = value
158    return [lines.get(n, NOT_COMMITTED) for n in range(1, max(lines, default=0) + 1)], commits

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

def blame( path: pathlib.Path, contents: str | bytes) -> Blame | None:
161def blame(path: Path, contents: str | bytes) -> Blame | None:
162    """The blame of ``contents`` as the file ``path`` (None: it isn't in a git
163    repository, or git doesn't know it). ``contents``: the bytes the file would
164    have, saved (its line endings and encoding: the editor's text has "\n",
165    and a file committed with "\r\n" would otherwise differ on every line).
166    Raises GitError if git fails."""
167    root = gs.find_repository(path.parent)  # (a folder: the file's)
168    if root is None:
169        return None
170    relative = path.resolve().relative_to(root.resolve()).as_posix()
171    data = contents.encode("utf-8") if isinstance(contents, str) else contents
172    with tempfile.NamedTemporaryFile("wb", suffix=path.suffix, delete=False) as file:
173        file.write(data)
174    try:
175        code, output, message = gs._git(root, "blame", "--porcelain", "--contents", file.name, "--", relative)
176    finally:
177        os.unlink(file.name)
178    if code != 0:
179        if "no such path" in message or "not tracked" in message or "fatal: no such ref" in message:
180            return None  # (a file git doesn't know yet)
181        raise gs.GitError(message.strip().splitlines()[-1] if message.strip() else "git blame failed")
182    lines, commits = parse_porcelain((output or b"").decode("utf-8", "replace"))
183    _code, remote, _message = gs._git(root, "config", "--get", "remote.origin.url")
184    return Blame(lines, commits, (remote or b"").decode().strip(), root)

The blame of contents as the file path (None: it isn't in a git repository, or git doesn't know it). contents: the bytes the file would have, saved (its line endings and encoding: the editor's text has " ", and a file committed with " " would otherwise differ on every line). Raises GitError if git fails.

def short_name(author: str, room: int = 14) -> str:
187def short_name(author: str, room: int = 14) -> str:
188    """An author's name, cut to fit: "Jacob Williams" or "Jacob W."."""
189    if len(author) <= room:
190        return author
191    first, *rest = author.split()
192    return f"{first} {rest[-1][0]}." if rest and len(first) + 3 <= room else author[:room - 1] + "…"

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

class BlameColumn(PySide6.QtWidgets.QWidget):
195class BlameColumn(QWidget):
196    """The column (see the module's description)."""
197
198    def __init__(self, editor, parent=None):
199        super().__init__(parent)
200        self.editor = editor
201        self.blame: Blame | None = None
202        self.message = ""  # (why there's no blame: shown at the top)
203        self._hover: int | None = None    # the card under the mouse (its first line)
204        self._pressed: int | None = None  # the card being clicked (its first line)
205        self._details: dict[str, CommitDetails | str] = {}  # a commit's details (or why git couldn't), once read
206        self._loading: set[str] = set()
207        self._card_at = QPoint()  # (where the mouse rested: the card is shown there)
208        self.setMouseTracking(True)
209        self.setAttribute(Qt.WA_OpaquePaintEvent)
210        editor.updateRequest.connect(lambda *_: self.update())  # (scrolled, edited: drawn again with it)
211        self.refresh_font()
212
213    # ------------------------------------------------------------ contents
214    def set_blame(self, blame: Blame | None, message: str = ""):
215        self.blame, self.message = blame, message
216        if self._hover is not None:  # (its lines may have moved: tinted again as the mouse moves)
217            self._hover = None
218            self.editor.show_commit_lines(None)
219        self.update()
220
221    def refresh_font(self):
222        font = QFont(QApplication.font())
223        line = self.editor.fontMetrics().height()
224        font.setPixelSize(max(8, int(line * 0.72)))
225        self.setFont(font)
226        self.setFixedWidth(QFontMetrics(font).horizontalAdvance("0" * CHARACTERS) + 12)
227
228    def sizeHint(self) -> QSize:
229        return QSize(self.width(), 100)
230
231    # ------------------------------------------------------------ where
232    def _visible_lines(self):
233        """(line index, top y, height) of each line on view."""
234        editor = self.editor
235        block = editor.firstVisibleBlock()
236        offset = editor.contentOffset()
237        top = editor.blockBoundingGeometry(block).translated(offset).top()
238        while block.isValid() and top <= self.height():
239            height = editor.blockBoundingRect(block).height()
240            if block.isVisible():
241                yield block.blockNumber(), top, height
242            top += height
243            block = block.next()
244
245    def runs(self):
246        """The cards on view: (first line, commit, top y, bottom y) for each run
247        of lines from one commit (a run starting above the view: from its top)."""
248        if self.blame is None:
249            return
250        run = None
251        for line, top, height in self._visible_lines():
252            sha = self.blame.lines[line] if line < len(self.blame.lines) else None
253            commit = self.blame.commits.get(sha) if sha else None
254            if run is not None and commit is run[1] and line == run[4] + 1:
255                run[3], run[4] = top + height, line
256                continue
257            if run is not None:
258                yield tuple(run[:4])
259            run = [line, commit, top, top + height, line] if commit is not None else None
260        if run is not None:
261            yield tuple(run[:4])
262
263    def run_at(self, y: float):
264        return next((r for r in self.runs() if r[2] <= y < r[3]), None)
265
266    def commit_at(self, y: float) -> Commit | None:
267        if self.blame is None:
268            return None
269        for line, top, height in self._visible_lines():
270            if top <= y < top + height and line < len(self.blame.lines):
271                return self.blame.commits.get(self.blame.lines[line])
272        return None
273
274    # ------------------------------------------------------------ drawing
275    @staticmethod
276    def _age_color(commit: Commit, base: QColor, now: float | None = None) -> QColor:
277        """The color of a commit's age band (see AGE_BANDS); not committed: red."""
278        if not commit.committed:
279            return QColor("#e5484d")
280        _name, name, strength = age_band(commit.time, now)
281        color = QColor(name)
282        color.setAlphaF(strength)
283        return color if base.lightness() < 128 else color.darker(112)
284
285    def paintEvent(self, _event):
286        painter = QPainter(self)
287        painter.setRenderHint(QPainter.Antialiasing)
288        palette = self.editor.palette()
289        base = palette.color(QPalette.Base)
290        light = base.lightness() >= 128
291        background = base.darker(106) if light else base.lighter(110)
292        painter.fillRect(self.rect(), background)
293        text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text))
294        dim.setAlpha(150)
295        if self.blame is None:
296            if self.message:
297                painter.setPen(dim)
298                painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message)
299            painter.end()
300            return
301        metrics = QFontMetrics(self.font())
302        line_height = self.editor.fontMetrics().height()
303        for first, commit, top, bottom in self.runs():
304            accent = self._age_color(commit, base)
305            hovered = commit.committed and first == self._hover
306            pressed = hovered and first == self._pressed
307            card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4))
308            if pressed:
309                card.translate(0, 1)  # (sunk: pressed in)
310            elif commit.committed:  # (a soft shadow under it: raised)
311                shadow = QColor(0, 0, 0, 40 if light else 90)
312                painter.setPen(Qt.NoPen)
313                painter.setBrush(shadow)
314                painter.drawRoundedRect(card.translated(0, 1.2), 5, 5)
315            fill = QColor(base.lighter(103) if light else base.lighter(135))
316            tint = QColor(accent)
317            tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0))
318            painter.setBrush(fill)
319            if commit.committed:
320                painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1))
321            else:  # (not committed: dashed, red, not clickable)
322                painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine))
323            painter.drawRoundedRect(card, 5, 5)
324            painter.setPen(Qt.NoPen)
325            painter.setBrush(tint)
326            painter.drawRoundedRect(card, 5, 5)
327            bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6)  # (the age bar, in the card)
328            painter.setBrush(accent)
329            painter.drawRoundedRect(bar, 1.5, 1.5)
330            if card.height() < line_height * 0.6:
331                continue  # (too little of it on view for its label)
332            label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2)
333            x, room = card.left() + 11, card.width() - 15
334            font = QFont(self.font())
335            if not commit.committed:
336                font.setItalic(True)
337                painter.setFont(font)
338                painter.setPen(dim)
339                painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter,
340                                 "Not committed yet")
341                continue
342            parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)]
343            for part, bold, color in parts:
344                font.setBold(bold)
345                painter.setFont(font)
346                width = QFontMetrics(font).horizontalAdvance(part)
347                if width > room:
348                    part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room))
349                    width = room
350                painter.setPen(color)  # (the hash bold, in the text's color: the age is the bar's and tint's)
351                painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter,
352                                 part)
353                x += width + metrics.horizontalAdvance("  ")
354                room = card.right() - 4 - x
355                if room <= 8:
356                    break
357        painter.end()
358
359    # ------------------------------------------------------------ the mouse
360    def event(self, event):
361        if event.type() == QEvent.ToolTip:  # (the mouse rested on a card: its card, as fortitude's)
362            commit = self.commit_at(event.pos().y())
363            popup = self.editor.hover_popup
364            if commit is None:
365                if (popup.anchor or ("",))[0] == "blame":
366                    popup.schedule_hide()
367            else:
368                self._card_at = event.globalPos()
369                self.show_card(commit)
370                if commit.committed and commit.sha not in self._details:
371                    self._load_details(commit)
372            return True
373        return super().event(event)
374
375    def show_card(self, commit: Commit):
376        """A commit's card in the editor's hover popup (it stays while the mouse
377        goes into it, so its links can be clicked)."""
378        palette = self.editor.palette()
379        html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "",
380                                palette.color(QPalette.Text))
381        self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha))
382
383    def _load_details(self, commit: Commit):
384        """Read a commit's message and files (off the window's thread, once):
385        its card then shows them, if it's still open."""
386        if commit.sha in self._loading or self.blame is None or self.blame.root is None:
387            return
388        self._loading.add(commit.sha)
389        root, sha = self.blame.root, commit.sha
390
391        def work():
392            try:
393                return commit_details(root, sha)
394            except gs.GitError as error:
395                return str(error)
396
397        def done(details):
398            self._loading.discard(sha)
399            self._details[sha] = details
400            popup = self.editor.hover_popup
401            if popup.isVisible() and popup.anchor == ("blame", sha):
402                self.show_card(commit)
403
404        call_in_background(work, done, self)
405
406    def _show_commit_lines(self, commit: Commit | None):
407        """Every line of the file from ``commit`` tinted in its card's color
408        (None: none), while the mouse is on its card."""
409        if commit is None or self.blame is None:
410            self.editor.show_commit_lines(None)
411            return
412        tint = self._age_color(commit, self.editor.palette().color(QPalette.Base))
413        tint.setAlphaF(0.22 if self.editor.palette().color(QPalette.Base).lightness() < 128 else 0.28)
414        lines = {n for n, sha in enumerate(self.blame.lines) if sha == commit.sha}
415        self.editor.show_commit_lines(lines, tint)
416
417    def wheelEvent(self, event):
418        # Scroll the editor, as if the wheel were over the text (as the minimap does): the
419        # column, and the minimap, follow it.
420        self.editor.wheelEvent(event)
421
422    def mouseMoveEvent(self, event):
423        run = self.run_at(event.position().y())
424        hover = run[0] if run is not None and run[1].committed else None
425        if hover != self._hover:
426            self._hover = hover
427            self.update()
428            self._show_commit_lines(run[1] if hover is not None else None)
429        self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor)
430
431    def leaveEvent(self, event):
432        self._hover = self._pressed = None
433        self.update()
434        self._show_commit_lines(None)
435        if (self.editor.hover_popup.anchor or ("",))[0] == "blame":
436            self.editor.hover_popup.schedule_hide()  # (unless the mouse went into it)
437        super().leaveEvent(event)
438
439    def mousePressEvent(self, event):
440        if event.button() == Qt.LeftButton:
441            run = self.run_at(event.position().y())
442            self._pressed = run[0] if run is not None and run[1].committed else None
443            self.update()
444
445    def mouseReleaseEvent(self, event):
446        if event.button() != Qt.LeftButton:
447            return
448        pressed, self._pressed = self._pressed, None
449        self.update()
450        run = self.run_at(event.position().y())
451        if run is None or run[0] != pressed:  # (released off the card pressed: no click, as a button)
452            return
453        commit = run[1]
454        url = self._commit_url(commit)
455        if url:
456            open_web_page(url)
457        else:
458            QApplication.clipboard().setText(commit.sha)
459            QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)),
460                              f"No web page for this repository's commits (its origin isn't on GitHub or "
461                              f"GitLab): the hash {commit.sha[:10]} is copied.", self)
462
463    def _commit_url(self, commit: Commit) -> str | None:
464        if self.blame is None or not self.blame.remote:
465            return None
466        from .window.packages import (
467            commit_web_url,  # (here: the window's module imports the editor's)
468        )
469
470        return commit_web_url(self.blame.remote, commit.sha)

The column (see the module's description).

BlameColumn(editor, parent=None)
198    def __init__(self, editor, parent=None):
199        super().__init__(parent)
200        self.editor = editor
201        self.blame: Blame | None = None
202        self.message = ""  # (why there's no blame: shown at the top)
203        self._hover: int | None = None    # the card under the mouse (its first line)
204        self._pressed: int | None = None  # the card being clicked (its first line)
205        self._details: dict[str, CommitDetails | str] = {}  # a commit's details (or why git couldn't), once read
206        self._loading: set[str] = set()
207        self._card_at = QPoint()  # (where the mouse rested: the card is shown there)
208        self.setMouseTracking(True)
209        self.setAttribute(Qt.WA_OpaquePaintEvent)
210        editor.updateRequest.connect(lambda *_: self.update())  # (scrolled, edited: drawn again with it)
211        self.refresh_font()

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

editor
blame: Blame | None
message
def set_blame(self, blame: Blame | None, message: str = ''):
214    def set_blame(self, blame: Blame | None, message: str = ""):
215        self.blame, self.message = blame, message
216        if self._hover is not None:  # (its lines may have moved: tinted again as the mouse moves)
217            self._hover = None
218            self.editor.show_commit_lines(None)
219        self.update()
def refresh_font(self):
221    def refresh_font(self):
222        font = QFont(QApplication.font())
223        line = self.editor.fontMetrics().height()
224        font.setPixelSize(max(8, int(line * 0.72)))
225        self.setFont(font)
226        self.setFixedWidth(QFontMetrics(font).horizontalAdvance("0" * CHARACTERS) + 12)
def sizeHint(self) -> PySide6.QtCore.QSize:
228    def sizeHint(self) -> QSize:
229        return QSize(self.width(), 100)

sizeHint(self, /) -> PySide6.QtCore.QSize

def runs(self):
245    def runs(self):
246        """The cards on view: (first line, commit, top y, bottom y) for each run
247        of lines from one commit (a run starting above the view: from its top)."""
248        if self.blame is None:
249            return
250        run = None
251        for line, top, height in self._visible_lines():
252            sha = self.blame.lines[line] if line < len(self.blame.lines) else None
253            commit = self.blame.commits.get(sha) if sha else None
254            if run is not None and commit is run[1] and line == run[4] + 1:
255                run[3], run[4] = top + height, line
256                continue
257            if run is not None:
258                yield tuple(run[:4])
259            run = [line, commit, top, top + height, line] if commit is not None else None
260        if run is not None:
261            yield tuple(run[:4])

The cards on view: (first line, commit, top y, bottom y) for each run of lines from one commit (a run starting above the view: from its top).

def run_at(self, y: float):
263    def run_at(self, y: float):
264        return next((r for r in self.runs() if r[2] <= y < r[3]), None)
def commit_at(self, y: float) -> Commit | None:
266    def commit_at(self, y: float) -> Commit | None:
267        if self.blame is None:
268            return None
269        for line, top, height in self._visible_lines():
270            if top <= y < top + height and line < len(self.blame.lines):
271                return self.blame.commits.get(self.blame.lines[line])
272        return None
def paintEvent(self, _event):
285    def paintEvent(self, _event):
286        painter = QPainter(self)
287        painter.setRenderHint(QPainter.Antialiasing)
288        palette = self.editor.palette()
289        base = palette.color(QPalette.Base)
290        light = base.lightness() >= 128
291        background = base.darker(106) if light else base.lighter(110)
292        painter.fillRect(self.rect(), background)
293        text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text))
294        dim.setAlpha(150)
295        if self.blame is None:
296            if self.message:
297                painter.setPen(dim)
298                painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message)
299            painter.end()
300            return
301        metrics = QFontMetrics(self.font())
302        line_height = self.editor.fontMetrics().height()
303        for first, commit, top, bottom in self.runs():
304            accent = self._age_color(commit, base)
305            hovered = commit.committed and first == self._hover
306            pressed = hovered and first == self._pressed
307            card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4))
308            if pressed:
309                card.translate(0, 1)  # (sunk: pressed in)
310            elif commit.committed:  # (a soft shadow under it: raised)
311                shadow = QColor(0, 0, 0, 40 if light else 90)
312                painter.setPen(Qt.NoPen)
313                painter.setBrush(shadow)
314                painter.drawRoundedRect(card.translated(0, 1.2), 5, 5)
315            fill = QColor(base.lighter(103) if light else base.lighter(135))
316            tint = QColor(accent)
317            tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0))
318            painter.setBrush(fill)
319            if commit.committed:
320                painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1))
321            else:  # (not committed: dashed, red, not clickable)
322                painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine))
323            painter.drawRoundedRect(card, 5, 5)
324            painter.setPen(Qt.NoPen)
325            painter.setBrush(tint)
326            painter.drawRoundedRect(card, 5, 5)
327            bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6)  # (the age bar, in the card)
328            painter.setBrush(accent)
329            painter.drawRoundedRect(bar, 1.5, 1.5)
330            if card.height() < line_height * 0.6:
331                continue  # (too little of it on view for its label)
332            label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2)
333            x, room = card.left() + 11, card.width() - 15
334            font = QFont(self.font())
335            if not commit.committed:
336                font.setItalic(True)
337                painter.setFont(font)
338                painter.setPen(dim)
339                painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter,
340                                 "Not committed yet")
341                continue
342            parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)]
343            for part, bold, color in parts:
344                font.setBold(bold)
345                painter.setFont(font)
346                width = QFontMetrics(font).horizontalAdvance(part)
347                if width > room:
348                    part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room))
349                    width = room
350                painter.setPen(color)  # (the hash bold, in the text's color: the age is the bar's and tint's)
351                painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter,
352                                 part)
353                x += width + metrics.horizontalAdvance("  ")
354                room = card.right() - 4 - x
355                if room <= 8:
356                    break
357        painter.end()

paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None

def event(self, event):
360    def event(self, event):
361        if event.type() == QEvent.ToolTip:  # (the mouse rested on a card: its card, as fortitude's)
362            commit = self.commit_at(event.pos().y())
363            popup = self.editor.hover_popup
364            if commit is None:
365                if (popup.anchor or ("",))[0] == "blame":
366                    popup.schedule_hide()
367            else:
368                self._card_at = event.globalPos()
369                self.show_card(commit)
370                if commit.committed and commit.sha not in self._details:
371                    self._load_details(commit)
372            return True
373        return super().event(event)

event(self, event: PySide6.QtCore.QEvent, /) -> bool

def show_card(self, commit: Commit):
375    def show_card(self, commit: Commit):
376        """A commit's card in the editor's hover popup (it stays while the mouse
377        goes into it, so its links can be clicked)."""
378        palette = self.editor.palette()
379        html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "",
380                                palette.color(QPalette.Text))
381        self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha))

A commit's card in the editor's hover popup (it stays while the mouse goes into it, so its links can be clicked).

def wheelEvent(self, event):
417    def wheelEvent(self, event):
418        # Scroll the editor, as if the wheel were over the text (as the minimap does): the
419        # column, and the minimap, follow it.
420        self.editor.wheelEvent(event)

wheelEvent(self, event: PySide6.QtGui.QWheelEvent, /) -> None

def mouseMoveEvent(self, event):
422    def mouseMoveEvent(self, event):
423        run = self.run_at(event.position().y())
424        hover = run[0] if run is not None and run[1].committed else None
425        if hover != self._hover:
426            self._hover = hover
427            self.update()
428            self._show_commit_lines(run[1] if hover is not None else None)
429        self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor)

mouseMoveEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def leaveEvent(self, event):
431    def leaveEvent(self, event):
432        self._hover = self._pressed = None
433        self.update()
434        self._show_commit_lines(None)
435        if (self.editor.hover_popup.anchor or ("",))[0] == "blame":
436            self.editor.hover_popup.schedule_hide()  # (unless the mouse went into it)
437        super().leaveEvent(event)

leaveEvent(self, event: PySide6.QtCore.QEvent, /) -> None

def mousePressEvent(self, event):
439    def mousePressEvent(self, event):
440        if event.button() == Qt.LeftButton:
441            run = self.run_at(event.position().y())
442            self._pressed = run[0] if run is not None and run[1].committed else None
443            self.update()

mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseReleaseEvent(self, event):
445    def mouseReleaseEvent(self, event):
446        if event.button() != Qt.LeftButton:
447            return
448        pressed, self._pressed = self._pressed, None
449        self.update()
450        run = self.run_at(event.position().y())
451        if run is None or run[0] != pressed:  # (released off the card pressed: no click, as a button)
452            return
453        commit = run[1]
454        url = self._commit_url(commit)
455        if url:
456            open_web_page(url)
457        else:
458            QApplication.clipboard().setText(commit.sha)
459            QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)),
460                              f"No web page for this repository's commits (its origin isn't on GitHub or "
461                              f"GitLab): the hash {commit.sha[:10]} is copied.", self)

mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("BlameColumn" inherits "QWidget": )
STATUS_CHIPS = {'A': ('added', '#2f9e44'), 'M': ('modified', '#1c7ed6'), 'D': ('deleted', '#e03131'), 'R': ('renamed', '#7048e8'), 'C': ('copied', '#7048e8'), 'T': ('type changed', '#868e96')}
def web_base(remote: str) -> str | None:
477def web_base(remote: str) -> str | None:
478    """The repository's web page (GitHub, GitLab, Codeberg…), None: none known."""
479    if not remote:
480        return None
481    from .window.packages import (
482        repo_web_url,  # (here: the window's module imports the editor's)
483    )
484
485    return repo_web_url(remote)

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

def issue_url(base: str, number: str, merge_request: bool = False) -> str | None:
488def issue_url(base: str, number: str, merge_request: bool = False) -> str | None:
489    """An issue's ("#33") or merge request's ("!34", GitLab's) page."""
490    if "gitlab." in base:
491        return f"{base}/-/{'merge_requests' if merge_request else 'issues'}/{number}"
492    if merge_request:
493        return None  # (only GitLab writes them so)
494    return f"{base}/issues/{number}"  # (GitHub sends a pull request's number on to it; Codeberg too)

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

def file_url(base: str, sha: str, path: str) -> str:
497def file_url(base: str, sha: str, path: str) -> str:
498    """A file as it was in a commit."""
499    if "gitlab." in base:
500        return f"{base}/-/blob/{sha}/{path}"
501    if "codeberg." in base:
502        return f"{base}/src/commit/{sha}/{path}"
503    if "bitbucket." in base:
504        return f"{base}/src/{sha}/{path}"
505    return f"{base}/blob/{sha}/{path}"

A file as it was in a commit.

def linkify(text: str, base: str | None) -> str:
508def linkify(text: str, base: str | None) -> str:
509    """Text for the card: escaped, its web addresses links, and its issue
510    ("#33") and merge request ("!34") numbers links to their pages."""
511    out, last = [], 0
512    pattern = re.compile(r"(?P<url>https?://[^\s<>\"')\]]+)|(?<![\w&/])(?P<kind>[#!])(?P<number>\d+)\b")
513    for match in pattern.finditer(text):
514        out.append(_escape(text[last:match.start()]))
515        last = match.end()
516        if match.group("url"):
517            url = match.group("url").rstrip(".,;:")
518            out.append(f"<a href='{_escape(url)}'>{_escape(url)}</a>{_escape(match.group('url')[len(url):])}")
519            continue
520        url = issue_url(base, match.group("number"), match.group("kind") == "!") if base else None
521        out.append(f"<a href='{url}'>{_escape(match.group(0))}</a>" if url else _escape(match.group(0)))
522    return "".join(out) + _escape(text[last:])

Text for the card: escaped, its web addresses links, and its issue ("#33") and merge request ("!34") numbers links to their pages.

def commit_card_html( commit: Commit, details: CommitDetails | str | None, remote: str, text: PySide6.QtGui.QColor) -> str:
525def commit_card_html(commit: Commit, details: CommitDetails | str | None, remote: str, text: QColor) -> str:
526    """A commit, as the editor's other hover cards (fortitude's): its hash
527    (a link: its page, or "copy"), age band, author (and committer), date,
528    the whole message (issue and merge request numbers, web addresses:
529    links), and the files it changed (each a link to it as it was then),
530    with their lines added and deleted. ``details``: None while they're
531    read; a string: why git couldn't."""
532    muted = QColor(text)
533    muted.setAlpha(150)
534    muted = muted.name(QColor.HexArgb)
535    dark = text.lightness() > 128
536    green, red = ("#57d163", "#ff6b6b") if dark else ("#1a7f37", "#cf222e")
537    small = "font-size:small"
538    base = web_base(remote)
539    host = "GitHub" if base and "github." in base else "GitLab" if base and "gitlab." in base else "the web"
540    page = f"{base}/{'-/commit' if base and 'gitlab.' in base else 'commit'}/{commit.sha}" if base else None
541    if not commit.committed:
542        return (f"<span style='color:{muted}; {small}'>GIT BLAME</span><br>"
543                "<i>Not committed yet</i>: changed since the last commit.")
544    band, color, _strength = age_band(commit.time)
545    when = time.strftime("%Y-%m-%d %H:%M", time.localtime(commit.time)) if commit.time else ""
546    chip = f"background-color:{color}; color:white; font-weight:700; {small}"
547    hash_link = page or f"copy:{commit.sha}"
548    out = [(f"<span style='color:{muted}; {small}'>COMMIT</span><br>"
549            f"<a href='{hash_link}' style='text-decoration:none'><b><code>{commit.sha[:12]}</code></b></a>"
550            f"&nbsp;&nbsp;<span style='{chip}'>&nbsp;{band}&nbsp;</span>")]
551    email = f" &lt;{_escape(details.email)}&gt;" if isinstance(details, CommitDetails) and details.email else ""
552    ago = age_words(commit.time).split(" (")[-1][:-1]
553    out.append(f"<br><b>{_escape(commit.author)}</b><span style='color:{muted}'>{email}</span><br>"
554               f"<span style='color:{muted}; {small}'>{when} · {ago}</span>")
555    if isinstance(details, CommitDetails) and details.committer:
556        out.append(f"<br><span style='color:{muted}; {small}'>committed by {_escape(details.committer)}</span>")
557    message = details.message if isinstance(details, CommitDetails) and details.message else commit.summary
558    subject, _, body = message.partition("\n")
559    out.append(f"<p style='margin-top:8px; margin-bottom:0'><b>{linkify(subject, base)}</b></p>")
560    if body.strip():
561        lines = (linkify(line, base) if line.strip() else "" for line in body.strip("\n").split("\n"))
562        out.append(f"<p style='margin-top:4px; margin-bottom:0'>{'<br>'.join(lines)}</p>")
563    if details is None:
564        out.append(f"<p style='margin-top:8px; color:{muted}'><i>Reading the commit…</i></p>")
565    elif isinstance(details, str):
566        out.append(f"<p style='margin-top:8px; color:{muted}'><i>git couldn't read it: {_escape(details)}</i></p>")
567    elif details.files is not None:
568        files = details.files
569        added = sum(f.added or 0 for f in files)
570        deleted = sum(f.deleted or 0 for f in files)
571        rows = [(f"<p style='margin-top:10px; margin-bottom:2px'><span style='color:{muted}; {small}'>"
572                 f"FILES CHANGED · {len(files)}</span>&nbsp; <span style='color:{green}'>+{added}</span> "
573                 f"<span style='color:{red}'>−{deleted}</span></p><table cellspacing='0' cellpadding='1'>")]
574        for change in files[:MAX_FILES]:
575            word, status_color = STATUS_CHIPS.get(change.status, (change.status, "#868e96"))
576            name = (f"{_escape(change.old_path)} → {_escape(change.path)}" if change.old_path
577                    else _escape(change.path))
578            if base and change.status != "D":
579                name = f"<a href='{_escape(file_url(base, commit.sha, change.path))}'>{name}</a>"
580            if change.added is None:
581                counts, bar = f"<span style='color:{muted}'>binary</span>", ""
582            elif change.added + change.deleted == 0:  # (renamed, or its mode changed: no lines)
583                counts, bar = f"<span style='color:{muted}'>—</span>", ""
584            else:
585                counts = (f"<span style='color:{green}'>+{change.added}</span>&nbsp;"
586                          f"<span style='color:{red}'>−{change.deleted}</span>")
587                total = change.added + change.deleted
588                plus = round(5 * change.added / total) if total else 0
589                bar = (f"<span style='color:{green}'>{'■' * plus}</span>"
590                       f"<span style='color:{red}'>{'■' * (5 - plus if total else 0)}</span>"
591                       f"<span style='color:{muted}'>{'□' * (0 if total else 5)}</span>")
592            rows.append(f"<tr><td style='padding-right:6px'><span style='background-color:{status_color}; "
593                        f"color:white; font-weight:700; {small}' title='{word}'>&nbsp;{change.status}&nbsp;</span>"
594                        f"</td><td style='padding-right:10px'>{name}</td><td align='right'>{counts}&nbsp;</td>"
595                        f"<td>{bar}</td></tr>")
596        rows.append("</table>")
597        if len(files) > MAX_FILES:
598            rows.append(f"<span style='color:{muted}; {small}'>and {len(files) - MAX_FILES} more</span>")
599        out += rows
600    actions = [f"<a href='{page}'>Open on {host}</a>"] if page else []
601    actions.append(f"<a href='copy:{commit.sha}'>Copy hash</a>")
602    out.append(f"<p style='margin-top:10px; margin-bottom:0; {small}'>{' &nbsp;·&nbsp; '.join(actions)}</p>")
603    return "".join(out)

A commit, as the editor's other hover cards (fortitude's): its hash (a link: its page, or "copy"), age band, author (and committer), date, the whole message (issue and merge request numbers, web addresses: links), and the files it changed (each a link to it as it was then), with their lines added and deleted. details: None while they're read; a string: why git couldn't.

def open_web_page(url: str):
606def open_web_page(url: str):
607    """Open a page in the browser."""
608    QDesktopServices.openUrl(QUrl(url))

Open a page in the browser.