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)
def move_to_trash(path: pathlib.Path) -> bool:
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.

class GitMixin:
 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).

def discard_git_change(self, change: vibefpmgui.git_status.Change):
 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).

def refresh_git(self):
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).

AWAY_SECONDS = 1.0