vibefpmgui.git_panel

The Git tab, with three views to switch between in its header:

The History and the Stats are read when they're on show and HEAD has moved since (or Refresh is clicked; or something of their own changed, such as the History's All branches: see reload_key). Each of those pages has the same few methods for this (reload_key, reader, show_result, show_message), so the panel needs nothing of either's own.

What git says is read on a pool thread.

  1"""The *Git* tab, with three views to switch between in its header:
  2
  3* *Diff*: the project's repository's status (its branch, and the changed
  4  files: staged, not staged, untracked, in conflict), and the diff of the
  5  selected file (see `vibefpmgui.git_status`, `vibefpmgui.diff_view`).
  6  Double-clicking a line of the diff opens the file there.
  7* *History*: the commits, with their branches and tags and the graph of
  8  how they join; a commit's details, files and diffs (see
  9  `vibefpmgui.git_log`, `vibefpmgui.git_log_view`).
 10* *Stats*: numbers about the repository and each person's commits over time
 11  (see `vibefpmgui.git_stats`, `vibefpmgui.git_stats_view`).
 12
 13The History and the Stats are read when they're on show and HEAD has moved
 14since (or Refresh is clicked; or something of their own changed, such as
 15the History's All branches: see `reload_key`). Each of those pages has the
 16same few methods for this (`reload_key`, `reader`, `show_result`,
 17`show_message`), so the panel needs nothing of either's own.
 18
 19What git says is read on a pool thread.
 20"""
 21
 22from __future__ import annotations
 23
 24from pathlib import Path
 25
 26from qtpy.QtCore import QSize, Qt, Signal
 27from qtpy.QtGui import QBrush, QColor, QFont
 28from qtpy.QtWidgets import (
 29    QApplication,
 30    QButtonGroup,
 31    QHBoxLayout,
 32    QLabel,
 33    QMenu,
 34    QSplitter,
 35    QStackedWidget,
 36    QToolButton,
 37    QTreeWidget,
 38    QTreeWidgetItem,
 39    QVBoxLayout,
 40    QWidget,
 41)
 42
 43from . import git_status as gs
 44from .background import call_in_background
 45from .build_monitor import PALETTES, TitleIcon, app_is_light, panel_stylesheet
 46from .file_diff import FileDiff
 47from .git_log_view import GitLogView
 48from .git_stats_view import GitStatsView
 49from .icons import make_icon
 50from .widgets import StatusLabel
 51
 52CHANGE_ROLE = Qt.UserRole + 1  # a file's row: its git_status.Change
 53DIFF, HISTORY, STATS = "diff", "history", "stats"  # the panel's views
 54VIEW_TITLES = {DIFF: "Diff", HISTORY: "History", STATS: "Stats"}
 55VIEW_TIPS = {DIFF: "The changed files, and the selected one's diff",
 56             HISTORY: "The commits, with their branches and tags and the graph of how they join",
 57             STATS: "Statistics about the repository, and everyone's commits over time"}
 58LETTER_COLORS = {"M": "#e8a317", "A": "#2f9e44", "D": "#e03131", "R": "#4c6ef5", "T": "#4c6ef5",
 59                 "U": "#2f9e44", "!": "#e03131"}
 60
 61
 62class GitPanel(QWidget):
 63    """Its header (the views' buttons, the branch, Unified and Refresh) over
 64    the view chosen: Diff (the changed files, left, and the selected one's
 65    diff, right), History or Stats."""
 66
 67    refresh_requested = Signal()
 68    open_requested = Signal(str, int)  # file, line
 69    reveal_requested = Signal(str)  # a changed file's "Show in Project Files": its path
 70    discard_requested = Signal(object)  # a changed file's "Discard Changes…": its gs.Change
 71    view_changed = Signal(str)  # DIFF, HISTORY or STATS chosen in the header
 72
 73    def __init__(self, parent=None):
 74        super().__init__(parent)
 75        self.status: gs.RepoStatus | None = None
 76        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 77        self.is_light = True
 78        self.palette_colors = PALETTES["light"]
 79        self._shown: dict[str, tuple] = {}    # HISTORY, STATS: what's on show ((root, HEAD…): see _key)
 80        self._reading: dict[str, tuple] = {}  # and what's being read
 81        self.setObjectName("GitPanel")
 82        self.setAttribute(Qt.WA_StyledBackground, True)
 83
 84        self.title_icon = TitleIcon("git")
 85        self.title = QLabel("Git")
 86        self.subtitle = StatusLabel()
 87        # Icon buttons (as the Debug panel's: see refresh_palette), their tooltips saying what they do.
 88        self.unified_button = QToolButton()
 89        self.unified_button.setText("Unified")
 90        self.unified_button.setCheckable(True)
 91        self.unified_button.setToolTip("Show the diff as one list (removed lines, then added ones) "
 92                                       "instead of side by side")
 93        self.unified_button.toggled.connect(self._set_unified)
 94        refresh = self.refresh_button = QToolButton()
 95        refresh.setText("Refresh")
 96        refresh.setToolTip("Read the repository's status again")
 97        refresh.clicked.connect(self._refresh_clicked)
 98        # The views: a pair of buttons, one of them on.
 99        self.view_buttons: dict[str, QToolButton] = {}
100        self.view_group = QButtonGroup(self)
101        for view in (DIFF, HISTORY, STATS):
102            button = self.view_buttons[view] = QToolButton()
103            button.setObjectName("GitView")
104            button.setText(VIEW_TITLES[view])
105            button.setToolTip(VIEW_TIPS[view])
106            button.setCheckable(True)
107            button.setAutoRaise(True)
108            button.clicked.connect(lambda _checked=False, v=view: self.set_view(v, user=True))
109            self.view_group.addButton(button)
110        self.view_buttons[DIFF].setChecked(True)
111        for button in (self.unified_button, refresh):
112            button.setToolButtonStyle(Qt.ToolButtonIconOnly)
113            button.setIconSize(QSize(18, 18))
114            button.setAutoRaise(True)
115        header = QHBoxLayout()
116        header.setSpacing(6)
117        header.addWidget(self.title_icon)
118        header.addWidget(self.title)
119        header.addSpacing(8)
120        for button in self.view_buttons.values():
121            header.addWidget(button)
122        header.addSpacing(8)
123        header.addWidget(self.subtitle, 1)
124        header.addWidget(self.unified_button)
125        header.addWidget(refresh)
126
127        self.files = QTreeWidget()
128        self.files.setHeaderHidden(True)
129        self.files.setRootIsDecorated(False)
130        self.files.setIndentation(10)
131        self.files.setColumnCount(2)
132        self.files.setUniformRowHeights(True)
133        self.files.currentItemChanged.connect(self._show_selected)
134        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
135        self.files.customContextMenuRequested.connect(self._file_menu)
136        self.file_menu: QMenu | None = None  # (kept while it's open)
137        self.file_diff = FileDiff()
138        self.file_diff.open_requested.connect(self._open_line)
139        self.splitter = QSplitter(Qt.Horizontal)
140        self.splitter.addWidget(self.files)
141        self.splitter.addWidget(self.file_diff)
142        self.splitter.setStretchFactor(0, 1)
143        self.splitter.setStretchFactor(1, 3)
144
145        self.empty = QLabel()
146        self.empty.setAlignment(Qt.AlignCenter)
147        self.empty.setWordWrap(True)
148
149        diff_page = QWidget()
150        diff_layout = QVBoxLayout(diff_page)
151        diff_layout.setContentsMargins(0, 0, 0, 0)
152        diff_layout.addWidget(self.splitter, 1)
153        diff_layout.addWidget(self.empty, 1)
154        self.stats_view = GitStatsView()
155        self.log_view = GitLogView()
156        self.log_view.open_requested.connect(self.open_requested)
157        self.log_view.reveal_requested.connect(self.reveal_requested)
158        # The pages read from the repository (see the module's description).
159        self.read_pages: dict[str, GitLogView | GitStatsView] = {HISTORY: self.log_view, STATS: self.stats_view}
160        for page in self.read_pages.values():
161            page.reload_requested.connect(self._read_view)
162        self.pages = QStackedWidget()
163        self.views: dict[str, QWidget] = {DIFF: diff_page, **self.read_pages}
164        for page in self.views.values():
165            self.pages.addWidget(page)
166
167        layout = QVBoxLayout(self)
168        layout.setContentsMargins(14, 10, 14, 6)
169        layout.setSpacing(8)
170        layout.addLayout(header)
171        layout.addWidget(self.pages, 1)
172        self.refresh_fonts()
173        self.show_message("Open a project in a git repository to see its changes here.")
174
175    # ------------------------------------------------------------ styling
176    def refresh_palette(self):
177        self.is_light = app_is_light()
178        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
179        # (Unified, while on: a background, so it shows.)
180        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
181                           + f"#{self.objectName()} QToolButton:checked {{ background: rgba(128,128,128,70);"
182                             " border: none; border-radius: 5px; }"
183                           + f"#{self.objectName()} QToolButton#GitView {{ padding: 2px 10px; color: {pal['muted']}; }}"
184                           + f"#{self.objectName()} QToolButton#GitView:checked {{ color: {pal['text']}; }}")
185        self.title_icon.draw(pal["text"], self.title)
186        self.unified_button.setIcon(make_icon("unified", pal["text"]))
187        self.refresh_button.setIcon(make_icon("refresh", pal["text"]))
188        for label in (self.subtitle, self.empty):
189            label.setStyleSheet(f"color: {pal['muted']};")
190        self.file_diff.refresh_style(pal["muted"])
191        for page in self.read_pages.values():
192            page.refresh_palette(pal, not self.is_light)
193
194    def refresh_fonts(self):
195        font = QFont(QApplication.font())
196        font.setPointSizeF(font.pointSizeF() + 2)
197        font.setWeight(QFont.Bold)
198        self.title.setFont(font)
199        self.refresh_palette()
200
201    def set_code_font(self, font: QFont):
202        """The diffs' font (the editor's)."""
203        self.file_diff.set_font(font)
204        self.log_view.set_code_font(font)
205
206    def _set_unified(self, on: bool):
207        """Unified: the diffs (the Diff page's, the History's) as one list."""
208        for diff in (self.file_diff, self.log_view.file_diff):
209            diff.set_unified(on)
210
211    # -------------------------------------------------------------- views
212    def view(self) -> str:
213        page = self.pages.currentWidget()
214        return next((view for view, shown in self.views.items() if shown is page), DIFF)
215
216    def set_view(self, view: str, user: bool = False):
217        """Show the Diff, the History or the Stats (``user``: chosen in the
218        header: said with view_changed, to be remembered)."""
219        view = view if view in self.views else DIFF
220        self.view_buttons[view].setChecked(True)
221        self.pages.setCurrentWidget(self.views[view])
222        self.unified_button.setVisible(view in (DIFF, HISTORY))  # (both have a diff)
223        self._read_view()
224        if user:
225            self.view_changed.emit(view)
226
227    def _refresh_clicked(self):
228        # (Read again even if HEAD hasn't moved: a new branch, a tag, a fetch… And a read under way
229        # since before: its answer may be from before, so it's dropped.)
230        self._shown.clear()
231        self._reading.clear()
232        self.refresh_requested.emit()
233
234    def _key(self, view: str) -> tuple:
235        """What the view's on show for: the repository, HEAD, and what the page adds (its reload_key)."""
236        root = self.status.root
237        return (root, self.status.head, *self.read_pages[view].reload_key(root))
238
239    def _read_view(self):
240        """The view on show (History or Stats), read again if what it's for
241        has changed (see _key)."""
242        view = self.view()
243        if view not in self.read_pages or self.status is None:
244            return
245        page, key = self.read_pages[view], self._key(view)
246        if key in (self._shown.get(view), self._reading.get(view)):
247            return
248        if self.status.head is None:
249            page.show_message(f"No commits in {self.status.root.name} yet.")
250            self._shown[view] = key
251            return
252        if self._shown.get(view) is None or self._shown[view][0] != key[0]:
253            page.show_message("Reading the repository…")
254        self._reading[view] = key
255        read = page.reader(self.status.root)
256        call_in_background(lambda: self._safely(read), lambda result: self._view_read(view, key, result), self)
257
258    @staticmethod
259    def _safely(read):
260        try:
261            return read()
262        except (gs.GitError, OSError) as error:
263            return str(error) or type(error).__name__
264
265    def _view_read(self, view: str, key: tuple, result):
266        if self._reading.get(view) != key:
267            return  # (another one asked for meanwhile, Refresh clicked, or the project closed)
268        self._reading.pop(view)
269        self._shown[view] = key
270        page = self.read_pages[view]
271        if isinstance(result, str):
272            page.show_message(f"Couldn't read the repository's history: {result}")
273        elif result is None:
274            page.show_message(f"No commits in {key[0].name} yet.")
275        else:
276            page.show_result(result)
277        if self.status is None or self._key(view) != key:
278            self._read_view()  # (it moved on meanwhile)
279
280    # -------------------------------------------------------------- state
281    def show_message(self, message: str):
282        """Nothing to list: why."""
283        self.status = None
284        self._shown.clear()
285        self._reading.clear()
286        for page in self.read_pages.values():
287            page.show_message(message)
288        self.subtitle.setText("")
289        self.empty.setText(message)
290        self.empty.show()
291        self.splitter.hide()
292        self.files.clear()
293        self.file_diff.clear()
294
295    def show_status(self, status: gs.RepoStatus):
296        """A repository's status; the file shown before stays selected if it's
297        still changed."""
298        current = self.selected()
299        self.status = status
300        self.subtitle.setText(status.summary())
301        self.subtitle.setToolTip(str(status.root))
302        self.files.blockSignals(True)
303        self.files.clear()
304        select = None
305        for area in gs.AREAS:
306            changes = status.in_area(area)
307            if not changes:
308                continue
309            group = QTreeWidgetItem([f"{gs.AREA_TITLES[area]} ({len(changes)})"])
310            group.setFlags(Qt.ItemIsEnabled)
311            bold = QFont(self.files.font())
312            bold.setWeight(QFont.DemiBold)
313            group.setFont(0, bold)
314            self.files.addTopLevelItem(group)
315            for change in changes:
316                item = QTreeWidgetItem([change.path, change.letter])
317                item.setData(0, CHANGE_ROLE, change)
318                item.setToolTip(0, f"{change.path}\n{gs.KIND_WORDS[change.kind]} · {gs.AREA_TITLES[area].lower()}")
319                item.setForeground(1, QBrush(QColor(LETTER_COLORS.get(change.letter, "#868e96"))))
320                item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
321                if self.icon_for is not None:
322                    item.setIcon(0, self.icon_for(status.root / change.path))
323                group.addChild(item)
324                if change == current:
325                    select = item
326            group.setExpanded(True)
327        self.files.header().setStretchLastSection(False)
328        self.files.header().setSectionResizeMode(0, self.files.header().ResizeMode.Stretch)
329        self.files.header().setSectionResizeMode(1, self.files.header().ResizeMode.ResizeToContents)
330        self.files.blockSignals(False)
331        self._read_view()
332        if not status.changes:
333            self.empty.setText(f"No changes in {status.root.name} (on {status.summary()}).")
334            self.empty.show()
335            self.splitter.hide()
336            self.file_diff.clear()
337            return
338        self.empty.hide()
339        self.splitter.show()
340        if select is None:
341            select = self._first_file()
342        self.files.setCurrentItem(select)
343        self._show_selected(select)
344
345    def selected(self) -> gs.Change | None:
346        item = self.files.currentItem()
347        return item.data(0, CHANGE_ROLE) if item is not None else None
348
349    def _first_file(self) -> QTreeWidgetItem | None:
350        for i in range(self.files.topLevelItemCount()):
351            group = self.files.topLevelItem(i)
352            if group.childCount():
353                return group.child(0)
354        return None
355
356    def _show_selected(self, item, _previous=None):
357        change = item.data(0, CHANGE_ROLE) if item is not None else None
358        if change is None or self.status is None:
359            return
360        root = self.status.root
361
362        def read():
363            old, new, old_label, new_label = gs.versions(root, change)
364            return old, new, f"{old_label} → {new_label}" if old_label else f"new file ({new_label})"
365
366        self.file_diff.load((root, change), change.path, read)
367
368    def _file_menu(self, pos):
369        """A changed file's menu (a right-click on it): Show in Project Files,
370        Discard Changes… (asked first: see window.git)."""
371        item = self.files.itemAt(pos)
372        change = item.data(0, CHANGE_ROLE) if item is not None else None
373        if change is None or self.status is None:
374            return  # (a group's title)
375        path = self.status.root / change.path
376        menu = self.file_menu = QMenu(self)
377        show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
378        if not path.exists():
379            show.setEnabled(False)
380            show.setText("Show in Project Files (deleted)")
381        menu.addSeparator()
382        discard = menu.addAction("Delete File…" if change.area == gs.UNTRACKED else "Discard Changes…",
383                                 lambda: self.discard_requested.emit(change))
384        if change.area == gs.CONFLICT:
385            discard.setEnabled(False)
386            discard.setText("Discard Changes… (resolve the conflict, or abort the merge, first)")
387        menu.aboutToHide.connect(menu.deleteLater)
388        menu.popup(self.files.viewport().mapToGlobal(pos))
389
390    def _open_line(self, line: int):
391        change = self.selected()
392        if change is not None and self.status is not None:
393            path = self.status.root / change.path
394            if Path(path).is_file():
395                self.open_requested.emit(str(path), line)
CHANGE_ROLE = 257
VIEW_TITLES = {'diff': 'Diff', 'history': 'History', 'stats': 'Stats'}
VIEW_TIPS = {'diff': "The changed files, and the selected one's diff", 'history': 'The commits, with their branches and tags and the graph of how they join', 'stats': "Statistics about the repository, and everyone's commits over time"}
LETTER_COLORS = {'M': '#e8a317', 'A': '#2f9e44', 'D': '#e03131', 'R': '#4c6ef5', 'T': '#4c6ef5', 'U': '#2f9e44', '!': '#e03131'}
class GitPanel(PySide6.QtWidgets.QWidget):
 63class GitPanel(QWidget):
 64    """Its header (the views' buttons, the branch, Unified and Refresh) over
 65    the view chosen: Diff (the changed files, left, and the selected one's
 66    diff, right), History or Stats."""
 67
 68    refresh_requested = Signal()
 69    open_requested = Signal(str, int)  # file, line
 70    reveal_requested = Signal(str)  # a changed file's "Show in Project Files": its path
 71    discard_requested = Signal(object)  # a changed file's "Discard Changes…": its gs.Change
 72    view_changed = Signal(str)  # DIFF, HISTORY or STATS chosen in the header
 73
 74    def __init__(self, parent=None):
 75        super().__init__(parent)
 76        self.status: gs.RepoStatus | None = None
 77        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 78        self.is_light = True
 79        self.palette_colors = PALETTES["light"]
 80        self._shown: dict[str, tuple] = {}    # HISTORY, STATS: what's on show ((root, HEAD…): see _key)
 81        self._reading: dict[str, tuple] = {}  # and what's being read
 82        self.setObjectName("GitPanel")
 83        self.setAttribute(Qt.WA_StyledBackground, True)
 84
 85        self.title_icon = TitleIcon("git")
 86        self.title = QLabel("Git")
 87        self.subtitle = StatusLabel()
 88        # Icon buttons (as the Debug panel's: see refresh_palette), their tooltips saying what they do.
 89        self.unified_button = QToolButton()
 90        self.unified_button.setText("Unified")
 91        self.unified_button.setCheckable(True)
 92        self.unified_button.setToolTip("Show the diff as one list (removed lines, then added ones) "
 93                                       "instead of side by side")
 94        self.unified_button.toggled.connect(self._set_unified)
 95        refresh = self.refresh_button = QToolButton()
 96        refresh.setText("Refresh")
 97        refresh.setToolTip("Read the repository's status again")
 98        refresh.clicked.connect(self._refresh_clicked)
 99        # The views: a pair of buttons, one of them on.
100        self.view_buttons: dict[str, QToolButton] = {}
101        self.view_group = QButtonGroup(self)
102        for view in (DIFF, HISTORY, STATS):
103            button = self.view_buttons[view] = QToolButton()
104            button.setObjectName("GitView")
105            button.setText(VIEW_TITLES[view])
106            button.setToolTip(VIEW_TIPS[view])
107            button.setCheckable(True)
108            button.setAutoRaise(True)
109            button.clicked.connect(lambda _checked=False, v=view: self.set_view(v, user=True))
110            self.view_group.addButton(button)
111        self.view_buttons[DIFF].setChecked(True)
112        for button in (self.unified_button, refresh):
113            button.setToolButtonStyle(Qt.ToolButtonIconOnly)
114            button.setIconSize(QSize(18, 18))
115            button.setAutoRaise(True)
116        header = QHBoxLayout()
117        header.setSpacing(6)
118        header.addWidget(self.title_icon)
119        header.addWidget(self.title)
120        header.addSpacing(8)
121        for button in self.view_buttons.values():
122            header.addWidget(button)
123        header.addSpacing(8)
124        header.addWidget(self.subtitle, 1)
125        header.addWidget(self.unified_button)
126        header.addWidget(refresh)
127
128        self.files = QTreeWidget()
129        self.files.setHeaderHidden(True)
130        self.files.setRootIsDecorated(False)
131        self.files.setIndentation(10)
132        self.files.setColumnCount(2)
133        self.files.setUniformRowHeights(True)
134        self.files.currentItemChanged.connect(self._show_selected)
135        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
136        self.files.customContextMenuRequested.connect(self._file_menu)
137        self.file_menu: QMenu | None = None  # (kept while it's open)
138        self.file_diff = FileDiff()
139        self.file_diff.open_requested.connect(self._open_line)
140        self.splitter = QSplitter(Qt.Horizontal)
141        self.splitter.addWidget(self.files)
142        self.splitter.addWidget(self.file_diff)
143        self.splitter.setStretchFactor(0, 1)
144        self.splitter.setStretchFactor(1, 3)
145
146        self.empty = QLabel()
147        self.empty.setAlignment(Qt.AlignCenter)
148        self.empty.setWordWrap(True)
149
150        diff_page = QWidget()
151        diff_layout = QVBoxLayout(diff_page)
152        diff_layout.setContentsMargins(0, 0, 0, 0)
153        diff_layout.addWidget(self.splitter, 1)
154        diff_layout.addWidget(self.empty, 1)
155        self.stats_view = GitStatsView()
156        self.log_view = GitLogView()
157        self.log_view.open_requested.connect(self.open_requested)
158        self.log_view.reveal_requested.connect(self.reveal_requested)
159        # The pages read from the repository (see the module's description).
160        self.read_pages: dict[str, GitLogView | GitStatsView] = {HISTORY: self.log_view, STATS: self.stats_view}
161        for page in self.read_pages.values():
162            page.reload_requested.connect(self._read_view)
163        self.pages = QStackedWidget()
164        self.views: dict[str, QWidget] = {DIFF: diff_page, **self.read_pages}
165        for page in self.views.values():
166            self.pages.addWidget(page)
167
168        layout = QVBoxLayout(self)
169        layout.setContentsMargins(14, 10, 14, 6)
170        layout.setSpacing(8)
171        layout.addLayout(header)
172        layout.addWidget(self.pages, 1)
173        self.refresh_fonts()
174        self.show_message("Open a project in a git repository to see its changes here.")
175
176    # ------------------------------------------------------------ styling
177    def refresh_palette(self):
178        self.is_light = app_is_light()
179        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
180        # (Unified, while on: a background, so it shows.)
181        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
182                           + f"#{self.objectName()} QToolButton:checked {{ background: rgba(128,128,128,70);"
183                             " border: none; border-radius: 5px; }"
184                           + f"#{self.objectName()} QToolButton#GitView {{ padding: 2px 10px; color: {pal['muted']}; }}"
185                           + f"#{self.objectName()} QToolButton#GitView:checked {{ color: {pal['text']}; }}")
186        self.title_icon.draw(pal["text"], self.title)
187        self.unified_button.setIcon(make_icon("unified", pal["text"]))
188        self.refresh_button.setIcon(make_icon("refresh", pal["text"]))
189        for label in (self.subtitle, self.empty):
190            label.setStyleSheet(f"color: {pal['muted']};")
191        self.file_diff.refresh_style(pal["muted"])
192        for page in self.read_pages.values():
193            page.refresh_palette(pal, not self.is_light)
194
195    def refresh_fonts(self):
196        font = QFont(QApplication.font())
197        font.setPointSizeF(font.pointSizeF() + 2)
198        font.setWeight(QFont.Bold)
199        self.title.setFont(font)
200        self.refresh_palette()
201
202    def set_code_font(self, font: QFont):
203        """The diffs' font (the editor's)."""
204        self.file_diff.set_font(font)
205        self.log_view.set_code_font(font)
206
207    def _set_unified(self, on: bool):
208        """Unified: the diffs (the Diff page's, the History's) as one list."""
209        for diff in (self.file_diff, self.log_view.file_diff):
210            diff.set_unified(on)
211
212    # -------------------------------------------------------------- views
213    def view(self) -> str:
214        page = self.pages.currentWidget()
215        return next((view for view, shown in self.views.items() if shown is page), DIFF)
216
217    def set_view(self, view: str, user: bool = False):
218        """Show the Diff, the History or the Stats (``user``: chosen in the
219        header: said with view_changed, to be remembered)."""
220        view = view if view in self.views else DIFF
221        self.view_buttons[view].setChecked(True)
222        self.pages.setCurrentWidget(self.views[view])
223        self.unified_button.setVisible(view in (DIFF, HISTORY))  # (both have a diff)
224        self._read_view()
225        if user:
226            self.view_changed.emit(view)
227
228    def _refresh_clicked(self):
229        # (Read again even if HEAD hasn't moved: a new branch, a tag, a fetch… And a read under way
230        # since before: its answer may be from before, so it's dropped.)
231        self._shown.clear()
232        self._reading.clear()
233        self.refresh_requested.emit()
234
235    def _key(self, view: str) -> tuple:
236        """What the view's on show for: the repository, HEAD, and what the page adds (its reload_key)."""
237        root = self.status.root
238        return (root, self.status.head, *self.read_pages[view].reload_key(root))
239
240    def _read_view(self):
241        """The view on show (History or Stats), read again if what it's for
242        has changed (see _key)."""
243        view = self.view()
244        if view not in self.read_pages or self.status is None:
245            return
246        page, key = self.read_pages[view], self._key(view)
247        if key in (self._shown.get(view), self._reading.get(view)):
248            return
249        if self.status.head is None:
250            page.show_message(f"No commits in {self.status.root.name} yet.")
251            self._shown[view] = key
252            return
253        if self._shown.get(view) is None or self._shown[view][0] != key[0]:
254            page.show_message("Reading the repository…")
255        self._reading[view] = key
256        read = page.reader(self.status.root)
257        call_in_background(lambda: self._safely(read), lambda result: self._view_read(view, key, result), self)
258
259    @staticmethod
260    def _safely(read):
261        try:
262            return read()
263        except (gs.GitError, OSError) as error:
264            return str(error) or type(error).__name__
265
266    def _view_read(self, view: str, key: tuple, result):
267        if self._reading.get(view) != key:
268            return  # (another one asked for meanwhile, Refresh clicked, or the project closed)
269        self._reading.pop(view)
270        self._shown[view] = key
271        page = self.read_pages[view]
272        if isinstance(result, str):
273            page.show_message(f"Couldn't read the repository's history: {result}")
274        elif result is None:
275            page.show_message(f"No commits in {key[0].name} yet.")
276        else:
277            page.show_result(result)
278        if self.status is None or self._key(view) != key:
279            self._read_view()  # (it moved on meanwhile)
280
281    # -------------------------------------------------------------- state
282    def show_message(self, message: str):
283        """Nothing to list: why."""
284        self.status = None
285        self._shown.clear()
286        self._reading.clear()
287        for page in self.read_pages.values():
288            page.show_message(message)
289        self.subtitle.setText("")
290        self.empty.setText(message)
291        self.empty.show()
292        self.splitter.hide()
293        self.files.clear()
294        self.file_diff.clear()
295
296    def show_status(self, status: gs.RepoStatus):
297        """A repository's status; the file shown before stays selected if it's
298        still changed."""
299        current = self.selected()
300        self.status = status
301        self.subtitle.setText(status.summary())
302        self.subtitle.setToolTip(str(status.root))
303        self.files.blockSignals(True)
304        self.files.clear()
305        select = None
306        for area in gs.AREAS:
307            changes = status.in_area(area)
308            if not changes:
309                continue
310            group = QTreeWidgetItem([f"{gs.AREA_TITLES[area]} ({len(changes)})"])
311            group.setFlags(Qt.ItemIsEnabled)
312            bold = QFont(self.files.font())
313            bold.setWeight(QFont.DemiBold)
314            group.setFont(0, bold)
315            self.files.addTopLevelItem(group)
316            for change in changes:
317                item = QTreeWidgetItem([change.path, change.letter])
318                item.setData(0, CHANGE_ROLE, change)
319                item.setToolTip(0, f"{change.path}\n{gs.KIND_WORDS[change.kind]} · {gs.AREA_TITLES[area].lower()}")
320                item.setForeground(1, QBrush(QColor(LETTER_COLORS.get(change.letter, "#868e96"))))
321                item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
322                if self.icon_for is not None:
323                    item.setIcon(0, self.icon_for(status.root / change.path))
324                group.addChild(item)
325                if change == current:
326                    select = item
327            group.setExpanded(True)
328        self.files.header().setStretchLastSection(False)
329        self.files.header().setSectionResizeMode(0, self.files.header().ResizeMode.Stretch)
330        self.files.header().setSectionResizeMode(1, self.files.header().ResizeMode.ResizeToContents)
331        self.files.blockSignals(False)
332        self._read_view()
333        if not status.changes:
334            self.empty.setText(f"No changes in {status.root.name} (on {status.summary()}).")
335            self.empty.show()
336            self.splitter.hide()
337            self.file_diff.clear()
338            return
339        self.empty.hide()
340        self.splitter.show()
341        if select is None:
342            select = self._first_file()
343        self.files.setCurrentItem(select)
344        self._show_selected(select)
345
346    def selected(self) -> gs.Change | None:
347        item = self.files.currentItem()
348        return item.data(0, CHANGE_ROLE) if item is not None else None
349
350    def _first_file(self) -> QTreeWidgetItem | None:
351        for i in range(self.files.topLevelItemCount()):
352            group = self.files.topLevelItem(i)
353            if group.childCount():
354                return group.child(0)
355        return None
356
357    def _show_selected(self, item, _previous=None):
358        change = item.data(0, CHANGE_ROLE) if item is not None else None
359        if change is None or self.status is None:
360            return
361        root = self.status.root
362
363        def read():
364            old, new, old_label, new_label = gs.versions(root, change)
365            return old, new, f"{old_label} → {new_label}" if old_label else f"new file ({new_label})"
366
367        self.file_diff.load((root, change), change.path, read)
368
369    def _file_menu(self, pos):
370        """A changed file's menu (a right-click on it): Show in Project Files,
371        Discard Changes… (asked first: see window.git)."""
372        item = self.files.itemAt(pos)
373        change = item.data(0, CHANGE_ROLE) if item is not None else None
374        if change is None or self.status is None:
375            return  # (a group's title)
376        path = self.status.root / change.path
377        menu = self.file_menu = QMenu(self)
378        show = menu.addAction("Show in Project Files", lambda: self.reveal_requested.emit(str(path)))
379        if not path.exists():
380            show.setEnabled(False)
381            show.setText("Show in Project Files (deleted)")
382        menu.addSeparator()
383        discard = menu.addAction("Delete File…" if change.area == gs.UNTRACKED else "Discard Changes…",
384                                 lambda: self.discard_requested.emit(change))
385        if change.area == gs.CONFLICT:
386            discard.setEnabled(False)
387            discard.setText("Discard Changes… (resolve the conflict, or abort the merge, first)")
388        menu.aboutToHide.connect(menu.deleteLater)
389        menu.popup(self.files.viewport().mapToGlobal(pos))
390
391    def _open_line(self, line: int):
392        change = self.selected()
393        if change is not None and self.status is not None:
394            path = self.status.root / change.path
395            if Path(path).is_file():
396                self.open_requested.emit(str(path), line)

Its header (the views' buttons, the branch, Unified and Refresh) over the view chosen: Diff (the changed files, left, and the selected one's diff, right), History or Stats.

GitPanel(parent=None)
 74    def __init__(self, parent=None):
 75        super().__init__(parent)
 76        self.status: gs.RepoStatus | None = None
 77        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 78        self.is_light = True
 79        self.palette_colors = PALETTES["light"]
 80        self._shown: dict[str, tuple] = {}    # HISTORY, STATS: what's on show ((root, HEAD…): see _key)
 81        self._reading: dict[str, tuple] = {}  # and what's being read
 82        self.setObjectName("GitPanel")
 83        self.setAttribute(Qt.WA_StyledBackground, True)
 84
 85        self.title_icon = TitleIcon("git")
 86        self.title = QLabel("Git")
 87        self.subtitle = StatusLabel()
 88        # Icon buttons (as the Debug panel's: see refresh_palette), their tooltips saying what they do.
 89        self.unified_button = QToolButton()
 90        self.unified_button.setText("Unified")
 91        self.unified_button.setCheckable(True)
 92        self.unified_button.setToolTip("Show the diff as one list (removed lines, then added ones) "
 93                                       "instead of side by side")
 94        self.unified_button.toggled.connect(self._set_unified)
 95        refresh = self.refresh_button = QToolButton()
 96        refresh.setText("Refresh")
 97        refresh.setToolTip("Read the repository's status again")
 98        refresh.clicked.connect(self._refresh_clicked)
 99        # The views: a pair of buttons, one of them on.
100        self.view_buttons: dict[str, QToolButton] = {}
101        self.view_group = QButtonGroup(self)
102        for view in (DIFF, HISTORY, STATS):
103            button = self.view_buttons[view] = QToolButton()
104            button.setObjectName("GitView")
105            button.setText(VIEW_TITLES[view])
106            button.setToolTip(VIEW_TIPS[view])
107            button.setCheckable(True)
108            button.setAutoRaise(True)
109            button.clicked.connect(lambda _checked=False, v=view: self.set_view(v, user=True))
110            self.view_group.addButton(button)
111        self.view_buttons[DIFF].setChecked(True)
112        for button in (self.unified_button, refresh):
113            button.setToolButtonStyle(Qt.ToolButtonIconOnly)
114            button.setIconSize(QSize(18, 18))
115            button.setAutoRaise(True)
116        header = QHBoxLayout()
117        header.setSpacing(6)
118        header.addWidget(self.title_icon)
119        header.addWidget(self.title)
120        header.addSpacing(8)
121        for button in self.view_buttons.values():
122            header.addWidget(button)
123        header.addSpacing(8)
124        header.addWidget(self.subtitle, 1)
125        header.addWidget(self.unified_button)
126        header.addWidget(refresh)
127
128        self.files = QTreeWidget()
129        self.files.setHeaderHidden(True)
130        self.files.setRootIsDecorated(False)
131        self.files.setIndentation(10)
132        self.files.setColumnCount(2)
133        self.files.setUniformRowHeights(True)
134        self.files.currentItemChanged.connect(self._show_selected)
135        self.files.setContextMenuPolicy(Qt.CustomContextMenu)
136        self.files.customContextMenuRequested.connect(self._file_menu)
137        self.file_menu: QMenu | None = None  # (kept while it's open)
138        self.file_diff = FileDiff()
139        self.file_diff.open_requested.connect(self._open_line)
140        self.splitter = QSplitter(Qt.Horizontal)
141        self.splitter.addWidget(self.files)
142        self.splitter.addWidget(self.file_diff)
143        self.splitter.setStretchFactor(0, 1)
144        self.splitter.setStretchFactor(1, 3)
145
146        self.empty = QLabel()
147        self.empty.setAlignment(Qt.AlignCenter)
148        self.empty.setWordWrap(True)
149
150        diff_page = QWidget()
151        diff_layout = QVBoxLayout(diff_page)
152        diff_layout.setContentsMargins(0, 0, 0, 0)
153        diff_layout.addWidget(self.splitter, 1)
154        diff_layout.addWidget(self.empty, 1)
155        self.stats_view = GitStatsView()
156        self.log_view = GitLogView()
157        self.log_view.open_requested.connect(self.open_requested)
158        self.log_view.reveal_requested.connect(self.reveal_requested)
159        # The pages read from the repository (see the module's description).
160        self.read_pages: dict[str, GitLogView | GitStatsView] = {HISTORY: self.log_view, STATS: self.stats_view}
161        for page in self.read_pages.values():
162            page.reload_requested.connect(self._read_view)
163        self.pages = QStackedWidget()
164        self.views: dict[str, QWidget] = {DIFF: diff_page, **self.read_pages}
165        for page in self.views.values():
166            self.pages.addWidget(page)
167
168        layout = QVBoxLayout(self)
169        layout.setContentsMargins(14, 10, 14, 6)
170        layout.setSpacing(8)
171        layout.addLayout(header)
172        layout.addWidget(self.pages, 1)
173        self.refresh_fonts()
174        self.show_message("Open a project in a git repository to see its changes here.")

__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 refresh_requested(unknown):

The type of the None singleton.

def open_requested(unknown):

The type of the None singleton.

def reveal_requested(unknown):

The type of the None singleton.

def discard_requested(unknown):

The type of the None singleton.

def view_changed(unknown):

The type of the None singleton.

icon_for
is_light
palette_colors
title_icon
title
subtitle
unified_button
view_buttons: dict[str, PySide6.QtWidgets.QToolButton]
view_group
files
file_menu: PySide6.QtWidgets.QMenu | None
file_diff
splitter
empty
stats_view
log_view
pages
views: dict[str, PySide6.QtWidgets.QWidget]
def refresh_palette(self):
177    def refresh_palette(self):
178        self.is_light = app_is_light()
179        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
180        # (Unified, while on: a background, so it shows.)
181        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
182                           + f"#{self.objectName()} QToolButton:checked {{ background: rgba(128,128,128,70);"
183                             " border: none; border-radius: 5px; }"
184                           + f"#{self.objectName()} QToolButton#GitView {{ padding: 2px 10px; color: {pal['muted']}; }}"
185                           + f"#{self.objectName()} QToolButton#GitView:checked {{ color: {pal['text']}; }}")
186        self.title_icon.draw(pal["text"], self.title)
187        self.unified_button.setIcon(make_icon("unified", pal["text"]))
188        self.refresh_button.setIcon(make_icon("refresh", pal["text"]))
189        for label in (self.subtitle, self.empty):
190            label.setStyleSheet(f"color: {pal['muted']};")
191        self.file_diff.refresh_style(pal["muted"])
192        for page in self.read_pages.values():
193            page.refresh_palette(pal, not self.is_light)
def refresh_fonts(self):
195    def refresh_fonts(self):
196        font = QFont(QApplication.font())
197        font.setPointSizeF(font.pointSizeF() + 2)
198        font.setWeight(QFont.Bold)
199        self.title.setFont(font)
200        self.refresh_palette()
def set_code_font(self, font: PySide6.QtGui.QFont):
202    def set_code_font(self, font: QFont):
203        """The diffs' font (the editor's)."""
204        self.file_diff.set_font(font)
205        self.log_view.set_code_font(font)

The diffs' font (the editor's).

def view(self) -> str:
213    def view(self) -> str:
214        page = self.pages.currentWidget()
215        return next((view for view, shown in self.views.items() if shown is page), DIFF)
def set_view(self, view: str, user: bool = False):
217    def set_view(self, view: str, user: bool = False):
218        """Show the Diff, the History or the Stats (``user``: chosen in the
219        header: said with view_changed, to be remembered)."""
220        view = view if view in self.views else DIFF
221        self.view_buttons[view].setChecked(True)
222        self.pages.setCurrentWidget(self.views[view])
223        self.unified_button.setVisible(view in (DIFF, HISTORY))  # (both have a diff)
224        self._read_view()
225        if user:
226            self.view_changed.emit(view)

Show the Diff, the History or the Stats (user: chosen in the header: said with view_changed, to be remembered).

def show_message(self, message: str):
282    def show_message(self, message: str):
283        """Nothing to list: why."""
284        self.status = None
285        self._shown.clear()
286        self._reading.clear()
287        for page in self.read_pages.values():
288            page.show_message(message)
289        self.subtitle.setText("")
290        self.empty.setText(message)
291        self.empty.show()
292        self.splitter.hide()
293        self.files.clear()
294        self.file_diff.clear()

Nothing to list: why.

def show_status(self, status: vibefpmgui.git_status.RepoStatus):
296    def show_status(self, status: gs.RepoStatus):
297        """A repository's status; the file shown before stays selected if it's
298        still changed."""
299        current = self.selected()
300        self.status = status
301        self.subtitle.setText(status.summary())
302        self.subtitle.setToolTip(str(status.root))
303        self.files.blockSignals(True)
304        self.files.clear()
305        select = None
306        for area in gs.AREAS:
307            changes = status.in_area(area)
308            if not changes:
309                continue
310            group = QTreeWidgetItem([f"{gs.AREA_TITLES[area]} ({len(changes)})"])
311            group.setFlags(Qt.ItemIsEnabled)
312            bold = QFont(self.files.font())
313            bold.setWeight(QFont.DemiBold)
314            group.setFont(0, bold)
315            self.files.addTopLevelItem(group)
316            for change in changes:
317                item = QTreeWidgetItem([change.path, change.letter])
318                item.setData(0, CHANGE_ROLE, change)
319                item.setToolTip(0, f"{change.path}\n{gs.KIND_WORDS[change.kind]} · {gs.AREA_TITLES[area].lower()}")
320                item.setForeground(1, QBrush(QColor(LETTER_COLORS.get(change.letter, "#868e96"))))
321                item.setTextAlignment(1, Qt.AlignRight | Qt.AlignVCenter)
322                if self.icon_for is not None:
323                    item.setIcon(0, self.icon_for(status.root / change.path))
324                group.addChild(item)
325                if change == current:
326                    select = item
327            group.setExpanded(True)
328        self.files.header().setStretchLastSection(False)
329        self.files.header().setSectionResizeMode(0, self.files.header().ResizeMode.Stretch)
330        self.files.header().setSectionResizeMode(1, self.files.header().ResizeMode.ResizeToContents)
331        self.files.blockSignals(False)
332        self._read_view()
333        if not status.changes:
334            self.empty.setText(f"No changes in {status.root.name} (on {status.summary()}).")
335            self.empty.show()
336            self.splitter.hide()
337            self.file_diff.clear()
338            return
339        self.empty.hide()
340        self.splitter.show()
341        if select is None:
342            select = self._first_file()
343        self.files.setCurrentItem(select)
344        self._show_selected(select)

A repository's status; the file shown before stays selected if it's still changed.

def selected(self) -> vibefpmgui.git_status.Change | None:
346    def selected(self) -> gs.Change | None:
347        item = self.files.currentItem()
348        return item.data(0, CHANGE_ROLE) if item is not None else None
staticMetaObject = PySide6.QtCore.QMetaObject("GitPanel" inherits "QWidget": Methods: #33 type=Signal, signature=refresh_requested() #34 type=Signal, signature=open_requested(QString,int), parameters=QString, int #35 type=Signal, signature=reveal_requested(QString), parameters=QString #36 type=Signal, signature=discard_requested(PyObject), parameters=PyObject #37 type=Signal, signature=view_changed(QString), parameters=QString )