vibefpmgui.git_log_view

The Git panel's History page (see vibefpmgui.git_log): the commits, newest first, each with the graph's lanes (its dot, the lines to its parents: a column of their own, wide enough for every lane), its branches and tags (badges: the branch checked out in bold), subject, author, date and hash; every branch's, or only what's checked out (All branches). The newest MAX_COMMITS at first; Load Older Commits lists the rest, back to the first. Choosing a commit shows its card (as git blame's: the whole message, links to its web page and issues), the files it changed, and the chosen file's diff (double-click a line: the file, there).

  1"""The Git panel's *History* page (see `vibefpmgui.git_log`): the commits,
  2newest first, each with the graph's lanes (its dot, the lines to its
  3parents: a column of their own, wide enough for every lane), its branches
  4and tags (badges: the branch checked out in bold), subject, author, date
  5and hash; every branch's, or only what's checked out (*All branches*). The
  6newest MAX_COMMITS at first; Load Older Commits lists the rest, back to the
  7first. Choosing a commit shows its card (as git blame's: the
  8whole message, links to its web page and issues), the files it changed, and
  9the chosen file's diff (double-click a line: the file, there).
 10"""
 11
 12from __future__ import annotations
 13
 14import time
 15from pathlib import Path
 16
 17from qtpy.QtCore import (
 18    QAbstractTableModel,
 19    QModelIndex,
 20    QPointF,
 21    QRectF,
 22    QSize,
 23    Qt,
 24    Signal,
 25)
 26from qtpy.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
 27from qtpy.QtWidgets import (
 28    QApplication,
 29    QHBoxLayout,
 30    QHeaderView,
 31    QLabel,
 32    QMenu,
 33    QPushButton,
 34    QSplitter,
 35    QStackedLayout,
 36    QStyle,
 37    QStyledItemDelegate,
 38    QTextBrowser,
 39    QToolButton,
 40    QTreeView,
 41    QTreeWidget,
 42    QTreeWidgetItem,
 43    QVBoxLayout,
 44    QWidget,
 45)
 46
 47from . import git_blame as gb
 48from . import git_log as gl
 49from . import git_status as gs
 50from .background import call_in_background
 51from .file_diff import FileDiff
 52
 53COLUMNS = ["Graph", "Commit", "Author", "Date", "Hash"]
 54GRAPH, SUBJECT = 0, 1  # (the columns drawn by GraphDelegate)
 55GRAPH_SHARE = 0.6  # the most of the list's width the graph is given at first (wider: drag its edge)
 56LANE_COLORS = {
 57    "light": ["#4c6ef5", "#e8590c", "#2f9e44", "#ae3ec9", "#1098ad", "#e03131", "#f08c00", "#5c940d"],
 58    "dark": ["#748ffc", "#ff922b", "#51cf66", "#da77f2", "#3bc9db", "#ff6b6b", "#fcc419", "#94d82d"],
 59}
 60REF_COLORS = {gl.HEAD: "#e03131", gl.BRANCH: "#2f9e44", gl.REMOTE: "#1c7ed6", gl.TAG: "#e8a317"}
 61COMMIT_ROLE = Qt.UserRole + 1   # a row's git_log.LogCommit
 62ROW_ROLE = Qt.UserRole + 2      # and its git_log.GraphRow
 63CHANGE_ROLE = Qt.UserRole + 3   # a changed file's git_blame.FileChange
 64
 65
 66def date_words(seconds: int) -> str:
 67    return time.strftime("%Y-%m-%d %H:%M", time.localtime(seconds)) if seconds else ""
 68
 69
 70class LogModel(QAbstractTableModel):
 71    def __init__(self, parent=None):
 72        super().__init__(parent)
 73        self.log: gl.Log | None = None
 74        self.lanes = 1  # the graph's width (lanes), the widest row's (every lane: none cut off)
 75
 76    def set_log(self, log: gl.Log | None):
 77        self.beginResetModel()
 78        self.log = log
 79        self.lanes = max((r.width for r in log.rows), default=1) if log else 1
 80        self.endResetModel()
 81
 82    def rowCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
 83        return 0 if parent.isValid() or self.log is None else len(self.log.commits)
 84
 85    def columnCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
 86        return 0 if parent.isValid() else len(COLUMNS)
 87
 88    def headerData(self, section, orientation, role=Qt.DisplayRole):
 89        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
 90            return COLUMNS[section]
 91        return None
 92
 93    def data(self, index, role=Qt.DisplayRole):
 94        if not index.isValid() or self.log is None:
 95            return None
 96        commit = self.log.commits[index.row()]
 97        column = index.column()
 98        if role == Qt.DisplayRole:
 99            return ["", commit.subject, commit.author, date_words(commit.time), commit.sha[:8]][column]
100        if role == Qt.ToolTipRole:
101            refs = ", ".join(r.name for r in commit.refs)
102            subject = f"{commit.subject}" + (f"\n{refs}" if refs else "")
103            return [subject, subject, f"{commit.author} <{commit.email}>", None, commit.sha][column]
104        if role == COMMIT_ROLE:
105            return commit
106        if role == ROW_ROLE:
107            return self.log.rows[index.row()]
108        return None
109
110
111def lane_width(font: QFont) -> float:
112    """A lane's width in the graph, for the list's font."""
113    return max(12.0, QFontMetrics(font).height() * 0.8)
114
115
116def graph_width(lanes: int, font: QFont) -> int:
117    """The Graph column's width for every one of ``lanes``."""
118    return round(8 + lane_width(font) * lanes)
119
120
121class GraphDelegate(QStyledItemDelegate):
122    """The Graph column (the lanes, the commit's dot) and the Commit column
123    (its branch and tag badges, its subject)."""
124
125    def __init__(self, view: GitLogView):
126        super().__init__(view.table)
127        self.view = view
128
129    def sizeHint(self, option, index):
130        size = super().sizeHint(option, index)
131        return QSize(size.width(), max(size.height(), round(QFontMetrics(option.font).height() * 1.5)))
132
133    def paint(self, painter, option, index):
134        commit: gl.LogCommit = index.data(COMMIT_ROLE)
135        row: gl.GraphRow = index.data(ROW_ROLE)
136        style = option.widget.style() if option.widget else QApplication.style()
137        style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget)
138        painter.save()
139        painter.setRenderHint(QPainter.Antialiasing)
140        rect = QRectF(option.rect)
141        if index.column() == SUBJECT:
142            self._paint_subject(painter, option, commit, rect)
143            painter.restore()
144            return
145        lane = lane_width(option.font)
146        colors = LANE_COLORS["dark" if self.view.dark else "light"]
147        top, bottom, middle = rect.top(), rect.bottom() + 1, rect.center().y()
148
149        def x(i: int) -> float:
150            return rect.left() + 4 + lane * (i + 0.5)
151
152        def pen(color: int) -> QPen:
153            line = QPen(QColor(colors[color % len(colors)]), 2)
154            line.setCapStyle(Qt.RoundCap)
155            return line
156
157        painter.setClipRect(rect)  # (the column made narrower than the graph: the rest cut off)
158        for i, color in row.through:
159            painter.setPen(pen(color))
160            painter.drawLine(QPointF(x(i), top), QPointF(x(i), bottom))
161        for i, color in row.joining:  # (from its lane above, curving into the commit)
162            painter.setPen(pen(color))
163            path = QPainterPath(QPointF(x(i), top))
164            path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(row.lane), middle))
165            painter.drawPath(path)
166        if row.from_above:
167            painter.setPen(pen(row.color))
168            painter.drawLine(QPointF(x(row.lane), top), QPointF(x(row.lane), middle))
169        for i, color in row.parents:
170            painter.setPen(pen(color))
171            if i == row.lane:
172                painter.drawLine(QPointF(x(i), middle), QPointF(x(i), bottom))
173            else:  # (a merge's other parent, or a lane waiting for it: curving out to it)
174                path = QPainterPath(QPointF(x(row.lane), middle))
175                path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(i), bottom))
176                painter.drawPath(path)
177        radius = lane * 0.28
178        dot = QColor(colors[row.color % len(colors)])
179        center = QPointF(x(row.lane), middle)
180        is_head = self.view.model.log is not None and commit.sha == self.view.model.log.head
181        if len(commit.parents) > 1:  # (a merge: a ring)
182            painter.setPen(QPen(dot, 2))
183            painter.setBrush(QBrush(QColor(self.view.pal.get("bg", "#ffffff"))))
184        else:
185            painter.setPen(Qt.NoPen)
186            painter.setBrush(QBrush(dot))
187        painter.drawEllipse(center, radius, radius)
188        if is_head:  # (what's checked out: a ring around it)
189            painter.setPen(QPen(QColor(self.view.pal.get("text", "#000000")), 1.5))
190            painter.setBrush(Qt.NoBrush)
191            painter.drawEllipse(center, radius + 3, radius + 3)
192        painter.restore()
193
194    def _paint_subject(self, painter, option, commit: gl.LogCommit, rect: QRectF):
195        """The commit's branches and tags (badges), then its subject."""
196        selected = bool(option.state & QStyle.State_Selected)
197        middle = rect.center().y()
198        left = rect.left() + 4
199        small = QFont(option.font)
200        small.setPointSizeF(max(7.0, option.font.pointSizeF() - 1.5))
201        badge_metrics = QFontMetrics(small)
202        for ref in commit.refs:
203            if left > rect.right() - 40:
204                break
205            bold = QFont(small)
206            bold.setBold(ref.current)
207            text_width = QFontMetrics(bold).horizontalAdvance(ref.name)
208            badge = QRectF(left, middle - badge_metrics.height() / 2 - 1, text_width + 10, badge_metrics.height() + 2)
209            color = QColor(REF_COLORS[ref.kind])
210            solid = ref.current or selected  # (on the selection's color: solid, so it shows)
211            painter.setPen(QPen(QColor("white") if selected else color, 1.2 if ref.current else 1))
212            fill = QColor(color)
213            fill.setAlphaF(0.9 if solid else 0.18)
214            painter.setBrush(fill)
215            painter.drawRoundedRect(badge, 4, 4)
216            painter.setFont(bold)
217            painter.setPen(QColor("white") if solid else color if not self.view.dark else color.lighter(130))
218            painter.drawText(badge, Qt.AlignCenter, ref.name)
219            left = badge.right() + 4
220        painter.setFont(option.font)
221        painter.setPen(option.palette.color(option.palette.ColorRole.HighlightedText if selected
222                                            else option.palette.ColorRole.Text))
223        text_rect = QRectF(left + 2, rect.top(), rect.right() - left - 4, rect.height())
224        painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft,
225                         QFontMetrics(option.font).elidedText(commit.subject, Qt.ElideRight, int(text_rect.width())))
226
227
228class GitLogView(QWidget):
229    open_requested = Signal(str, int)        # a file of the working tree, a line
230    reveal_requested = Signal(str)           # a changed file's "Show in Project Files": its path now
231    all_branches_changed = Signal(bool)      # All branches toggled
232    reload_requested = Signal()              # (All branches toggled, older commits asked for: see reload_key)
233
234    def __init__(self, parent=None):
235        super().__init__(parent)
236        self.log: gl.Log | None = None
237        self.pal: dict = {}
238        self.dark = False
239        self.icon_for = None   # a file's QIcon (set by the panel)
240        self.everything: set[Path] = set()  # repositories whose every commit is listed (Load Older Commits)
241        self._graph_lanes = 0  # (the lanes the Graph column was last made wide enough for)
242        self._details: tuple[str, object] | None = None  # (a commit, its details: CommitDetails, None
243        #                                                   while they're read, or why git couldn't), the one chosen
244
245        self.all_branches = QToolButton()
246        self.all_branches.setObjectName("GitView")
247        self.all_branches.setText("All branches")
248        self.all_branches.setToolTip("Every branch's commits (local and remote) and tags', or only those of what's "
249                                     "checked out")
250        self.all_branches.setCheckable(True)
251        self.all_branches.setChecked(True)
252        self.all_branches.setAutoRaise(True)
253        self.all_branches.clicked.connect(self._all_branches_clicked)
254        self.count = QLabel()
255        self.older_button = QPushButton("Load Older Commits")
256        self.older_button.setToolTip("List every commit, back to the first one (at first, only the newest "
257                                     f"{gl.MAX_COMMITS:,} are)")
258        self.older_button.clicked.connect(self.load_older)
259        self.older_button.hide()
260        bar = QHBoxLayout()
261        bar.setContentsMargins(0, 0, 0, 0)
262        bar.addWidget(self.count)
263        bar.addWidget(self.older_button)
264        bar.addStretch(1)
265        bar.addWidget(self.all_branches)
266
267        self.model = LogModel(self)
268        self.table = QTreeView()
269        self.table.setModel(self.model)
270        self.table.setRootIsDecorated(False)
271        self.table.setUniformRowHeights(True)
272        self.table.setAllColumnsShowFocus(True)
273        delegate = GraphDelegate(self)
274        self.table.setItemDelegateForColumn(GRAPH, delegate)
275        self.table.setItemDelegateForColumn(SUBJECT, delegate)
276        self.table.header().setStretchLastSection(False)
277        self.table.header().setSectionResizeMode(SUBJECT, QHeaderView.Stretch)
278        for column in (GRAPH, 2, 3, 4):
279            self.table.header().setSectionResizeMode(column, QHeaderView.Interactive)
280        self.table.selectionModel().currentRowChanged.connect(self._commit_chosen)
281
282        self.card = QTextBrowser()
283        self.card.setOpenLinks(False)
284        self.card.anchorClicked.connect(self._link)
285        self.files = QTreeWidget()
286        self.files.setHeaderHidden(True)
287        self.files.setRootIsDecorated(False)
288        self.files.setColumnCount(3)  # (the file, its lines added and deleted, its letter: M, A, D…)
289        self.files.setUniformRowHeights(True)
290        self.files.currentItemChanged.connect(self._file_chosen)
291        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
292        self.files.customContextMenuRequested.connect(self._file_menu)
293        self.file_menu: QMenu | None = None  # (kept while it's open)
294        self.file_diff = FileDiff()
295        self.file_diff.open_requested.connect(self._open_line)
296        details = QSplitter(Qt.Vertical)
297        details.addWidget(self.card)
298        details.addWidget(self.files)
299        details.setStretchFactor(0, 3)
300        details.setStretchFactor(1, 2)
301        self.bottom = QSplitter(Qt.Horizontal)
302        self.bottom.addWidget(details)
303        self.bottom.addWidget(self.file_diff)
304        self.bottom.setStretchFactor(0, 2)
305        self.bottom.setStretchFactor(1, 3)
306        self.splitter = QSplitter(Qt.Vertical)
307        self.splitter.addWidget(self.table)
308        self.splitter.addWidget(self.bottom)
309        self.splitter.setStretchFactor(0, 3)
310        self.splitter.setStretchFactor(1, 2)
311
312        content = QWidget()
313        layout = QVBoxLayout(content)
314        layout.setContentsMargins(0, 0, 0, 0)
315        layout.setSpacing(4)
316        layout.addLayout(bar)
317        layout.addWidget(self.splitter, 1)
318        self.message = QLabel()
319        self.message.setAlignment(Qt.AlignCenter)
320        self.message.setWordWrap(True)
321        self.stack = QStackedLayout(self)
322        self.stack.addWidget(self.message)
323        self.stack.addWidget(content)
324        self.content = content
325        self.show_message("Reading the repository…")
326
327    # ------------------------------------------------------------ styling
328    def refresh_palette(self, pal: dict, dark: bool):
329        """New colors: drawn again from what's been read (git isn't asked again;
330        the commit and file chosen, and the diff's place, stay)."""
331        self.pal, self.dark = pal, dark
332        for label in (self.count, self.message):
333            label.setStyleSheet(f"color: {pal['muted']};")
334        self.file_diff.refresh_style(pal["muted"])
335        self.table.viewport().update()
336        commit = self.current_commit()
337        if commit is not None and self._details is not None and self._details[0] == commit.sha:
338            self._show_card(commit, self._details[1])
339        muted = QBrush(QColor(pal["muted"]))
340        for i in range(self.files.topLevelItemCount()):
341            self.files.topLevelItem(i).setForeground(1, muted)
342
343    def set_code_font(self, font: QFont):
344        self.file_diff.set_font(font)
345
346    # ------------------------------------------------------- read by the panel
347    # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.)
348    def reload_key(self, root: Path) -> tuple:
349        """What, besides the repository and HEAD, the history read depends on:
350        All branches, and whether every commit is listed (Load Older Commits)."""
351        return self.all_branches.isChecked(), root in self.everything
352
353    def reader(self, root: Path):
354        """What reads it (on a pool thread)."""
355        all_branches = self.all_branches.isChecked()
356        limit = None if root in self.everything else gl.MAX_COMMITS
357        return lambda: gl.read_log(root, all_branches=all_branches, limit=limit)
358
359    def load_older(self):
360        """Load Older Commits: every commit listed, back to the first (this
361        repository's, from now on while the app runs)."""
362        if self.log is None:
363            return
364        self.everything.add(self.log.root)
365        self.older_button.setEnabled(False)
366        self.older_button.setText("Reading every commit…")
367        self.reload_requested.emit()
368
369    def show_result(self, log: gl.Log):
370        self.show_log(log)
371
372    # -------------------------------------------------------------- state
373    def show_message(self, text: str):
374        self.log = None
375        self.model.set_log(None)
376        self.older_button.hide()
377        self.message.setText(text)
378        self.stack.setCurrentWidget(self.message)
379
380    def show_log(self, log: gl.Log):
381        """A repository's history; the commit chosen before stays chosen if it's still listed."""
382        before = self.current_commit()
383        self.log = log
384        self.model.set_log(log)
385        self.all_branches.setChecked(log.all_branches)
386        n = len(log.commits)
387        self.count.setText(f"{'The newest ' if log.limited else ''}{n:,} commit{'s' * (n != 1)}"
388                           + (f" of {log.total:,}" if log.limited else "")
389                           + (" of every branch" if log.all_branches else " of what's checked out"))
390        older = log.total - n
391        self.older_button.setVisible(log.limited)
392        self.older_button.setEnabled(True)
393        self.older_button.setText(f"Load the {older:,} Older Commit{'s' * (older != 1)}")
394        metrics = QFontMetrics(self.table.font())
395        self.table.setColumnWidth(2, 160)
396        self.table.setColumnWidth(3, metrics.horizontalAdvance("0000-00-00 00:00") + 16)
397        self.table.setColumnWidth(4, metrics.horizontalAdvance("00000000") + 16)
398        if self.model.lanes != self._graph_lanes:  # (more lanes, or fewer: room for each; dragged since: kept)
399            self._graph_lanes = self.model.lanes
400            width = graph_width(self.model.lanes, self.table.font())
401            room = self.table.viewport().width()
402            self.table.setColumnWidth(GRAPH, min(width, round(room * GRAPH_SHARE)) if room > 200 else width)
403        self.stack.setCurrentWidget(self.content)
404        row = next((i for i, c in enumerate(log.commits) if before and c.sha == before.sha), None)
405        if row is None:
406            row = next((i for i, c in enumerate(log.commits) if c.sha == log.head), 0)
407        self.table.setCurrentIndex(self.model.index(row, 0))
408        self.table.scrollTo(self.model.index(row, 0), QTreeView.PositionAtCenter)
409
410    def current_commit(self) -> gl.LogCommit | None:
411        index = self.table.currentIndex()
412        return index.data(COMMIT_ROLE) if index.isValid() else None
413
414    def _all_branches_clicked(self, on: bool):
415        self.all_branches_changed.emit(on)
416        self.reload_requested.emit()
417
418    # ------------------------------------------------------------ a commit
419    def _commit_chosen(self, index, _previous=None):
420        commit = index.data(COMMIT_ROLE) if index.isValid() else None
421        self.files.clear()
422        self.file_diff.clear()
423        if commit is None or self.log is None:
424            self._details = None
425            self.card.clear()
426            return
427        self._details = (commit.sha, None)
428        self._show_card(commit, None)
429        root = self.log.root
430
431        def read():
432            try:
433                return gb.commit_details(root, commit.sha)
434            except (gs.GitError, OSError) as error:
435                return str(error) or type(error).__name__
436
437        call_in_background(read, lambda result: self._details_read(commit, result), self)
438
439    def _show_card(self, commit: gl.LogCommit, details):
440        shown = details
441        if isinstance(details, gb.CommitDetails):  # (its files: in the list under it)
442            shown = gb.CommitDetails(details.email, details.message, details.committer, details.commit_time, None)
443        text = QColor(self.pal.get("text", "#000000"))
444        html = gb.commit_card_html(gb.Commit(commit.sha, commit.author, commit.time, commit.subject), shown,
445                                   self.log.remote if self.log else "", text)
446        self.card.setHtml(html)
447
448    def _details_read(self, commit: gl.LogCommit, result):
449        if self._details is None or self._details[0] != commit.sha:
450            return  # (another commit chosen meanwhile)
451        self._details = (commit.sha, result)
452        self._show_card(commit, result)
453        if not isinstance(result, gb.CommitDetails):
454            return
455        for change in result.files or []:
456            name = f"{change.old_path} → {change.path}" if change.old_path else change.path
457            counts = "binary" if change.added is None else f"+{change.added} −{change.deleted}"
458            item = QTreeWidgetItem([name, counts, change.status])
459            item.setData(0, CHANGE_ROLE, change)
460            word, color = gb.STATUS_CHIPS.get(change.status, (change.status, "#868e96"))
461            item.setToolTip(0, f"{change.path}\n{word}")
462            item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
463            item.setForeground(1, QBrush(QColor(self.pal.get("muted", "#888888"))))
464            item.setForeground(2, QBrush(QColor(color)))
465            item.setToolTip(2, word)
466            if self.icon_for is not None and self.log is not None:
467                item.setIcon(0, self.icon_for(self.log.root / change.path))
468            self.files.addTopLevelItem(item)
469        header = self.files.header()
470        header.setStretchLastSection(False)
471        header.setSectionResizeMode(0, QHeaderView.Stretch)
472        header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
473        header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
474        if self.files.topLevelItemCount():
475            self.files.setCurrentItem(self.files.topLevelItem(0))
476
477    # -------------------------------------------------------------- a file
478    def _file_chosen(self, item, _previous=None):
479        change = item.data(0, CHANGE_ROLE) if item is not None else None
480        commit = self.current_commit()
481        if change is None or commit is None or self.log is None:
482            return
483        root = self.log.root
484
485        def read():
486            before, after = gl.file_versions(root, commit, change)
487            parent = commit.parents[0][:8] if commit.parents else ""
488            return before, after, f"{parent} → {commit.sha[:8]}" if parent else f"added in {commit.sha[:8]}"
489
490        self.file_diff.load((commit.sha, change.path), change.path, read)
491
492    def _file_menu(self, pos):
493        """A changed file's menu (a right-click on it): Show in Project Files,
494        if it's still there (where the commit left it) in the working tree."""
495        item = self.files.itemAt(pos)
496        change = item.data(0, CHANGE_ROLE) if item is not None else None
497        if change is None or self.log is None:
498            return
499        path = self.log.root / change.path
500        menu = self.file_menu = QMenu(self)
501        show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
502        if change.status == "D" or not path.exists():
503            show.setEnabled(False)
504            show.setText("Show in Project Files (not there now)")
505        menu.aboutToHide.connect(menu.deleteLater)
506        menu.popup(self.files.viewport().mapToGlobal(pos))
507
508    def _open_line(self, line: int):
509        item = self.files.currentItem()
510        change = item.data(0, CHANGE_ROLE) if item is not None else None
511        if change is not None and self.log is not None and (self.log.root / change.path).is_file():
512            self.open_requested.emit(str(self.log.root / change.path), line)
513
514    def _link(self, url):
515        target = url.toString()
516        if target.startswith("copy:"):
517            QApplication.clipboard().setText(target.removeprefix("copy:"))
518        elif target.startswith(("http:", "https:")):
519            gb.open_web_page(target)
COLUMNS = ['Graph', 'Commit', 'Author', 'Date', 'Hash']
GRAPH_SHARE = 0.6
LANE_COLORS = {'light': ['#4c6ef5', '#e8590c', '#2f9e44', '#ae3ec9', '#1098ad', '#e03131', '#f08c00', '#5c940d'], 'dark': ['#748ffc', '#ff922b', '#51cf66', '#da77f2', '#3bc9db', '#ff6b6b', '#fcc419', '#94d82d']}
REF_COLORS = {'head': '#e03131', 'branch': '#2f9e44', 'remote': '#1c7ed6', 'tag': '#e8a317'}
COMMIT_ROLE = 257
ROW_ROLE = 258
CHANGE_ROLE = 259
def date_words(seconds: int) -> str:
67def date_words(seconds: int) -> str:
68    return time.strftime("%Y-%m-%d %H:%M", time.localtime(seconds)) if seconds else ""
class LogModel(PySide6.QtCore.QAbstractTableModel):
 71class LogModel(QAbstractTableModel):
 72    def __init__(self, parent=None):
 73        super().__init__(parent)
 74        self.log: gl.Log | None = None
 75        self.lanes = 1  # the graph's width (lanes), the widest row's (every lane: none cut off)
 76
 77    def set_log(self, log: gl.Log | None):
 78        self.beginResetModel()
 79        self.log = log
 80        self.lanes = max((r.width for r in log.rows), default=1) if log else 1
 81        self.endResetModel()
 82
 83    def rowCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
 84        return 0 if parent.isValid() or self.log is None else len(self.log.commits)
 85
 86    def columnCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
 87        return 0 if parent.isValid() else len(COLUMNS)
 88
 89    def headerData(self, section, orientation, role=Qt.DisplayRole):
 90        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
 91            return COLUMNS[section]
 92        return None
 93
 94    def data(self, index, role=Qt.DisplayRole):
 95        if not index.isValid() or self.log is None:
 96            return None
 97        commit = self.log.commits[index.row()]
 98        column = index.column()
 99        if role == Qt.DisplayRole:
100            return ["", commit.subject, commit.author, date_words(commit.time), commit.sha[:8]][column]
101        if role == Qt.ToolTipRole:
102            refs = ", ".join(r.name for r in commit.refs)
103            subject = f"{commit.subject}" + (f"\n{refs}" if refs else "")
104            return [subject, subject, f"{commit.author} <{commit.email}>", None, commit.sha][column]
105        if role == COMMIT_ROLE:
106            return commit
107        if role == ROW_ROLE:
108            return self.log.rows[index.row()]
109        return None

QAbstractTableModel(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

LogModel(parent=None)
72    def __init__(self, parent=None):
73        super().__init__(parent)
74        self.log: gl.Log | None = None
75        self.lanes = 1  # the graph's width (lanes), the widest row's (every lane: none cut off)

__init__(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

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

lanes
def set_log(self, log: vibefpmgui.git_log.Log | None):
77    def set_log(self, log: gl.Log | None):
78        self.beginResetModel()
79        self.log = log
80        self.lanes = max((r.width for r in log.rows), default=1) if log else 1
81        self.endResetModel()
def rowCount(self, parent=<PySide6.QtCore.QModelIndex(-1,-1,0x0,QObject(0x0))>):
83    def rowCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
84        return 0 if parent.isValid() or self.log is None else len(self.log.commits)

rowCount(self, /, parent: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex = Invalid(PySide6.QtCore.QModelIndex)) -> int

def columnCount(self, parent=<PySide6.QtCore.QModelIndex(-1,-1,0x0,QObject(0x0))>):
86    def columnCount(self, parent=QModelIndex()):  # noqa: B008 - Qt's signature
87        return 0 if parent.isValid() else len(COLUMNS)

columnCount(self, /, parent: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex = Invalid(PySide6.QtCore.QModelIndex)) -> int

def headerData(self, section, orientation, role=<ItemDataRole.DisplayRole: 0>):
89    def headerData(self, section, orientation, role=Qt.DisplayRole):
90        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
91            return COLUMNS[section]
92        return None

headerData(self, section: int, orientation: PySide6.QtCore.Qt.Orientation, /, role: int = Instance(Qt.DisplayRole)) -> Any

def data(self, index, role=<ItemDataRole.DisplayRole: 0>):
 94    def data(self, index, role=Qt.DisplayRole):
 95        if not index.isValid() or self.log is None:
 96            return None
 97        commit = self.log.commits[index.row()]
 98        column = index.column()
 99        if role == Qt.DisplayRole:
100            return ["", commit.subject, commit.author, date_words(commit.time), commit.sha[:8]][column]
101        if role == Qt.ToolTipRole:
102            refs = ", ".join(r.name for r in commit.refs)
103            subject = f"{commit.subject}" + (f"\n{refs}" if refs else "")
104            return [subject, subject, f"{commit.author} <{commit.email}>", None, commit.sha][column]
105        if role == COMMIT_ROLE:
106            return commit
107        if role == ROW_ROLE:
108            return self.log.rows[index.row()]
109        return None

data(self, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /, role: int = Instance(Qt.DisplayRole)) -> Any

staticMetaObject = PySide6.QtCore.QMetaObject("LogModel" inherits "QAbstractTableModel": )
def lane_width(font: PySide6.QtGui.QFont) -> float:
112def lane_width(font: QFont) -> float:
113    """A lane's width in the graph, for the list's font."""
114    return max(12.0, QFontMetrics(font).height() * 0.8)

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

def graph_width(lanes: int, font: PySide6.QtGui.QFont) -> int:
117def graph_width(lanes: int, font: QFont) -> int:
118    """The Graph column's width for every one of ``lanes``."""
119    return round(8 + lane_width(font) * lanes)

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

class GraphDelegate(PySide6.QtWidgets.QStyledItemDelegate):
122class GraphDelegate(QStyledItemDelegate):
123    """The Graph column (the lanes, the commit's dot) and the Commit column
124    (its branch and tag badges, its subject)."""
125
126    def __init__(self, view: GitLogView):
127        super().__init__(view.table)
128        self.view = view
129
130    def sizeHint(self, option, index):
131        size = super().sizeHint(option, index)
132        return QSize(size.width(), max(size.height(), round(QFontMetrics(option.font).height() * 1.5)))
133
134    def paint(self, painter, option, index):
135        commit: gl.LogCommit = index.data(COMMIT_ROLE)
136        row: gl.GraphRow = index.data(ROW_ROLE)
137        style = option.widget.style() if option.widget else QApplication.style()
138        style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget)
139        painter.save()
140        painter.setRenderHint(QPainter.Antialiasing)
141        rect = QRectF(option.rect)
142        if index.column() == SUBJECT:
143            self._paint_subject(painter, option, commit, rect)
144            painter.restore()
145            return
146        lane = lane_width(option.font)
147        colors = LANE_COLORS["dark" if self.view.dark else "light"]
148        top, bottom, middle = rect.top(), rect.bottom() + 1, rect.center().y()
149
150        def x(i: int) -> float:
151            return rect.left() + 4 + lane * (i + 0.5)
152
153        def pen(color: int) -> QPen:
154            line = QPen(QColor(colors[color % len(colors)]), 2)
155            line.setCapStyle(Qt.RoundCap)
156            return line
157
158        painter.setClipRect(rect)  # (the column made narrower than the graph: the rest cut off)
159        for i, color in row.through:
160            painter.setPen(pen(color))
161            painter.drawLine(QPointF(x(i), top), QPointF(x(i), bottom))
162        for i, color in row.joining:  # (from its lane above, curving into the commit)
163            painter.setPen(pen(color))
164            path = QPainterPath(QPointF(x(i), top))
165            path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(row.lane), middle))
166            painter.drawPath(path)
167        if row.from_above:
168            painter.setPen(pen(row.color))
169            painter.drawLine(QPointF(x(row.lane), top), QPointF(x(row.lane), middle))
170        for i, color in row.parents:
171            painter.setPen(pen(color))
172            if i == row.lane:
173                painter.drawLine(QPointF(x(i), middle), QPointF(x(i), bottom))
174            else:  # (a merge's other parent, or a lane waiting for it: curving out to it)
175                path = QPainterPath(QPointF(x(row.lane), middle))
176                path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(i), bottom))
177                painter.drawPath(path)
178        radius = lane * 0.28
179        dot = QColor(colors[row.color % len(colors)])
180        center = QPointF(x(row.lane), middle)
181        is_head = self.view.model.log is not None and commit.sha == self.view.model.log.head
182        if len(commit.parents) > 1:  # (a merge: a ring)
183            painter.setPen(QPen(dot, 2))
184            painter.setBrush(QBrush(QColor(self.view.pal.get("bg", "#ffffff"))))
185        else:
186            painter.setPen(Qt.NoPen)
187            painter.setBrush(QBrush(dot))
188        painter.drawEllipse(center, radius, radius)
189        if is_head:  # (what's checked out: a ring around it)
190            painter.setPen(QPen(QColor(self.view.pal.get("text", "#000000")), 1.5))
191            painter.setBrush(Qt.NoBrush)
192            painter.drawEllipse(center, radius + 3, radius + 3)
193        painter.restore()
194
195    def _paint_subject(self, painter, option, commit: gl.LogCommit, rect: QRectF):
196        """The commit's branches and tags (badges), then its subject."""
197        selected = bool(option.state & QStyle.State_Selected)
198        middle = rect.center().y()
199        left = rect.left() + 4
200        small = QFont(option.font)
201        small.setPointSizeF(max(7.0, option.font.pointSizeF() - 1.5))
202        badge_metrics = QFontMetrics(small)
203        for ref in commit.refs:
204            if left > rect.right() - 40:
205                break
206            bold = QFont(small)
207            bold.setBold(ref.current)
208            text_width = QFontMetrics(bold).horizontalAdvance(ref.name)
209            badge = QRectF(left, middle - badge_metrics.height() / 2 - 1, text_width + 10, badge_metrics.height() + 2)
210            color = QColor(REF_COLORS[ref.kind])
211            solid = ref.current or selected  # (on the selection's color: solid, so it shows)
212            painter.setPen(QPen(QColor("white") if selected else color, 1.2 if ref.current else 1))
213            fill = QColor(color)
214            fill.setAlphaF(0.9 if solid else 0.18)
215            painter.setBrush(fill)
216            painter.drawRoundedRect(badge, 4, 4)
217            painter.setFont(bold)
218            painter.setPen(QColor("white") if solid else color if not self.view.dark else color.lighter(130))
219            painter.drawText(badge, Qt.AlignCenter, ref.name)
220            left = badge.right() + 4
221        painter.setFont(option.font)
222        painter.setPen(option.palette.color(option.palette.ColorRole.HighlightedText if selected
223                                            else option.palette.ColorRole.Text))
224        text_rect = QRectF(left + 2, rect.top(), rect.right() - left - 4, rect.height())
225        painter.drawText(text_rect, Qt.AlignVCenter | Qt.AlignLeft,
226                         QFontMetrics(option.font).elidedText(commit.subject, Qt.ElideRight, int(text_rect.width())))

The Graph column (the lanes, the commit's dot) and the Commit column (its branch and tag badges, its subject).

GraphDelegate(view: GitLogView)
126    def __init__(self, view: GitLogView):
127        super().__init__(view.table)
128        self.view = view

__init__(self, /, parent: PySide6.QtCore.QObject | None = None) -> None

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

view
def sizeHint(self, option, index):
130    def sizeHint(self, option, index):
131        size = super().sizeHint(option, index)
132        return QSize(size.width(), max(size.height(), round(QFontMetrics(option.font).height() * 1.5)))

sizeHint(self, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> PySide6.QtCore.QSize

def paint(self, painter, option, index):
134    def paint(self, painter, option, index):
135        commit: gl.LogCommit = index.data(COMMIT_ROLE)
136        row: gl.GraphRow = index.data(ROW_ROLE)
137        style = option.widget.style() if option.widget else QApplication.style()
138        style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget)
139        painter.save()
140        painter.setRenderHint(QPainter.Antialiasing)
141        rect = QRectF(option.rect)
142        if index.column() == SUBJECT:
143            self._paint_subject(painter, option, commit, rect)
144            painter.restore()
145            return
146        lane = lane_width(option.font)
147        colors = LANE_COLORS["dark" if self.view.dark else "light"]
148        top, bottom, middle = rect.top(), rect.bottom() + 1, rect.center().y()
149
150        def x(i: int) -> float:
151            return rect.left() + 4 + lane * (i + 0.5)
152
153        def pen(color: int) -> QPen:
154            line = QPen(QColor(colors[color % len(colors)]), 2)
155            line.setCapStyle(Qt.RoundCap)
156            return line
157
158        painter.setClipRect(rect)  # (the column made narrower than the graph: the rest cut off)
159        for i, color in row.through:
160            painter.setPen(pen(color))
161            painter.drawLine(QPointF(x(i), top), QPointF(x(i), bottom))
162        for i, color in row.joining:  # (from its lane above, curving into the commit)
163            painter.setPen(pen(color))
164            path = QPainterPath(QPointF(x(i), top))
165            path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(row.lane), middle))
166            painter.drawPath(path)
167        if row.from_above:
168            painter.setPen(pen(row.color))
169            painter.drawLine(QPointF(x(row.lane), top), QPointF(x(row.lane), middle))
170        for i, color in row.parents:
171            painter.setPen(pen(color))
172            if i == row.lane:
173                painter.drawLine(QPointF(x(i), middle), QPointF(x(i), bottom))
174            else:  # (a merge's other parent, or a lane waiting for it: curving out to it)
175                path = QPainterPath(QPointF(x(row.lane), middle))
176                path.cubicTo(QPointF(x(i), middle), QPointF(x(i), middle), QPointF(x(i), bottom))
177                painter.drawPath(path)
178        radius = lane * 0.28
179        dot = QColor(colors[row.color % len(colors)])
180        center = QPointF(x(row.lane), middle)
181        is_head = self.view.model.log is not None and commit.sha == self.view.model.log.head
182        if len(commit.parents) > 1:  # (a merge: a ring)
183            painter.setPen(QPen(dot, 2))
184            painter.setBrush(QBrush(QColor(self.view.pal.get("bg", "#ffffff"))))
185        else:
186            painter.setPen(Qt.NoPen)
187            painter.setBrush(QBrush(dot))
188        painter.drawEllipse(center, radius, radius)
189        if is_head:  # (what's checked out: a ring around it)
190            painter.setPen(QPen(QColor(self.view.pal.get("text", "#000000")), 1.5))
191            painter.setBrush(Qt.NoBrush)
192            painter.drawEllipse(center, radius + 3, radius + 3)
193        painter.restore()

paint(self, painter: PySide6.QtGui.QPainter, option: PySide6.QtWidgets.QStyleOptionViewItem, index: PySide6.QtCore.QModelIndex | PySide6.QtCore.QPersistentModelIndex, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("GraphDelegate" inherits "QStyledItemDelegate": )
class GitLogView(PySide6.QtWidgets.QWidget):
229class GitLogView(QWidget):
230    open_requested = Signal(str, int)        # a file of the working tree, a line
231    reveal_requested = Signal(str)           # a changed file's "Show in Project Files": its path now
232    all_branches_changed = Signal(bool)      # All branches toggled
233    reload_requested = Signal()              # (All branches toggled, older commits asked for: see reload_key)
234
235    def __init__(self, parent=None):
236        super().__init__(parent)
237        self.log: gl.Log | None = None
238        self.pal: dict = {}
239        self.dark = False
240        self.icon_for = None   # a file's QIcon (set by the panel)
241        self.everything: set[Path] = set()  # repositories whose every commit is listed (Load Older Commits)
242        self._graph_lanes = 0  # (the lanes the Graph column was last made wide enough for)
243        self._details: tuple[str, object] | None = None  # (a commit, its details: CommitDetails, None
244        #                                                   while they're read, or why git couldn't), the one chosen
245
246        self.all_branches = QToolButton()
247        self.all_branches.setObjectName("GitView")
248        self.all_branches.setText("All branches")
249        self.all_branches.setToolTip("Every branch's commits (local and remote) and tags', or only those of what's "
250                                     "checked out")
251        self.all_branches.setCheckable(True)
252        self.all_branches.setChecked(True)
253        self.all_branches.setAutoRaise(True)
254        self.all_branches.clicked.connect(self._all_branches_clicked)
255        self.count = QLabel()
256        self.older_button = QPushButton("Load Older Commits")
257        self.older_button.setToolTip("List every commit, back to the first one (at first, only the newest "
258                                     f"{gl.MAX_COMMITS:,} are)")
259        self.older_button.clicked.connect(self.load_older)
260        self.older_button.hide()
261        bar = QHBoxLayout()
262        bar.setContentsMargins(0, 0, 0, 0)
263        bar.addWidget(self.count)
264        bar.addWidget(self.older_button)
265        bar.addStretch(1)
266        bar.addWidget(self.all_branches)
267
268        self.model = LogModel(self)
269        self.table = QTreeView()
270        self.table.setModel(self.model)
271        self.table.setRootIsDecorated(False)
272        self.table.setUniformRowHeights(True)
273        self.table.setAllColumnsShowFocus(True)
274        delegate = GraphDelegate(self)
275        self.table.setItemDelegateForColumn(GRAPH, delegate)
276        self.table.setItemDelegateForColumn(SUBJECT, delegate)
277        self.table.header().setStretchLastSection(False)
278        self.table.header().setSectionResizeMode(SUBJECT, QHeaderView.Stretch)
279        for column in (GRAPH, 2, 3, 4):
280            self.table.header().setSectionResizeMode(column, QHeaderView.Interactive)
281        self.table.selectionModel().currentRowChanged.connect(self._commit_chosen)
282
283        self.card = QTextBrowser()
284        self.card.setOpenLinks(False)
285        self.card.anchorClicked.connect(self._link)
286        self.files = QTreeWidget()
287        self.files.setHeaderHidden(True)
288        self.files.setRootIsDecorated(False)
289        self.files.setColumnCount(3)  # (the file, its lines added and deleted, its letter: M, A, D…)
290        self.files.setUniformRowHeights(True)
291        self.files.currentItemChanged.connect(self._file_chosen)
292        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
293        self.files.customContextMenuRequested.connect(self._file_menu)
294        self.file_menu: QMenu | None = None  # (kept while it's open)
295        self.file_diff = FileDiff()
296        self.file_diff.open_requested.connect(self._open_line)
297        details = QSplitter(Qt.Vertical)
298        details.addWidget(self.card)
299        details.addWidget(self.files)
300        details.setStretchFactor(0, 3)
301        details.setStretchFactor(1, 2)
302        self.bottom = QSplitter(Qt.Horizontal)
303        self.bottom.addWidget(details)
304        self.bottom.addWidget(self.file_diff)
305        self.bottom.setStretchFactor(0, 2)
306        self.bottom.setStretchFactor(1, 3)
307        self.splitter = QSplitter(Qt.Vertical)
308        self.splitter.addWidget(self.table)
309        self.splitter.addWidget(self.bottom)
310        self.splitter.setStretchFactor(0, 3)
311        self.splitter.setStretchFactor(1, 2)
312
313        content = QWidget()
314        layout = QVBoxLayout(content)
315        layout.setContentsMargins(0, 0, 0, 0)
316        layout.setSpacing(4)
317        layout.addLayout(bar)
318        layout.addWidget(self.splitter, 1)
319        self.message = QLabel()
320        self.message.setAlignment(Qt.AlignCenter)
321        self.message.setWordWrap(True)
322        self.stack = QStackedLayout(self)
323        self.stack.addWidget(self.message)
324        self.stack.addWidget(content)
325        self.content = content
326        self.show_message("Reading the repository…")
327
328    # ------------------------------------------------------------ styling
329    def refresh_palette(self, pal: dict, dark: bool):
330        """New colors: drawn again from what's been read (git isn't asked again;
331        the commit and file chosen, and the diff's place, stay)."""
332        self.pal, self.dark = pal, dark
333        for label in (self.count, self.message):
334            label.setStyleSheet(f"color: {pal['muted']};")
335        self.file_diff.refresh_style(pal["muted"])
336        self.table.viewport().update()
337        commit = self.current_commit()
338        if commit is not None and self._details is not None and self._details[0] == commit.sha:
339            self._show_card(commit, self._details[1])
340        muted = QBrush(QColor(pal["muted"]))
341        for i in range(self.files.topLevelItemCount()):
342            self.files.topLevelItem(i).setForeground(1, muted)
343
344    def set_code_font(self, font: QFont):
345        self.file_diff.set_font(font)
346
347    # ------------------------------------------------------- read by the panel
348    # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.)
349    def reload_key(self, root: Path) -> tuple:
350        """What, besides the repository and HEAD, the history read depends on:
351        All branches, and whether every commit is listed (Load Older Commits)."""
352        return self.all_branches.isChecked(), root in self.everything
353
354    def reader(self, root: Path):
355        """What reads it (on a pool thread)."""
356        all_branches = self.all_branches.isChecked()
357        limit = None if root in self.everything else gl.MAX_COMMITS
358        return lambda: gl.read_log(root, all_branches=all_branches, limit=limit)
359
360    def load_older(self):
361        """Load Older Commits: every commit listed, back to the first (this
362        repository's, from now on while the app runs)."""
363        if self.log is None:
364            return
365        self.everything.add(self.log.root)
366        self.older_button.setEnabled(False)
367        self.older_button.setText("Reading every commit…")
368        self.reload_requested.emit()
369
370    def show_result(self, log: gl.Log):
371        self.show_log(log)
372
373    # -------------------------------------------------------------- state
374    def show_message(self, text: str):
375        self.log = None
376        self.model.set_log(None)
377        self.older_button.hide()
378        self.message.setText(text)
379        self.stack.setCurrentWidget(self.message)
380
381    def show_log(self, log: gl.Log):
382        """A repository's history; the commit chosen before stays chosen if it's still listed."""
383        before = self.current_commit()
384        self.log = log
385        self.model.set_log(log)
386        self.all_branches.setChecked(log.all_branches)
387        n = len(log.commits)
388        self.count.setText(f"{'The newest ' if log.limited else ''}{n:,} commit{'s' * (n != 1)}"
389                           + (f" of {log.total:,}" if log.limited else "")
390                           + (" of every branch" if log.all_branches else " of what's checked out"))
391        older = log.total - n
392        self.older_button.setVisible(log.limited)
393        self.older_button.setEnabled(True)
394        self.older_button.setText(f"Load the {older:,} Older Commit{'s' * (older != 1)}")
395        metrics = QFontMetrics(self.table.font())
396        self.table.setColumnWidth(2, 160)
397        self.table.setColumnWidth(3, metrics.horizontalAdvance("0000-00-00 00:00") + 16)
398        self.table.setColumnWidth(4, metrics.horizontalAdvance("00000000") + 16)
399        if self.model.lanes != self._graph_lanes:  # (more lanes, or fewer: room for each; dragged since: kept)
400            self._graph_lanes = self.model.lanes
401            width = graph_width(self.model.lanes, self.table.font())
402            room = self.table.viewport().width()
403            self.table.setColumnWidth(GRAPH, min(width, round(room * GRAPH_SHARE)) if room > 200 else width)
404        self.stack.setCurrentWidget(self.content)
405        row = next((i for i, c in enumerate(log.commits) if before and c.sha == before.sha), None)
406        if row is None:
407            row = next((i for i, c in enumerate(log.commits) if c.sha == log.head), 0)
408        self.table.setCurrentIndex(self.model.index(row, 0))
409        self.table.scrollTo(self.model.index(row, 0), QTreeView.PositionAtCenter)
410
411    def current_commit(self) -> gl.LogCommit | None:
412        index = self.table.currentIndex()
413        return index.data(COMMIT_ROLE) if index.isValid() else None
414
415    def _all_branches_clicked(self, on: bool):
416        self.all_branches_changed.emit(on)
417        self.reload_requested.emit()
418
419    # ------------------------------------------------------------ a commit
420    def _commit_chosen(self, index, _previous=None):
421        commit = index.data(COMMIT_ROLE) if index.isValid() else None
422        self.files.clear()
423        self.file_diff.clear()
424        if commit is None or self.log is None:
425            self._details = None
426            self.card.clear()
427            return
428        self._details = (commit.sha, None)
429        self._show_card(commit, None)
430        root = self.log.root
431
432        def read():
433            try:
434                return gb.commit_details(root, commit.sha)
435            except (gs.GitError, OSError) as error:
436                return str(error) or type(error).__name__
437
438        call_in_background(read, lambda result: self._details_read(commit, result), self)
439
440    def _show_card(self, commit: gl.LogCommit, details):
441        shown = details
442        if isinstance(details, gb.CommitDetails):  # (its files: in the list under it)
443            shown = gb.CommitDetails(details.email, details.message, details.committer, details.commit_time, None)
444        text = QColor(self.pal.get("text", "#000000"))
445        html = gb.commit_card_html(gb.Commit(commit.sha, commit.author, commit.time, commit.subject), shown,
446                                   self.log.remote if self.log else "", text)
447        self.card.setHtml(html)
448
449    def _details_read(self, commit: gl.LogCommit, result):
450        if self._details is None or self._details[0] != commit.sha:
451            return  # (another commit chosen meanwhile)
452        self._details = (commit.sha, result)
453        self._show_card(commit, result)
454        if not isinstance(result, gb.CommitDetails):
455            return
456        for change in result.files or []:
457            name = f"{change.old_path} → {change.path}" if change.old_path else change.path
458            counts = "binary" if change.added is None else f"+{change.added} −{change.deleted}"
459            item = QTreeWidgetItem([name, counts, change.status])
460            item.setData(0, CHANGE_ROLE, change)
461            word, color = gb.STATUS_CHIPS.get(change.status, (change.status, "#868e96"))
462            item.setToolTip(0, f"{change.path}\n{word}")
463            item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
464            item.setForeground(1, QBrush(QColor(self.pal.get("muted", "#888888"))))
465            item.setForeground(2, QBrush(QColor(color)))
466            item.setToolTip(2, word)
467            if self.icon_for is not None and self.log is not None:
468                item.setIcon(0, self.icon_for(self.log.root / change.path))
469            self.files.addTopLevelItem(item)
470        header = self.files.header()
471        header.setStretchLastSection(False)
472        header.setSectionResizeMode(0, QHeaderView.Stretch)
473        header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
474        header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
475        if self.files.topLevelItemCount():
476            self.files.setCurrentItem(self.files.topLevelItem(0))
477
478    # -------------------------------------------------------------- a file
479    def _file_chosen(self, item, _previous=None):
480        change = item.data(0, CHANGE_ROLE) if item is not None else None
481        commit = self.current_commit()
482        if change is None or commit is None or self.log is None:
483            return
484        root = self.log.root
485
486        def read():
487            before, after = gl.file_versions(root, commit, change)
488            parent = commit.parents[0][:8] if commit.parents else ""
489            return before, after, f"{parent} → {commit.sha[:8]}" if parent else f"added in {commit.sha[:8]}"
490
491        self.file_diff.load((commit.sha, change.path), change.path, read)
492
493    def _file_menu(self, pos):
494        """A changed file's menu (a right-click on it): Show in Project Files,
495        if it's still there (where the commit left it) in the working tree."""
496        item = self.files.itemAt(pos)
497        change = item.data(0, CHANGE_ROLE) if item is not None else None
498        if change is None or self.log is None:
499            return
500        path = self.log.root / change.path
501        menu = self.file_menu = QMenu(self)
502        show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
503        if change.status == "D" or not path.exists():
504            show.setEnabled(False)
505            show.setText("Show in Project Files (not there now)")
506        menu.aboutToHide.connect(menu.deleteLater)
507        menu.popup(self.files.viewport().mapToGlobal(pos))
508
509    def _open_line(self, line: int):
510        item = self.files.currentItem()
511        change = item.data(0, CHANGE_ROLE) if item is not None else None
512        if change is not None and self.log is not None and (self.log.root / change.path).is_file():
513            self.open_requested.emit(str(self.log.root / change.path), line)
514
515    def _link(self, url):
516        target = url.toString()
517        if target.startswith("copy:"):
518            QApplication.clipboard().setText(target.removeprefix("copy:"))
519        elif target.startswith(("http:", "https:")):
520            gb.open_web_page(target)

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

GitLogView(parent=None)
235    def __init__(self, parent=None):
236        super().__init__(parent)
237        self.log: gl.Log | None = None
238        self.pal: dict = {}
239        self.dark = False
240        self.icon_for = None   # a file's QIcon (set by the panel)
241        self.everything: set[Path] = set()  # repositories whose every commit is listed (Load Older Commits)
242        self._graph_lanes = 0  # (the lanes the Graph column was last made wide enough for)
243        self._details: tuple[str, object] | None = None  # (a commit, its details: CommitDetails, None
244        #                                                   while they're read, or why git couldn't), the one chosen
245
246        self.all_branches = QToolButton()
247        self.all_branches.setObjectName("GitView")
248        self.all_branches.setText("All branches")
249        self.all_branches.setToolTip("Every branch's commits (local and remote) and tags', or only those of what's "
250                                     "checked out")
251        self.all_branches.setCheckable(True)
252        self.all_branches.setChecked(True)
253        self.all_branches.setAutoRaise(True)
254        self.all_branches.clicked.connect(self._all_branches_clicked)
255        self.count = QLabel()
256        self.older_button = QPushButton("Load Older Commits")
257        self.older_button.setToolTip("List every commit, back to the first one (at first, only the newest "
258                                     f"{gl.MAX_COMMITS:,} are)")
259        self.older_button.clicked.connect(self.load_older)
260        self.older_button.hide()
261        bar = QHBoxLayout()
262        bar.setContentsMargins(0, 0, 0, 0)
263        bar.addWidget(self.count)
264        bar.addWidget(self.older_button)
265        bar.addStretch(1)
266        bar.addWidget(self.all_branches)
267
268        self.model = LogModel(self)
269        self.table = QTreeView()
270        self.table.setModel(self.model)
271        self.table.setRootIsDecorated(False)
272        self.table.setUniformRowHeights(True)
273        self.table.setAllColumnsShowFocus(True)
274        delegate = GraphDelegate(self)
275        self.table.setItemDelegateForColumn(GRAPH, delegate)
276        self.table.setItemDelegateForColumn(SUBJECT, delegate)
277        self.table.header().setStretchLastSection(False)
278        self.table.header().setSectionResizeMode(SUBJECT, QHeaderView.Stretch)
279        for column in (GRAPH, 2, 3, 4):
280            self.table.header().setSectionResizeMode(column, QHeaderView.Interactive)
281        self.table.selectionModel().currentRowChanged.connect(self._commit_chosen)
282
283        self.card = QTextBrowser()
284        self.card.setOpenLinks(False)
285        self.card.anchorClicked.connect(self._link)
286        self.files = QTreeWidget()
287        self.files.setHeaderHidden(True)
288        self.files.setRootIsDecorated(False)
289        self.files.setColumnCount(3)  # (the file, its lines added and deleted, its letter: M, A, D…)
290        self.files.setUniformRowHeights(True)
291        self.files.currentItemChanged.connect(self._file_chosen)
292        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
293        self.files.customContextMenuRequested.connect(self._file_menu)
294        self.file_menu: QMenu | None = None  # (kept while it's open)
295        self.file_diff = FileDiff()
296        self.file_diff.open_requested.connect(self._open_line)
297        details = QSplitter(Qt.Vertical)
298        details.addWidget(self.card)
299        details.addWidget(self.files)
300        details.setStretchFactor(0, 3)
301        details.setStretchFactor(1, 2)
302        self.bottom = QSplitter(Qt.Horizontal)
303        self.bottom.addWidget(details)
304        self.bottom.addWidget(self.file_diff)
305        self.bottom.setStretchFactor(0, 2)
306        self.bottom.setStretchFactor(1, 3)
307        self.splitter = QSplitter(Qt.Vertical)
308        self.splitter.addWidget(self.table)
309        self.splitter.addWidget(self.bottom)
310        self.splitter.setStretchFactor(0, 3)
311        self.splitter.setStretchFactor(1, 2)
312
313        content = QWidget()
314        layout = QVBoxLayout(content)
315        layout.setContentsMargins(0, 0, 0, 0)
316        layout.setSpacing(4)
317        layout.addLayout(bar)
318        layout.addWidget(self.splitter, 1)
319        self.message = QLabel()
320        self.message.setAlignment(Qt.AlignCenter)
321        self.message.setWordWrap(True)
322        self.stack = QStackedLayout(self)
323        self.stack.addWidget(self.message)
324        self.stack.addWidget(content)
325        self.content = content
326        self.show_message("Reading the repository…")

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

def open_requested(unknown):

The type of the None singleton.

def reveal_requested(unknown):

The type of the None singleton.

def all_branches_changed(unknown):

The type of the None singleton.

def reload_requested(unknown):

The type of the None singleton.

pal: dict
dark
icon_for
everything: set[pathlib.Path]
all_branches
count
older_button
model
table
card
files
file_menu: PySide6.QtWidgets.QMenu | None
file_diff
bottom
splitter
message
stack
content
def refresh_palette(self, pal: dict, dark: bool):
329    def refresh_palette(self, pal: dict, dark: bool):
330        """New colors: drawn again from what's been read (git isn't asked again;
331        the commit and file chosen, and the diff's place, stay)."""
332        self.pal, self.dark = pal, dark
333        for label in (self.count, self.message):
334            label.setStyleSheet(f"color: {pal['muted']};")
335        self.file_diff.refresh_style(pal["muted"])
336        self.table.viewport().update()
337        commit = self.current_commit()
338        if commit is not None and self._details is not None and self._details[0] == commit.sha:
339            self._show_card(commit, self._details[1])
340        muted = QBrush(QColor(pal["muted"]))
341        for i in range(self.files.topLevelItemCount()):
342            self.files.topLevelItem(i).setForeground(1, muted)

New colors: drawn again from what's been read (git isn't asked again; the commit and file chosen, and the diff's place, stay).

def set_code_font(self, font: PySide6.QtGui.QFont):
344    def set_code_font(self, font: QFont):
345        self.file_diff.set_font(font)
def reload_key(self, root: pathlib.Path) -> tuple:
349    def reload_key(self, root: Path) -> tuple:
350        """What, besides the repository and HEAD, the history read depends on:
351        All branches, and whether every commit is listed (Load Older Commits)."""
352        return self.all_branches.isChecked(), root in self.everything

What, besides the repository and HEAD, the history read depends on: All branches, and whether every commit is listed (Load Older Commits).

def reader(self, root: pathlib.Path):
354    def reader(self, root: Path):
355        """What reads it (on a pool thread)."""
356        all_branches = self.all_branches.isChecked()
357        limit = None if root in self.everything else gl.MAX_COMMITS
358        return lambda: gl.read_log(root, all_branches=all_branches, limit=limit)

What reads it (on a pool thread).

def load_older(self):
360    def load_older(self):
361        """Load Older Commits: every commit listed, back to the first (this
362        repository's, from now on while the app runs)."""
363        if self.log is None:
364            return
365        self.everything.add(self.log.root)
366        self.older_button.setEnabled(False)
367        self.older_button.setText("Reading every commit…")
368        self.reload_requested.emit()

Load Older Commits: every commit listed, back to the first (this repository's, from now on while the app runs).

def show_result(self, log: vibefpmgui.git_log.Log):
370    def show_result(self, log: gl.Log):
371        self.show_log(log)
def show_message(self, text: str):
374    def show_message(self, text: str):
375        self.log = None
376        self.model.set_log(None)
377        self.older_button.hide()
378        self.message.setText(text)
379        self.stack.setCurrentWidget(self.message)
def show_log(self, log: vibefpmgui.git_log.Log):
381    def show_log(self, log: gl.Log):
382        """A repository's history; the commit chosen before stays chosen if it's still listed."""
383        before = self.current_commit()
384        self.log = log
385        self.model.set_log(log)
386        self.all_branches.setChecked(log.all_branches)
387        n = len(log.commits)
388        self.count.setText(f"{'The newest ' if log.limited else ''}{n:,} commit{'s' * (n != 1)}"
389                           + (f" of {log.total:,}" if log.limited else "")
390                           + (" of every branch" if log.all_branches else " of what's checked out"))
391        older = log.total - n
392        self.older_button.setVisible(log.limited)
393        self.older_button.setEnabled(True)
394        self.older_button.setText(f"Load the {older:,} Older Commit{'s' * (older != 1)}")
395        metrics = QFontMetrics(self.table.font())
396        self.table.setColumnWidth(2, 160)
397        self.table.setColumnWidth(3, metrics.horizontalAdvance("0000-00-00 00:00") + 16)
398        self.table.setColumnWidth(4, metrics.horizontalAdvance("00000000") + 16)
399        if self.model.lanes != self._graph_lanes:  # (more lanes, or fewer: room for each; dragged since: kept)
400            self._graph_lanes = self.model.lanes
401            width = graph_width(self.model.lanes, self.table.font())
402            room = self.table.viewport().width()
403            self.table.setColumnWidth(GRAPH, min(width, round(room * GRAPH_SHARE)) if room > 200 else width)
404        self.stack.setCurrentWidget(self.content)
405        row = next((i for i, c in enumerate(log.commits) if before and c.sha == before.sha), None)
406        if row is None:
407            row = next((i for i, c in enumerate(log.commits) if c.sha == log.head), 0)
408        self.table.setCurrentIndex(self.model.index(row, 0))
409        self.table.scrollTo(self.model.index(row, 0), QTreeView.PositionAtCenter)

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

def current_commit(self) -> vibefpmgui.git_log.LogCommit | None:
411    def current_commit(self) -> gl.LogCommit | None:
412        index = self.table.currentIndex()
413        return index.data(COMMIT_ROLE) if index.isValid() else None
staticMetaObject = PySide6.QtCore.QMetaObject("GitLogView" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(QString,int), parameters=QString, int #34 type=Signal, signature=reveal_requested(QString), parameters=QString #35 type=Signal, signature=all_branches_changed(bool), parameters=bool #36 type=Signal, signature=reload_requested() )