Coverage for vibefpmgui/window/git.py: 84%
128 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
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.
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"""
10from __future__ import annotations
12import time
13from pathlib import Path
15from qtpy.QtCore import QFile, Qt
16from qtpy.QtWidgets import QApplication, QMessageBox
18from .. import git_status as gs
19from ..background import call_in_background
20from ..git_panel import GitPanel
21from ..widgets import run_dialog
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__
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))
37class GitMixin:
38 """The Git panel (a part of `vibefpmgui.main_window.MainWindow`)."""
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)
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)
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: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
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: 85 ↛ 90line 85 didn't jump to line 90 because the condition on line 85 was always true
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(): 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
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
106 def work():
107 try:
108 return gs.discard(root, change), ""
109 except gs.GitError as error:
110 return [], str(error)
112 def done(result):
113 paths, error = result
114 if error: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true
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: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
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: 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true
121 self.statusBar().showMessage(f"Discarded the changes to {name}"
122 + (" (moved to the Trash)" if paths else ""), 5000)
123 self.refresh_git()
125 call_in_background(work, done, self)
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: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
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)
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: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true
156 return
157 if error: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
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)
164 def _git_panel_shown(self, shown: bool):
165 if shown and self._git_stale:
166 self.refresh_git()
168 AWAY_SECONDS = 1.0 # (shorter: a dialog, a popup, a focus change; not time to use git elsewhere)
170 def _app_state_changed(self, state):
171 if state != Qt.ApplicationActive:
172 if self._inactive_since is None: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true
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)