vibefpmgui.window.git
The Git panel: reading the project's repository's status (git, on a pool thread) when it may have changed, and only while the panel is on view.
It may have changed when the project is opened or reloaded, a file is saved, the app becomes active again after a while away (git used in a terminal, another app), or Refresh is clicked. One read at a time: asked again meanwhile, it's read once more when that one is done.
1"""The Git panel: reading the project's repository's status (git, on a 2pool thread) when it may have changed, and only while the panel is on view. 3 4It may have changed when the project is opened or reloaded, a file is 5saved, the app becomes active again after a while away (git used in a 6terminal, another app), or Refresh is clicked. One read at a time: asked 7again meanwhile, it's read once more when that one is done. 8""" 9 10from __future__ import annotations 11 12import time 13from pathlib import Path 14 15from qtpy.QtCore import QFile, Qt 16from qtpy.QtWidgets import QApplication, QMessageBox 17 18from .. import git_status as gs 19from ..background import call_in_background 20from ..git_panel import GitPanel 21from ..widgets import run_dialog 22 23 24def _read(path: Path) -> tuple[gs.RepoStatus | None, str]: 25 """(status, error message).""" 26 try: 27 return gs.read_status(path), "" 28 except (gs.GitError, OSError) as error: 29 return None, str(error) or type(error).__name__ 30 31 32def move_to_trash(path: Path) -> bool: 33 """Move a file to the Trash (the Recycle Bin on Windows); False: it couldn't be.""" 34 return QFile.moveToTrash(str(path)) 35 36 37class GitMixin: 38 """The Git panel (a part of `vibefpmgui.main_window.MainWindow`).""" 39 40 def _build_git_panel(self): 41 """The Git panel.""" 42 self.git_panel = GitPanel() 43 self.git_panel.icon_for = self.git_panel.log_view.icon_for = self.project_tree.icon_for 44 self.git_panel.set_code_font(self._mono_font(editor=True)) 45 self.git_panel.refresh_requested.connect(self.refresh_git) 46 self.git_panel.open_requested.connect(lambda path, line: self.navigate_to(path, line, 0, preview=True)) 47 self.git_panel.reveal_requested.connect(self._show_in_project_files) 48 self.git_panel.discard_requested.connect(self.discard_git_change) 49 self.git_panel.set_view(str(self.settings.value("git_view", "diff") or "diff")) # (Diff or Stats, as last) 50 self.git_panel.view_changed.connect(lambda view: self.settings.setValue("git_view", view)) 51 stats = self.git_panel.stats_view 52 stats.set_mode(str(self.settings.value("git_stats_plot", "total") or "total")) # (Total or Per month) 53 stats.mode_changed.connect(lambda mode: self.settings.setValue("git_stats_plot", mode)) 54 history = self.git_panel.log_view 55 history.all_branches.setChecked(self.settings.value("git_all_branches", True, type=bool)) 56 history.all_branches_changed.connect(lambda on: self.settings.setValue("git_all_branches", on)) 57 self._git_request = 0 # (an answer to an older request is dropped) 58 self._git_stale = True # (read again once the panel is on view) 59 self._git_reading = False # (a read is under way) 60 self._git_again = False # (asked again meanwhile: read again once it's done) 61 self._inactive_since: float | None = None 62 QApplication.instance().applicationStateChanged.connect(self._app_state_changed) 63 64 def _show_in_project_files(self, path: str): 65 """A changed file's Show in Project Files: Project files in front, the file selected in it.""" 66 self.show_panel(self._content(self.docks["files"])) 67 if not self.project_tree.reveal(path): 68 name = Path(path).name 69 self.statusBar().showMessage(f"{name} isn't in Project files (it's outside the project, or filtered " 70 "out)", 5000) 71 72 def discard_git_change(self, change: gs.Change): 73 """The Git panel's Discard Changes… (Delete File… for an untracked 74 one): asked first, saying what's lost; files git has no copy of go to 75 the Trash (not deleted: they can be got back).""" 76 status = self.git_panel.status 77 if status is None: 78 return 79 root, name = status.root, change.path 80 new = change.area == gs.UNTRACKED or (change.area == gs.STAGED and change.kind in ("added", "renamed")) 81 both = change.area == gs.STAGED and any(c.path == name and c.area == gs.CHANGED for c in status.changes) 82 if change.area == gs.UNTRACKED: 83 question = (f"Move {name} to the Trash?\n\nIt isn't in git: this is its only copy (until the " 84 "Trash is emptied).") 85 elif change.area == gs.CHANGED: 86 staged = any(c.path == name and c.area == gs.STAGED for c in status.changes) 87 question = (f"Discard the changes to {name}?\n\nIt goes back to its " 88 + ("staged version (what's staged is kept)" if staged else "version in the last commit") 89 + ": the changes are lost.") 90 elif new: 91 question = (f"Discard {name}, new in the staged changes?\n\nIt's unstaged and moved to the Trash" 92 + (f", and {change.old_path} gets its name back" if change.kind == "renamed" else "") + ".") 93 else: 94 question = (f"Discard the changes to {name}, staged" + (" and not" if both else "") 95 + "?\n\nIt goes back to its version in the last commit: the changes are lost.") 96 editor = self._open_editor_for(root / name) 97 if editor is not None and editor.editor.document().isModified(): 98 question += "\n\nIt's open in the editor with unsaved edits: they're kept there, to save or not." 99 box = QMessageBox(QMessageBox.Warning, "Discard Changes", question, QMessageBox.Cancel, self) 100 discard = box.addButton("Move to Trash" if new else "Discard", QMessageBox.DestructiveRole) 101 box.setDefaultButton(QMessageBox.Cancel) 102 run_dialog(box) 103 if box.clickedButton() is not discard: 104 return 105 106 def work(): 107 try: 108 return gs.discard(root, change), "" 109 except gs.GitError as error: 110 return [], str(error) 111 112 def done(result): 113 paths, error = result 114 if error: 115 QMessageBox.warning(self, "Discard Changes", f"git couldn't discard the changes to {name}:\n\n{error}") 116 kept = [p for p in paths if p.exists() and not move_to_trash(p)] 117 if kept: 118 QMessageBox.warning(self, "Discard Changes", "Couldn't move to the Trash (left where it is):\n" 119 + "\n".join(str(p) for p in kept)) 120 elif not error: 121 self.statusBar().showMessage(f"Discarded the changes to {name}" 122 + (" (moved to the Trash)" if paths else ""), 5000) 123 self.refresh_git() 124 125 call_in_background(work, done, self) 126 127 def refresh_git(self): 128 """Read the repository's status again (or once the panel is on view).""" 129 panel = self.git_panel 130 if self.manifest_path is None: 131 panel.show_message("Open a project in a git repository to see its changes here.") 132 return 133 if not gs.available(): 134 panel.show_message("The Git panel needs git: install it (https://git-scm.com) and make sure " 135 "it's on the PATH, then click Refresh.") 136 return 137 if not self.panel_in_front(panel): 138 self._git_stale = True 139 return 140 self._git_stale = False 141 if self._git_reading: 142 self._git_again = True 143 return 144 self._git_reading = True 145 self._git_request += 1 146 request, folder = self._git_request, self.manifest_path.parent 147 call_in_background(lambda: _read(folder), lambda result: self._git_read(request, *result), self) 148 149 def _git_read(self, request: int, status: gs.RepoStatus | None, error: str): 150 self._git_reading = False 151 if self._git_again: 152 self._git_again = False 153 self.refresh_git() 154 return # (that one's answer is the one shown) 155 if request != self._git_request or self.manifest_path is None: 156 return 157 if error: 158 self.git_panel.show_message(f"Couldn't read the repository: {error}") 159 elif status is None: 160 self.git_panel.show_message(f"{self.manifest_path.parent.name} isn't in a git repository.") 161 else: 162 self.git_panel.show_status(status) 163 164 def _git_panel_shown(self, shown: bool): 165 if shown and self._git_stale: 166 self.refresh_git() 167 168 AWAY_SECONDS = 1.0 # (shorter: a dialog, a popup, a focus change; not time to use git elsewhere) 169 170 def _app_state_changed(self, state): 171 if state != Qt.ApplicationActive: 172 if self._inactive_since is None: 173 self._inactive_since = time.monotonic() 174 return 175 since, self._inactive_since = self._inactive_since, None 176 if since is not None and time.monotonic() - since >= self.AWAY_SECONDS: 177 self.refresh_git() # (back from a terminal, or another app, that may have used git)
33def move_to_trash(path: Path) -> bool: 34 """Move a file to the Trash (the Recycle Bin on Windows); False: it couldn't be.""" 35 return QFile.moveToTrash(str(path))
Move a file to the Trash (the Recycle Bin on Windows); False: it couldn't be.
38class GitMixin: 39 """The Git panel (a part of `vibefpmgui.main_window.MainWindow`).""" 40 41 def _build_git_panel(self): 42 """The Git panel.""" 43 self.git_panel = GitPanel() 44 self.git_panel.icon_for = self.git_panel.log_view.icon_for = self.project_tree.icon_for 45 self.git_panel.set_code_font(self._mono_font(editor=True)) 46 self.git_panel.refresh_requested.connect(self.refresh_git) 47 self.git_panel.open_requested.connect(lambda path, line: self.navigate_to(path, line, 0, preview=True)) 48 self.git_panel.reveal_requested.connect(self._show_in_project_files) 49 self.git_panel.discard_requested.connect(self.discard_git_change) 50 self.git_panel.set_view(str(self.settings.value("git_view", "diff") or "diff")) # (Diff or Stats, as last) 51 self.git_panel.view_changed.connect(lambda view: self.settings.setValue("git_view", view)) 52 stats = self.git_panel.stats_view 53 stats.set_mode(str(self.settings.value("git_stats_plot", "total") or "total")) # (Total or Per month) 54 stats.mode_changed.connect(lambda mode: self.settings.setValue("git_stats_plot", mode)) 55 history = self.git_panel.log_view 56 history.all_branches.setChecked(self.settings.value("git_all_branches", True, type=bool)) 57 history.all_branches_changed.connect(lambda on: self.settings.setValue("git_all_branches", on)) 58 self._git_request = 0 # (an answer to an older request is dropped) 59 self._git_stale = True # (read again once the panel is on view) 60 self._git_reading = False # (a read is under way) 61 self._git_again = False # (asked again meanwhile: read again once it's done) 62 self._inactive_since: float | None = None 63 QApplication.instance().applicationStateChanged.connect(self._app_state_changed) 64 65 def _show_in_project_files(self, path: str): 66 """A changed file's Show in Project Files: Project files in front, the file selected in it.""" 67 self.show_panel(self._content(self.docks["files"])) 68 if not self.project_tree.reveal(path): 69 name = Path(path).name 70 self.statusBar().showMessage(f"{name} isn't in Project files (it's outside the project, or filtered " 71 "out)", 5000) 72 73 def discard_git_change(self, change: gs.Change): 74 """The Git panel's Discard Changes… (Delete File… for an untracked 75 one): asked first, saying what's lost; files git has no copy of go to 76 the Trash (not deleted: they can be got back).""" 77 status = self.git_panel.status 78 if status is None: 79 return 80 root, name = status.root, change.path 81 new = change.area == gs.UNTRACKED or (change.area == gs.STAGED and change.kind in ("added", "renamed")) 82 both = change.area == gs.STAGED and any(c.path == name and c.area == gs.CHANGED for c in status.changes) 83 if change.area == gs.UNTRACKED: 84 question = (f"Move {name} to the Trash?\n\nIt isn't in git: this is its only copy (until the " 85 "Trash is emptied).") 86 elif change.area == gs.CHANGED: 87 staged = any(c.path == name and c.area == gs.STAGED for c in status.changes) 88 question = (f"Discard the changes to {name}?\n\nIt goes back to its " 89 + ("staged version (what's staged is kept)" if staged else "version in the last commit") 90 + ": the changes are lost.") 91 elif new: 92 question = (f"Discard {name}, new in the staged changes?\n\nIt's unstaged and moved to the Trash" 93 + (f", and {change.old_path} gets its name back" if change.kind == "renamed" else "") + ".") 94 else: 95 question = (f"Discard the changes to {name}, staged" + (" and not" if both else "") 96 + "?\n\nIt goes back to its version in the last commit: the changes are lost.") 97 editor = self._open_editor_for(root / name) 98 if editor is not None and editor.editor.document().isModified(): 99 question += "\n\nIt's open in the editor with unsaved edits: they're kept there, to save or not." 100 box = QMessageBox(QMessageBox.Warning, "Discard Changes", question, QMessageBox.Cancel, self) 101 discard = box.addButton("Move to Trash" if new else "Discard", QMessageBox.DestructiveRole) 102 box.setDefaultButton(QMessageBox.Cancel) 103 run_dialog(box) 104 if box.clickedButton() is not discard: 105 return 106 107 def work(): 108 try: 109 return gs.discard(root, change), "" 110 except gs.GitError as error: 111 return [], str(error) 112 113 def done(result): 114 paths, error = result 115 if error: 116 QMessageBox.warning(self, "Discard Changes", f"git couldn't discard the changes to {name}:\n\n{error}") 117 kept = [p for p in paths if p.exists() and not move_to_trash(p)] 118 if kept: 119 QMessageBox.warning(self, "Discard Changes", "Couldn't move to the Trash (left where it is):\n" 120 + "\n".join(str(p) for p in kept)) 121 elif not error: 122 self.statusBar().showMessage(f"Discarded the changes to {name}" 123 + (" (moved to the Trash)" if paths else ""), 5000) 124 self.refresh_git() 125 126 call_in_background(work, done, self) 127 128 def refresh_git(self): 129 """Read the repository's status again (or once the panel is on view).""" 130 panel = self.git_panel 131 if self.manifest_path is None: 132 panel.show_message("Open a project in a git repository to see its changes here.") 133 return 134 if not gs.available(): 135 panel.show_message("The Git panel needs git: install it (https://git-scm.com) and make sure " 136 "it's on the PATH, then click Refresh.") 137 return 138 if not self.panel_in_front(panel): 139 self._git_stale = True 140 return 141 self._git_stale = False 142 if self._git_reading: 143 self._git_again = True 144 return 145 self._git_reading = True 146 self._git_request += 1 147 request, folder = self._git_request, self.manifest_path.parent 148 call_in_background(lambda: _read(folder), lambda result: self._git_read(request, *result), self) 149 150 def _git_read(self, request: int, status: gs.RepoStatus | None, error: str): 151 self._git_reading = False 152 if self._git_again: 153 self._git_again = False 154 self.refresh_git() 155 return # (that one's answer is the one shown) 156 if request != self._git_request or self.manifest_path is None: 157 return 158 if error: 159 self.git_panel.show_message(f"Couldn't read the repository: {error}") 160 elif status is None: 161 self.git_panel.show_message(f"{self.manifest_path.parent.name} isn't in a git repository.") 162 else: 163 self.git_panel.show_status(status) 164 165 def _git_panel_shown(self, shown: bool): 166 if shown and self._git_stale: 167 self.refresh_git() 168 169 AWAY_SECONDS = 1.0 # (shorter: a dialog, a popup, a focus change; not time to use git elsewhere) 170 171 def _app_state_changed(self, state): 172 if state != Qt.ApplicationActive: 173 if self._inactive_since is None: 174 self._inactive_since = time.monotonic() 175 return 176 since, self._inactive_since = self._inactive_since, None 177 if since is not None and time.monotonic() - since >= self.AWAY_SECONDS: 178 self.refresh_git() # (back from a terminal, or another app, that may have used git)
The Git panel (a part of vibefpmgui.main_window.MainWindow).
73 def discard_git_change(self, change: gs.Change): 74 """The Git panel's Discard Changes… (Delete File… for an untracked 75 one): asked first, saying what's lost; files git has no copy of go to 76 the Trash (not deleted: they can be got back).""" 77 status = self.git_panel.status 78 if status is None: 79 return 80 root, name = status.root, change.path 81 new = change.area == gs.UNTRACKED or (change.area == gs.STAGED and change.kind in ("added", "renamed")) 82 both = change.area == gs.STAGED and any(c.path == name and c.area == gs.CHANGED for c in status.changes) 83 if change.area == gs.UNTRACKED: 84 question = (f"Move {name} to the Trash?\n\nIt isn't in git: this is its only copy (until the " 85 "Trash is emptied).") 86 elif change.area == gs.CHANGED: 87 staged = any(c.path == name and c.area == gs.STAGED for c in status.changes) 88 question = (f"Discard the changes to {name}?\n\nIt goes back to its " 89 + ("staged version (what's staged is kept)" if staged else "version in the last commit") 90 + ": the changes are lost.") 91 elif new: 92 question = (f"Discard {name}, new in the staged changes?\n\nIt's unstaged and moved to the Trash" 93 + (f", and {change.old_path} gets its name back" if change.kind == "renamed" else "") + ".") 94 else: 95 question = (f"Discard the changes to {name}, staged" + (" and not" if both else "") 96 + "?\n\nIt goes back to its version in the last commit: the changes are lost.") 97 editor = self._open_editor_for(root / name) 98 if editor is not None and editor.editor.document().isModified(): 99 question += "\n\nIt's open in the editor with unsaved edits: they're kept there, to save or not." 100 box = QMessageBox(QMessageBox.Warning, "Discard Changes", question, QMessageBox.Cancel, self) 101 discard = box.addButton("Move to Trash" if new else "Discard", QMessageBox.DestructiveRole) 102 box.setDefaultButton(QMessageBox.Cancel) 103 run_dialog(box) 104 if box.clickedButton() is not discard: 105 return 106 107 def work(): 108 try: 109 return gs.discard(root, change), "" 110 except gs.GitError as error: 111 return [], str(error) 112 113 def done(result): 114 paths, error = result 115 if error: 116 QMessageBox.warning(self, "Discard Changes", f"git couldn't discard the changes to {name}:\n\n{error}") 117 kept = [p for p in paths if p.exists() and not move_to_trash(p)] 118 if kept: 119 QMessageBox.warning(self, "Discard Changes", "Couldn't move to the Trash (left where it is):\n" 120 + "\n".join(str(p) for p in kept)) 121 elif not error: 122 self.statusBar().showMessage(f"Discarded the changes to {name}" 123 + (" (moved to the Trash)" if paths else ""), 5000) 124 self.refresh_git() 125 126 call_in_background(work, done, self)
The Git panel's Discard Changes… (Delete File… for an untracked one): asked first, saying what's lost; files git has no copy of go to the Trash (not deleted: they can be got back).
128 def refresh_git(self): 129 """Read the repository's status again (or once the panel is on view).""" 130 panel = self.git_panel 131 if self.manifest_path is None: 132 panel.show_message("Open a project in a git repository to see its changes here.") 133 return 134 if not gs.available(): 135 panel.show_message("The Git panel needs git: install it (https://git-scm.com) and make sure " 136 "it's on the PATH, then click Refresh.") 137 return 138 if not self.panel_in_front(panel): 139 self._git_stale = True 140 return 141 self._git_stale = False 142 if self._git_reading: 143 self._git_again = True 144 return 145 self._git_reading = True 146 self._git_request += 1 147 request, folder = self._git_request, self.manifest_path.parent 148 call_in_background(lambda: _read(folder), lambda result: self._git_read(request, *result), self)
Read the repository's status again (or once the panel is on view).