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)
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
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.
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
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
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
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
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.
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.
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).
__init__(self, /, parent: PySide6.QtCore.QObject | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
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
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
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
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.
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).
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).
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).
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).
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.