vibefpmgui.git_panel
The Git tab, with three views to switch between in its header:
- Diff: the project's repository's status (its branch, and the changed
files: staged, not staged, untracked, in conflict), and the diff of the
selected file (see
vibefpmgui.git_status,vibefpmgui.diff_view). Double-clicking a line of the diff opens the file there. - History: the commits, with their branches and tags and the graph of
how they join; a commit's details, files and diffs (see
vibefpmgui.git_log,vibefpmgui.git_log_view). - Stats: numbers about the repository and each person's commits over time
(see
vibefpmgui.git_stats,vibefpmgui.git_stats_view).
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)
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.
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.
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)
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).
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).
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.
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.