Addon Manager: Add branch switching GUI

For users with git and GitPython installed, a new button is added to the
details view allowing them to change the branch they are on. Some other
minor UI changes are made to accomodate this new behavior.
This commit is contained in:
Chris Hennes
2022-02-03 19:30:16 -06:00
parent ce2a8d00ec
commit f19f4e65b2
8 changed files with 541 additions and 11 deletions

View File

@@ -757,6 +757,8 @@ class CommandAddonManager:
if repo.status() == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE:
self.packages_with_updates.append(repo)
self.enable_updates(len(self.packages_with_updates))
elif repo.status() == AddonManagerRepo.UpdateStatus.PENDING_RESTART:
self.restart_required = True
def enable_updates(self, number_of_updates: int) -> None:
"""enables the update button"""

View File

@@ -13,6 +13,8 @@ SET(AddonManager_SRCS
AddonManager.ui
AddonManagerOptions.ui
ALLOWED_PYTHON_PACKAGES.txt
change_branch.py
change_branch.ui
first_run.ui
compact_view.py
dependency_resolution_dialog.ui

View File

@@ -433,21 +433,32 @@ class UpdateChecker:
with wb.git_lock:
utils.repair_git_repo(wb.url, clonedir)
with wb.git_lock:
gitrepo = git.Git(clonedir)
gitrepo = git.Repo(clonedir)
try:
gitrepo.fetch()
except Exception:
if gitrepo.head.is_detached:
# By definition, in a detached-head state we cannot
# update, so don't even bother checking.
wb.set_status(AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE)
if hasattr(gitrepo.head, "ref"):
wb.branch = gitrepo.head.ref.name
else:
wb.branch = gitrepo.head.name
return
gitrepo.git.fetch()
except Exception as e:
FreeCAD.Console.PrintWarning(
"AddonManager: "
+ translate(
"AddonsInstaller",
"Unable to fetch git updates for workbench {}",
).format(wb.name)
+ "\n"
)
FreeCAD.Console.PrintWarning(str(e) + "\n")
wb.set_status(AddonManagerRepo.UpdateStatus.CANNOT_CHECK)
else:
try:
if "git pull" in gitrepo.status():
if "git pull" in gitrepo.git.status():
wb.set_status(
AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE
)
@@ -460,12 +471,22 @@ class UpdateChecker:
translate(
"AddonsInstaller", "git fetch failed for {}"
).format(wb.name)
+ "\n"
)
wb.set_status(AddonManagerRepo.UpdateStatus.CANNOT_CHECK)
def check_package(self, package: AddonManagerRepo) -> None:
clonedir = self.moddir + os.sep + package.name
if os.path.exists(clonedir):
# First, try to just do a git-based update, which will give the most accurate results:
if have_git and not NOGIT:
self.check_workbench(package)
if package.status() != AddonManagerRepo.UpdateStatus.CANNOT_CHECK:
# It worked, just exit now
return
# If we were unable to do a git-based update, try using the package.xml file instead:
installed_metadata_file = os.path.join(clonedir, "package.xml")
if not os.path.isfile(installed_metadata_file):
# If there is no package.xml file, then it's because the package author added it after the last time
@@ -1020,7 +1041,9 @@ class InstallWorkbenchWorker(QtCore.QThread):
if self.repo.git_lock.locked():
FreeCAD.Console.PrintMessage("Waiting for lock to be released to us...\n")
if not self.repo.git_lock.acquire(timeout=2):
FreeCAD.Console.PrintError("Timeout waiting for a lock on the git process, failed to clone repo\n")
FreeCAD.Console.PrintError(
"Timeout waiting for a lock on the git process, failed to clone repo\n"
)
return
else:
self.repo.git_lock.release()

View File

@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2022 Chris Hennes <chennes@pioneerlibrarysystem.org> *
# * *
# * This library is free software; you can redistribute it and/or *
# * modify it under the terms of the GNU Lesser General Public *
# * License as published by the Free Software Foundation; either *
# * version 2.1 of the License, or (at your option) any later version. *
# * *
# * This library is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with this library; if not, write to the Free Software *
# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
# * 02110-1301 USA *
# * *
# ***************************************************************************
import os
import FreeCAD
import FreeCADGui
from PySide2 import QtWidgets, QtCore
translate = FreeCAD.Qt.translate
try:
import git
NO_GIT = False
except Exception:
NO_GIT = True
class ChangeBranchDialog(QtWidgets.QWidget):
branch_changed = QtCore.Signal(str)
def __init__(self, path: os.PathLike, parent=None):
super().__init__(parent)
self.ui = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "change_branch.ui")
)
self.item_filter = ChangeBranchDialogFilter()
self.ui.tableView.setModel(self.item_filter)
self.item_model = ChangeBranchDialogModel(path, self)
self.item_filter.setSourceModel(self.item_model)
self.ui.tableView.sortByColumn(
4, QtCore.Qt.DescendingOrder
) # Default to sorting by remote last-changed date
# Figure out what row gets selected:
row = 0
current_ref = self.item_model.repo.head.ref
selection_model = self.ui.tableView.selectionModel()
for ref in self.item_model.refs:
if ref == current_ref:
index = self.item_filter.mapFromSource(self.item_model.index(row, 0))
selection_model.select(index, QtCore.QItemSelectionModel.ClearAndSelect)
selection_model.select(
index.siblingAtColumn(1), QtCore.QItemSelectionModel.Select
)
selection_model.select(
index.siblingAtColumn(2), QtCore.QItemSelectionModel.Select
)
selection_model.select(
index.siblingAtColumn(3), QtCore.QItemSelectionModel.Select
)
selection_model.select(
index.siblingAtColumn(4), QtCore.QItemSelectionModel.Select
)
break
row += 1
# Make sure the column widths are OK:
header = self.ui.tableView.horizontalHeader()
header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
def exec(self):
if self.ui.exec() == QtWidgets.QDialog.Accepted:
selection = self.ui.tableView.selectedIndexes()
index = self.item_filter.mapToSource(selection[0])
ref = self.item_model.data(index, ChangeBranchDialogModel.RefAccessRole)
if ref == self.item_model.repo.head.ref:
# This is the one we are already on... just return
return
if self.item_model.repo.is_dirty():
result = QtWidgets.QMessageBox.critical(
self,
translate("AddonsInstaller", "There are local changes"),
translate(
"AddonsInstaller",
"WARNING: This repo has uncommitted local changes. Are you sure you want to change branches (bringing the changes with you)?",
),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel,
)
if result == QtWidgets.QMessageBox.Cancel:
return
if isinstance(ref, git.TagReference):
# Detach the head
self.item_model.repo.head.reference = ref
self.item_model.repo.head.reset(index=True, working_tree=True)
elif isinstance(ref, git.RemoteReference):
# Set up a local tracking branch
slash_index = ref.name.find("/")
if slash_index != -1:
local_name = ref.name[slash_index + 1 :]
else:
local_name = ref.name
self.item_model.repo.create_head(local_name, ref)
self.item_model.repo.heads[local_name].set_tracking_branch(ref)
self.item_model.repo.heads[local_name].checkout()
else:
# It's already a local branch, just check it out
ref.checkout()
self.branch_changed.emit(ref.name)
class ChangeBranchDialogModel(QtCore.QAbstractTableModel):
refs = []
display_data = []
DataSortRole = QtCore.Qt.UserRole
RefAccessRole = QtCore.Qt.UserRole + 1
def __init__(self, path: os.PathLike, parent=None) -> None:
super().__init__(parent)
self.repo = git.Repo(path)
self.refs = []
tracking_refs = []
for ref in self.repo.refs:
row = ["", None, None, None, None]
if "HEAD" in ref.name:
continue
if isinstance(ref, git.RemoteReference):
if ref.name in tracking_refs:
# Already seen, it's the remote part of a remote tracking branch
continue
else:
# Just a remote branch, not tracking:
row[0] = translate("AddonsInstaller", "Branch", "git terminology")
row[2] = ref.name
if hasattr(ref, "commit") and hasattr(ref.commit, "committed_date"):
row[4] = ref.commit.committed_date
else:
row[4] = ref.log_entry(0).time[0]
elif isinstance(ref, git.TagReference):
# Tags are simple, there is no tracking to worry about
row[0] = translate("AddonsInstaller", "Tag", "git terminology")
row[1] = ref.name
row[3] = ref.commit.committed_date
elif isinstance(ref, git.Head):
if hasattr(ref, "tracking_branch") and ref.tracking_branch():
# This local branch tracks a remote: we have all five pieces of data...
row[0] = translate("AddonsInstaller", "Branch", "git terminology")
row[1] = ref.name
row[2] = ref.tracking_branch().name
row[3] = ref.commit.committed_date
row[4] = ref.tracking_branch().commit.committed_date
tracking_refs.append(ref.tracking_branch().name)
else:
# Just a local branch, no remote tracking:
row[0] = translate("AddonsInstaller", "Branch", "git terminology")
row[1] = ref.name
if hasattr(ref, "commit") and hasattr(ref.commit, "committed_date"):
row[3] = ref.commit.committed_date
else:
row[3] = ref.log_entry(0).time[0]
else:
continue
self.display_data.append(row.copy())
self.refs.append(ref)
def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self.refs)
def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
if parent.isValid():
return 0
return 5 # Type, local name, tracking name, local update, and remote update
def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole):
if not index.isValid():
return None
row = index.row()
column = index.column()
if role == QtCore.Qt.ToolTipRole:
tooltip = ""
# TODO: What should the tooltip be for these items? Last commit message?
return tooltip
elif role == QtCore.Qt.DisplayRole:
dd = self.display_data[row]
if column == 3 or column == 4:
if dd[column] is not None:
qdate = QtCore.QDateTime.fromTime_t(dd[column])
return QtCore.QLocale().toString(qdate, QtCore.QLocale.ShortFormat)
elif column < len(dd):
return dd[column]
else:
return None
elif role == ChangeBranchDialogModel.DataSortRole:
if column == 0:
if self.refs[row] in self.repo.heads:
return 0
else:
return 1
elif column < len(self.display_data[row]):
return self.display_data[row][column]
else:
return None
elif role == ChangeBranchDialogModel.RefAccessRole:
return self.refs[row]
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role: int = QtCore.Qt.DisplayRole,
):
if orientation == QtCore.Qt.Vertical:
return None
if role != QtCore.Qt.DisplayRole:
return None
if section == 0:
return translate(
"AddonsInstaller",
"Kind",
"Table header for git ref type (e.g. either Tag or Branch)",
)
elif section == 1:
return translate(
"AddonsInstaller", "Local name", "Table header for git ref name"
)
elif section == 2:
return translate(
"AddonsInstaller",
"Tracking",
"Table header for git remote tracking branch name name",
)
elif section == 3:
return translate(
"AddonsInstaller",
"Local updated",
"Table header for git update time of local branch",
)
elif section == 4:
return translate(
"AddonsInstaller",
"Remote updated",
"Table header for git update time of remote branch",
)
else:
return None
class ChangeBranchDialogFilter(QtCore.QSortFilterProxyModel):
def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex):
leftData = self.sourceModel().data(left, ChangeBranchDialogModel.DataSortRole)
rightData = self.sourceModel().data(right, ChangeBranchDialogModel.DataSortRole)
if leftData is None or rightData is None:
if rightData is not None:
return True
else:
return False
return leftData < rightData

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>change_branch</class>
<widget class="QDialog" name="change_branch">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>550</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Change Branch</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Change to branch or tag:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QTableView" name="tableView">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>change_branch</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>change_branch</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -29,11 +29,22 @@ from PySide2.QtWidgets import *
import os
import FreeCAD
import FreeCADGui
import addonmanager_utilities as utils
from addonmanager_workers import GetMacroDetailsWorker, CheckSingleUpdateWorker
from AddonManagerRepo import AddonManagerRepo
import NetworkManager
from change_branch import ChangeBranchDialog
have_git = False
try:
import git
if hasattr(git, "Repo"):
have_git = True
except ImportError:
pass
from typing import Optional
@@ -83,6 +94,7 @@ class PackageDetails(QWidget):
self.ui.buttonCheckForUpdate.clicked.connect(
lambda: self.check_for_update.emit(self.repo)
)
self.ui.buttonChangeBranch.clicked.connect(self.change_branch_clicked)
if HAS_QTWEBENGINE:
self.ui.webView.loadStarted.connect(self.load_started)
self.ui.webView.loadProgress.connect(self.load_progress)
@@ -152,6 +164,7 @@ class PackageDetails(QWidget):
def display_repo_status(self, status):
repo = self.repo
self.set_change_branch_button_state()
if status != AddonManagerRepo.UpdateStatus.NOT_INSTALLED:
version = repo.installed_version
@@ -187,7 +200,7 @@ class PackageDetails(QWidget):
if repo.metadata:
installed_version_string += (
"<b>"
+ translate("AddonsInstaller", "Update available to version")
+ translate("AddonsInstaller", "On branch {}, update available to version").format(repo.branch)
+ " "
)
installed_version_string += repo.metadata.Version
@@ -210,10 +223,25 @@ class PackageDetails(QWidget):
+ ".</b>"
)
elif status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE:
installed_version_string += (
translate("AddonsInstaller", "This is the latest version available")
+ "."
)
detached_head = False
branch = repo.branch
if have_git:
basedir = FreeCAD.getUserAppDataDir()
moddir = os.path.join(basedir, "Mod", repo.name)
gitrepo = git.Repo(moddir)
branch = gitrepo.head.ref.name
detached_head = gitrepo.head.is_detached
if detached_head:
installed_version_string += (
translate("AddonsInstaller", "Git tag '{}' checked out, no updates possible").format(branch)
+ "."
)
else:
installed_version_string += (
translate("AddonsInstaller", "This is the latest version available for branch {}").format(branch)
+ "."
)
elif status == AddonManagerRepo.UpdateStatus.PENDING_RESTART:
installed_version_string += (
translate(
@@ -347,6 +375,34 @@ class PackageDetails(QWidget):
return first_supported_version
return None
def set_change_branch_button_state(self):
"""The change branch button is only available for installed Addons that have a .git directory
and in runs where the GitPython import is available."""
self.ui.buttonChangeBranch.hide()
# Is this repo installed? If not, return.
if self.repo.status() == AddonManagerRepo.UpdateStatus.NOT_INSTALLED:
return
# Is it a Macro? If so, return:
if self.repo.repo_type == AddonManagerRepo.RepoType.MACRO:
return
# Can we actually switch branches? If not, return.
if not have_git:
return
# Is there a .git subdirectory? If not, return.
basedir = FreeCAD.getUserAppDataDir()
path_to_git = os.path.join(basedir, "Mod", self.repo.name, ".git")
if not os.path.isdir(path_to_git):
return
# If all four above checks passed, then it's possible for us to switch
# branches, if there are any besides the one we are on: show the button
self.ui.buttonChangeBranch.show()
def show_workbench(self, repo: AddonManagerRepo) -> None:
"""loads information of a given workbench"""
url = utils.get_readme_html_url(repo)
@@ -492,6 +548,47 @@ class PackageDetails(QWidget):
html = f"<html><body><p>{m}</p></body></html>"
self.ui.webView.setHtml(html)
def change_branch_clicked(self) -> None:
basedir = FreeCAD.getUserAppDataDir()
path_to_repo = os.path.join(basedir, "Mod", self.repo.name)
change_branch_dialog = ChangeBranchDialog(path_to_repo, self)
change_branch_dialog.branch_changed.connect(self.branch_changed)
change_branch_dialog.exec()
def branch_changed(self, name: str) -> None:
QMessageBox.information(
self,
translate("AddonsInstaller", "Success"),
translate(
"AddonsInstaller",
"Branch change succeeded, please restart to use the new version.",
),
)
# See if this branch has a package.xml file:
basedir = FreeCAD.getUserAppDataDir()
path_to_metadata = os.path.join(basedir, "Mod", self.repo.name, "package.xml")
if os.path.isfile(path_to_metadata):
self.repo.load_metadata_file(path_to_metadata)
self.repo.installed_version = self.repo.metadata.Version
else:
self.repo.repo_type = AddonManagerRepo.RepoType.WORKBENCH
self.repo.metadata = None
self.repo.installed_version = None
self.repo.updated_timestamp = QDateTime.currentDateTime().toSecsSinceEpoch()
self.repo.branch = name
self.repo.set_status(AddonManagerRepo.UpdateStatus.PENDING_RESTART)
installed_version_string = "<h3>"
installed_version_string += translate(
"AddonsInstaller", "Changed to git ref '{}' -- please restart to use Addon."
).format(name)
installed_version_string += "</h3>"
self.ui.labelPackageDetails.setText(installed_version_string)
self.ui.labelPackageDetails.setStyleSheet(
"color:" + utils.attention_color_string()
)
self.update_status.emit(self.repo)
if HAS_QTWEBENGINE:
@@ -570,6 +667,11 @@ class Ui_PackageDetails(object):
self.layoutDetailsBackButton.addWidget(self.buttonCheckForUpdate)
self.buttonChangeBranch = QPushButton(PackageDetails)
self.buttonChangeBranch.setObjectName("buttonChangeBranch")
self.layoutDetailsBackButton.addWidget(self.buttonChangeBranch)
self.buttonExecute = QPushButton(PackageDetails)
self.buttonExecute.setObjectName("buttonExecute")
@@ -663,6 +765,9 @@ class Ui_PackageDetails(object):
self.buttonExecute.setText(
QCoreApplication.translate("AddonsInstaller", "Run Macro", None)
)
self.buttonChangeBranch.setText(
QCoreApplication.translate("AddonsInstaller", "Change Branch", None)
)
self.buttonBack.setToolTip(
QCoreApplication.translate(
"AddonsInstaller", "Return to package list", None

View File

@@ -74,6 +74,13 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonChangeBranch">
<property name="text">
<string>Change branch</string>
</property>
</widget>
</item>
</layout>
</item>
<item>

View File

@@ -353,7 +353,9 @@ class PackageListItemDelegate(QStyledItemDelegate):
)
elif len(maintainers) > 1:
n = len(maintainers)
maintainers_string = translate("AddonsInstaller", "Maintainers:", "", n)
maintainers_string = translate(
"AddonsInstaller", "Maintainers:", "", n
)
for maintainer in maintainers:
maintainers_string += (
f"\n{maintainer['name']} <{maintainer['email']}>"