diff --git a/Addon.py b/Addon.py
index 621edfec..1ce21a3c 100644
--- a/Addon.py
+++ b/Addon.py
@@ -169,6 +169,7 @@ def __init__(
self.url = url.strip()
self.relative_cache_path = ""
self.branch = branch.strip()
+ self.branch_display_name = branch.strip()
self.repo_type = Addon.Kind.WORKBENCH
self.description = None
self.tags = set() # Just a cache, loaded from Metadata
@@ -176,6 +177,11 @@ def __init__(
self.stats = AddonStats()
self.score = 0
+ # In cases where there are multiple versions/branches/installations available for an addon,
+ # this dictionary is the mapping from the displayed name in the UI (as given in the
+ # catalog) to the Addon object.
+ self.sub_addons = {}
+
# To prevent multiple threads from running git actions on this repo at the
# same time
self.git_lock = Lock()
@@ -208,6 +214,14 @@ def __init__(
self._cached_license: str = ""
self._cached_update_date = None
+ def __eq__(self, other):
+ if not isinstance(other, Addon):
+ return NotImplemented
+ return self.name == other.name and self.branch_display_name == other.branch_display_name
+
+ def __hash__(self):
+ return hash((self.name, self.branch_display_name))
+
def _clean_url(self):
# The url should never end in ".git", so strip it if it's there
parsed_url = urlparse(self.url)
@@ -759,3 +773,24 @@ def import_from_addon(self, repo: Addon, all_repos: List[Addon]):
self.python_optional = [
option for option in self.python_optional if option not in self.python_requires
]
+
+
+def cycle_to_sub_addon(original: Addon, sub_addon: Addon, addon_model):
+ """Given an addon with sub-addons, cycle the sub-addon to be the primary. After this call,
+ the addon_list will contain the sub_addon, which will itself have a list of sub-addons that
+ now includes the original addon (and *not* itself).
+ :param original: The addon that is currently the primary
+ :param sub_addon: The sub-addon that should be the primary
+ :param addon_model: The PackageListItemModel containing all addons"""
+
+ addon_model.remove_item(original)
+
+ new_sub_dict = {original.branch_display_name: original}
+ for key, value in original.sub_addons.items():
+ if key != sub_addon.branch_display_name:
+ new_sub_dict[key] = value
+
+ sub_addon.sub_addons = new_sub_dict
+ original.sub_addons = {}
+
+ addon_model.append_item(sub_addon)
diff --git a/AddonCatalog.py b/AddonCatalog.py
index 20aa833f..50a5dcc0 100644
--- a/AddonCatalog.py
+++ b/AddonCatalog.py
@@ -178,6 +178,10 @@ def instantiate_addon(self, addon_id: str) -> Addon:
else:
addon.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
+ addon.branch_display_name = (
+ self.branch_display_name if self.branch_display_name else self.git_ref
+ )
+
return addon
@staticmethod
@@ -335,16 +339,18 @@ def add_metadata_to_entry(
raise RuntimeError(f"Addon {addon_id} index out of range")
self._dictionary[addon_id][index].metadata = metadata
- def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
+ def get_available_branches(self, addon_id: str) -> List[str]:
"""For a given ID, get the list of available branches compatible with this version of
- FreeCAD along with the branch display name. Either field may be empty, but not both. The
- first entry in the list is expected to be the "primary"."""
+ FreeCAD.
+ :return: A list of branch display names (or git refs, if no display name is available)"""
if addon_id not in self._dictionary:
return []
result = []
for entry in self._dictionary[addon_id]:
if entry.is_compatible():
- result.append((entry.git_ref, entry.branch_display_name))
+ result.append(
+ entry.branch_display_name if entry.branch_display_name else entry.git_ref
+ )
return result
def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]:
diff --git a/AddonManager.py b/AddonManager.py
index c62d8240..3fcd4a05 100644
--- a/AddonManager.py
+++ b/AddonManager.py
@@ -24,9 +24,10 @@
import os
import functools
+import shutil
import tempfile
import threading
-from typing import Dict
+from typing import Dict, List
from PySideWrapper import QtGui, QtCore, QtWidgets, QtSvg
@@ -45,7 +46,7 @@
from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
from Widgets.addonmanager_widget_progress_bar import Progress
from package_list import PackageListItemModel
-from Addon import Addon
+from Addon import Addon, cycle_to_sub_addon
from addonmanager_python_deps_gui import (
PythonPackageManagerGui,
)
@@ -298,7 +299,7 @@ def launch(self) -> None:
self.composite_view.package_list.stop_loading.connect(self.stop_update)
self.composite_view.package_list.setEnabled(False)
self.composite_view.execute.connect(self.execute_macro)
- self.composite_view.install.connect(self.launch_installer_gui)
+ self.composite_view.install.connect(self.update)
self.composite_view.uninstall.connect(self.remove)
self.composite_view.update.connect(self.update)
self.composite_view.update_status.connect(self.status_updated)
@@ -440,6 +441,7 @@ def populate_packages_table(self) -> None:
self.create_addon_list_worker = CreateAddonListWorker()
self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo)
self.update_progress_bar(translate("AddonsInstaller", "Creating addon list"), 10, 100)
+ self.create_addon_list_worker.old_backups_found.connect(self.found_old_backups)
self.create_addon_list_worker.finished.connect(self.do_next_startup_phase) # Link to step 2
self.create_addon_list_worker.start()
@@ -600,6 +602,12 @@ def append_to_repos_list(self, repo: Addon) -> None:
self.item_model.append_item(repo)
def update(self, repo: Addon) -> None:
+ try:
+ self.prep_for_install(repo)
+ except OSError as e:
+ fci.Console.PrintError(e)
+ fci.Console.PrintError("\n\nInstallation cancelled: out of disk space?\n")
+ return
self.launch_installer_gui(repo)
def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
@@ -610,6 +618,19 @@ def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
self.item_model.reload_item(repo)
self.composite_view.package_details_controller.show_repo(repo)
+ def prep_for_install(self, installing_addon: Addon):
+ """To prepare for installing an addon, we need to see if this is the current active branch:
+ if it is not, then we need to cycle the addon's attached to this addon ID, making this one
+ active. We'll also remove any existing addon installed with this ID, making a backup
+ so that we can recover from a failed update/branch switch."""
+
+ for catalog_addon in self.item_model.repos:
+ if catalog_addon.name == installing_addon.name:
+ if catalog_addon != installing_addon:
+ cycle_to_sub_addon(catalog_addon, installing_addon, self.item_model)
+ break
+ make_backup(installing_addon)
+
def launch_installer_gui(self, addon: Addon) -> None:
if self.installer_gui is not None:
fci.Console.PrintError(
@@ -624,6 +645,7 @@ def launch_installer_gui(self, addon: Addon) -> None:
else:
self.installer_gui = AddonInstallerGUI(addon, self.item_model.repos)
self.installer_gui.success.connect(self.on_package_status_changed)
+ self.installer_gui.success.connect(cleanup_pre_installation_backup)
self.installer_gui.finished.connect(self.cleanup_installer)
self.installer_gui.run() # Does not block
@@ -743,5 +765,85 @@ def open_addons_folder():
QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(addons_folder))
return
+ @staticmethod
+ def found_old_backups(backups: List[str]):
+ handling = fci.Preferences().get("old_backup_handling")
+ if handling.lower().strip() == "never":
+ return
+ if handling.lower().strip() == "always":
+ delete_old_backups(backups)
+ return
+
+ backup_string = (
+ translate(
+ "AddonsInstaller",
+ f"The following auto-generated backups were found in your Mod directory:",
+ )
+ + "\n"
+ )
+ for backup in backups:
+ backup_string += "• " + str(backup) + "\n"
+ backup_string += translate("AddonsInstaller", "Do you want to delete them now?")
+ dlg = QtWidgets.QMessageBox()
+ dlg.setObjectName("AddonManager_FoundOldBackups")
+ dlg.setText(backup_string)
+ dlg.setStandardButtons(
+ QtWidgets.QMessageBox.YesToAll
+ | QtWidgets.QMessageBox.Yes
+ | QtWidgets.QMessageBox.No
+ | QtWidgets.QMessageBox.NoToAll
+ )
+ dlg.setDefaultButton(QtWidgets.QMessageBox.No)
+ dlg.button(QtWidgets.QMessageBox.YesToAll).setText(
+ translate("AddonsInstaller", "Always", "'Always' delete old backups")
+ )
+ dlg.button(QtWidgets.QMessageBox.NoToAll).setText(
+ translate("AddonsInstaller", "Never", "'Never' delete old backups")
+ )
+ result = dlg.exec_()
+
+ if result == QtWidgets.QMessageBox.NoToAll:
+ fci.Preferences().set("old_backup_handling", "never")
+ return
+ if result == QtWidgets.QMessageBox.No:
+ return
+ if result == QtWidgets.QMessageBox.YesToAll:
+ fci.Preferences().set("old_backup_handling", "always")
+ delete_old_backups(backups)
+
+
+# Some utility functions
+
+
+def make_backup(addon: Addon) -> None:
+ """Make a backup of the addon's current installation directory, so that we can recover
+ from a failed update/branch switch."""
+ original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
+ if os.path.exists(original):
+ shutil.copytree(original, original + ".pre_update_backup", dirs_exist_ok=True)
+
+
+def cleanup_pre_installation_backup(addon: Addon) -> None:
+ """Remove the backup of the addon's current installation directory"""
+ original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
+ if os.path.exists(original):
+ shutil.rmtree(original + ".pre_update_backup", ignore_errors=True)
+
+
+def revert_to_backup(addon: Addon) -> None:
+ """Revert to the backup of the addon's current installation directory"""
+ original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
+ if os.path.exists(original + ".pre_update_backup"):
+ shutil.rmtree(original, ignore_errors=True)
+ shutil.copytree(original + ".pre_update_backup", original, dirs_exist_ok=True)
+
+
+def delete_old_backups(backups) -> None:
+ """Delete old backups found in the Mod directory."""
+ for backup in backups:
+ full_path = os.path.join(fci.DataPaths().mod_dir, backup)
+ if os.path.exists(full_path):
+ shutil.rmtree(full_path, ignore_errors=True)
+
# @}
diff --git a/AddonManagerTest/app/test_installation_manifest.py b/AddonManagerTest/app/test_installation_manifest.py
new file mode 100644
index 00000000..2e154c77
--- /dev/null
+++ b/AddonManagerTest/app/test_installation_manifest.py
@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 The FreeCAD project association AISBL *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD 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. *
+# * *
+# * FreeCAD 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 FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+import datetime
+import json
+import os
+from unittest.mock import patch
+
+from pyfakefs.fake_filesystem_unittest import TestCase as PyFakeFSTestCase
+
+from addonmanager_installation_manifest import InstallationManifest, most_recent_update
+
+
+class MockCatalog:
+ def get_available_branches(self, addon_id):
+ return ["Main"]
+
+
+class MockAddon:
+ branch_display_name = "Main"
+ branch = "abc123"
+
+
+class TestMostRecentUpdate(PyFakeFSTestCase):
+ def setUp(self):
+ self.setUpPyfakefs()
+
+ def test_latest_mtime_file_is_returned(self):
+ self.fs.create_dir("/test/dir")
+ now = datetime.datetime.now().timestamp()
+ earlier = now - 100
+ later = now + 100
+
+ file1 = "/test/dir/file1.txt"
+ file2 = "/test/dir/file2.txt"
+
+ self.fs.create_file(file1)
+ self.fs.create_file(file2)
+
+ os.utime(file1, (earlier, earlier))
+ os.utime(file2, (later, later))
+
+ result = most_recent_update("/test/dir")
+ expected = datetime.datetime.fromtimestamp(later).astimezone()
+ self.assertEqual(result, expected)
+
+ def test_empty_directory_returns_epoch(self):
+ self.fs.create_dir("/test/empty")
+ result = most_recent_update("/test/empty")
+ expected = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc)
+ self.assertEqual(result, expected)
+
+ def test_nonexistent_directory_raises(self):
+ with self.assertRaises(FileNotFoundError):
+ most_recent_update("/does/not/exist")
+
+ def test_path_is_not_directory(self):
+ self.fs.create_file("/test/file.txt")
+ with self.assertRaises(NotADirectoryError):
+ most_recent_update("/test/file.txt")
+
+
+class TestInstallationManifest(PyFakeFSTestCase):
+
+ def setUp(self):
+ self.setUpPyfakefs()
+
+ def tearDown(self):
+ pass
+
+ @patch("addonmanager_installation_manifest.fci.DataPaths")
+ def test_manifest_created_when_missing(self, mock_data_paths):
+ # Arrange
+ self.mod_dir = "/fake/mod"
+ self.manifest_path = os.path.join(self.mod_dir, "manifest.json")
+
+ mock_data_paths.return_value.mod_dir = self.mod_dir
+
+ self.fs.create_dir(self.mod_dir)
+ self.fs.create_dir(os.path.join(self.mod_dir, "SomeAddon"))
+
+ # File does NOT exist before Act
+ self.assertFalse(os.path.exists(self.manifest_path))
+
+ catalog = MockCatalog()
+
+ # Act
+ InstallationManifest.path_to_manifest_file = "" # clear any previous state
+ manifest = InstallationManifest(catalog)
+
+ # Assert
+
+ # File should now exist
+ self.assertTrue(os.path.exists(self.manifest_path))
+
+ # File contents should match the expected manifest structure
+ with open(self.manifest_path, "r") as f:
+ data = json.load(f)
+
+ expected = {
+ "SomeAddon": {
+ "addon_id": "SomeAddon",
+ "migrated": True,
+ "first_installed": datetime.datetime.fromtimestamp(
+ 0, tz=datetime.timezone.utc
+ ).isoformat(),
+ "last_updated": manifest.get_addon_info("SomeAddon")[
+ "last_updated"
+ ], # can't hardcode
+ "branch_display_name": "Main",
+ "extra_files": [],
+ "freecad_version": "",
+ }
+ }
+
+ self.assertEqual(data["SomeAddon"]["addon_id"], expected["SomeAddon"]["addon_id"])
+ self.assertEqual(data["SomeAddon"]["migrated"], expected["SomeAddon"]["migrated"])
+ self.assertEqual(
+ data["SomeAddon"]["branch_display_name"], expected["SomeAddon"]["branch_display_name"]
+ )
+ self.assertEqual(data["SomeAddon"]["extra_files"], expected["SomeAddon"]["extra_files"])
+ self.assertEqual(
+ data["SomeAddon"]["freecad_version"], expected["SomeAddon"]["freecad_version"]
+ )
+ # Validate that last_updated is a valid ISO8601 datetime string
+ self.assertTrue(datetime.datetime.fromisoformat(data["SomeAddon"]["last_updated"]))
+
+ @patch("addonmanager_installation_manifest.fci.DataPaths")
+ def test_manifest_created_without_catalog(self, mock_data_paths):
+ mod_dir = "/fake/mod"
+ manifest_path = os.path.join(mod_dir, "manifest.json")
+
+ mock_data_paths.return_value.mod_dir = mod_dir
+
+ self.fs.create_dir(mod_dir)
+ self.fs.create_dir(os.path.join(mod_dir, "OrphanAddon"))
+
+ InstallationManifest.path_to_manifest_file = "" # Reset shared class var
+ manifest = InstallationManifest(catalog=None)
+
+ self.assertTrue(os.path.exists(manifest_path))
+
+ with open(manifest_path, "r") as f:
+ data = json.load(f)
+ self.assertEqual(data, {})
+
+ @patch("addonmanager_installation_manifest.fci.DataPaths")
+ def test_manifest_loaded_if_exists(self, mock_data_paths):
+ mod_dir = "/fake/mod"
+ manifest_path = os.path.join(mod_dir, "manifest.json")
+ mock_data_paths.return_value.mod_dir = mod_dir
+
+ self.fs.create_dir(mod_dir)
+ known_addon_id = "KnownAddon"
+ backup_addon_id = "SomeAddon_backup"
+ self.fs.create_dir(os.path.join(mod_dir, known_addon_id))
+ self.fs.create_dir(os.path.join(mod_dir, backup_addon_id))
+
+ fake_manifest_data = {
+ known_addon_id: {
+ "addon_id": known_addon_id,
+ "migrated": True,
+ "first_installed": "2024-01-01T00:00:00+00:00",
+ "last_updated": "2024-01-01T00:00:00+00:00",
+ "branch_display_name": "Main",
+ "extra_files": [],
+ "freecad_version": "0.20",
+ }
+ }
+ with open(manifest_path, "w") as f:
+ json.dump(fake_manifest_data, f)
+
+ InstallationManifest.path_to_manifest_file = "" # Reset class var
+ manifest = InstallationManifest(catalog=None)
+
+ self.assertIn(known_addon_id, manifest._manifest)
+ self.assertEqual(manifest._manifest[known_addon_id]["branch_display_name"], "Main")
+
+ # Manifest file should still exist and be unmodified (load only)
+ with open(manifest_path, "r") as f:
+ reloaded = json.load(f)
+ self.assertEqual(reloaded, fake_manifest_data)
+
+ @patch("addonmanager_installation_manifest.fci.DataPaths")
+ def test_unrecognized_addons_are_detected(self, mock_data_paths):
+ mod_dir = "/fake/mod"
+ mock_data_paths.return_value.mod_dir = mod_dir
+ self.fs.create_dir(mod_dir)
+
+ self.fs.create_dir(os.path.join(mod_dir, "UnknownAddon"))
+
+ manifest_path = os.path.join(mod_dir, "manifest.json")
+ with open(manifest_path, "w") as f:
+ f.write("{}")
+
+ class MinimalMockCatalog:
+ def get_available_branches(self, addon_id):
+ return []
+
+ catalog = MinimalMockCatalog()
+ InstallationManifest.path_to_manifest_file = ""
+ manifest = InstallationManifest(catalog)
+
+ self.assertIn("UnknownAddon", manifest.unrecognized_directories)
+
+ @patch("addonmanager_installation_manifest.fci.DataPaths")
+ def test_backups_are_detected(self, mock_data_paths):
+ mod_dir = "/fake/mod"
+ mock_data_paths.return_value.mod_dir = mod_dir
+ self.fs.create_dir(mod_dir)
+
+ self.fs.create_dir(os.path.join(mod_dir, "SomeAddon-backup-1-2-3"))
+
+ manifest_path = os.path.join(mod_dir, "manifest.json")
+ with open(manifest_path, "w") as f:
+ f.write("{}")
+
+ class MinimalMockCatalog:
+ def get_available_branches(self, addon_id):
+ return []
+
+ catalog = MinimalMockCatalog()
+ InstallationManifest.path_to_manifest_file = ""
+ manifest = InstallationManifest(catalog)
+
+ self.assertIn("SomeAddon-backup-1-2-3", manifest.old_backups)
diff --git a/AddonManagerTest/app/test_installer.py b/AddonManagerTest/app/test_installer.py
index 5b9c9dae..750c020f 100644
--- a/AddonManagerTest/app/test_installer.py
+++ b/AddonManagerTest/app/test_installer.py
@@ -24,7 +24,7 @@
"""Contains the unit test class for addonmanager_installer.py non-GUI functionality."""
import unittest
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
import os
import shutil
import tempfile
@@ -114,7 +114,8 @@ def test_update_metadata(self):
installer._update_metadata()
self.assertEqual(self.real_addon.installed_version, good_metadata.version)
- def test_finalize_zip_installation_non_github(self):
+ @patch("addonmanager_installer.InstallationManifest")
+ def test_finalize_zip_installation_non_github(self, mock_manifest):
"""Ensure that zip files are correctly extracted."""
with tempfile.TemporaryDirectory() as temp_dir:
test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip")
@@ -127,7 +128,8 @@ def test_finalize_zip_installation_non_github(self):
expected_location = os.path.join(temp_dir, non_gh_mock.name, "README")
self.assertTrue(os.path.isfile(expected_location), "Non-GitHub zip extraction failed")
- def test_finalize_zip_installation_github(self):
+ @patch("addonmanager_installer.InstallationManifest")
+ def test_finalize_zip_installation_github(self, mock_manifest):
with tempfile.TemporaryDirectory() as temp_dir:
test_github_style_repo = os.path.join(self.test_data_dir, "test_github_style_repo.zip")
self.mock_addon.url = "https://github.com/something/test_github_style_repo"
@@ -189,7 +191,8 @@ def test_move_code_out_of_subdirectory(self):
self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt")))
self.assertFalse(os.path.isdir(subdir))
- def test_install_by_git(self):
+ @patch("addonmanager_installer.InstallationManifest")
+ def test_install_by_git(self, mock_manifest):
"""Test using git to install. Depends on there being a local git
installation: the test is skipped if there is no local git."""
git_manager = initialize_git()
@@ -218,7 +221,8 @@ def test_install_by_git(self):
readme = os.path.join(addon_name_dir, "README.md")
self.assertTrue(os.path.exists(readme))
- def test_install_by_copy(self):
+ @patch("addonmanager_installer.InstallationManifest")
+ def test_install_by_copy(self, manifest):
"""Test using a simple filesystem copy to install an addon."""
with tempfile.TemporaryDirectory() as temp_dir:
git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip")
@@ -404,7 +408,8 @@ def setUp(self):
self.mock = MockAddon()
self.mock.macro = MockMacro()
- def test_installation(self):
+ @patch("addonmanager_installer.InstallationManifest")
+ def test_installation(self, mock_installation_manifest):
"""Test the wrapper around the macro installer"""
# Note that this doesn't test the underlying Macro object's install function,
diff --git a/AddonManagerTest/app/test_uninstaller.py b/AddonManagerTest/app/test_uninstaller.py
index 4396da0a..9ccfef5c 100644
--- a/AddonManagerTest/app/test_uninstaller.py
+++ b/AddonManagerTest/app/test_uninstaller.py
@@ -28,6 +28,7 @@
from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR
import tempfile
import unittest
+from unittest.mock import patch
from addonmanager_uninstaller import AddonUninstaller, MacroUninstaller
@@ -76,7 +77,8 @@ def create_fake_macro(self, macro_directory, fake_macro_name, digest):
with open(fake_file_installed, "w", encoding="utf-8") as f:
f.write("# Fake macro data for unit testing")
- def test_uninstall_normal(self):
+ @patch("addonmanager_uninstaller.InstallationManifest")
+ def test_uninstall_normal(self, mock_installation_manifest):
"""Test the integrated uninstall function under normal circumstances"""
with tempfile.TemporaryDirectory() as temp_dir:
@@ -124,7 +126,8 @@ def test_uninstall_unmatching_name(self):
self.assertNotIn("success", self.signals_caught)
self.assertIn("finished", self.signals_caught)
- def test_uninstall_addon_with_macros(self):
+ @patch("addonmanager_uninstaller.InstallationManifest")
+ def test_uninstall_addon_with_macros(self, mock_manifest):
"""Tests that the uninstaller removes the macro files"""
with tempfile.TemporaryDirectory() as temp_dir:
toplevel_path = self.setup_dummy_installation(temp_dir)
@@ -141,7 +144,8 @@ def test_uninstall_addon_with_macros(self):
self.assertFalse(os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro")))
self.assertTrue(os.path.exists(macro_directory))
- def test_uninstall_calls_script(self):
+ @patch("addonmanager_uninstaller.InstallationManifest")
+ def test_uninstall_calls_script(self, mock_install_manifest):
"""Tests that the main uninstaller run function calls the uninstall.py script"""
class Interceptor:
diff --git a/AddonManagerTest/app/test_workers_startup.py b/AddonManagerTest/app/test_workers_startup.py
index 2a0737b3..cad78a56 100644
--- a/AddonManagerTest/app/test_workers_startup.py
+++ b/AddonManagerTest/app/test_workers_startup.py
@@ -101,10 +101,13 @@ def create_fake_addon_catalog_json(num_entries: int):
]
return json.dumps(catalog_dict)
+ @patch("addonmanager_workers_startup.InstallationManifest")
@patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo")
- def test_process_addon_catalog_single(self, mock_addon_repo_signal):
+ def test_process_addon_catalog_single(self, mock_addon_repo_signal, mock_manifest_class):
# Arrange
catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(1)
+ mock_manifest_instance = self.MockManifest()
+ mock_manifest_class.return_value = mock_manifest_instance
# Act
addonmanager_workers_startup.CreateAddonListWorker().process_addon_cache(catalog_text)
@@ -112,25 +115,42 @@ def test_process_addon_catalog_single(self, mock_addon_repo_signal):
# Assert
mock_addon_repo_signal.emit.assert_called_once()
+ class MockManifest:
+ def __init__(self):
+ self.old_backups = []
+
+ def contains(self, _):
+ return False
+
+ @patch("addonmanager_workers_startup.InstallationManifest")
@patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo")
- def test_process_addon_catalog_multiple(self, mock_addon_repo_signal):
+ def test_process_addon_catalog_multiple(self, mock_addon_repo_signal, mock_manifest_class):
# Arrange
catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(10)
+ mock_manifest_instance = self.MockManifest()
+ mock_manifest_class.return_value = mock_manifest_instance
+
# Act
addonmanager_workers_startup.CreateAddonListWorker().process_addon_cache(catalog_text)
# Assert
self.assertEqual(mock_addon_repo_signal.emit.call_count, 10)
+ @patch("addonmanager_workers_startup.InstallationManifest")
@patch("addonmanager_workers_startup.CreateAddonListWorker.addon_repo")
@patch("addonmanager_workers_startup.fci.Console")
- def test_process_addon_catalog_with_user_override(self, _, mock_addon_repo_signal):
+ def test_process_addon_catalog_with_user_override(
+ self, _, mock_addon_repo_signal, mock_manifest_class
+ ):
# Arrange
catalog_text = TestCreateAddonListWorker.create_fake_addon_catalog_json(10)
worker = addonmanager_workers_startup.CreateAddonListWorker()
worker.package_names = ["FakeAddon1", "FakeAddon2"]
+ mock_manifest_instance = self.MockManifest()
+ mock_manifest_class.return_value = mock_manifest_instance
+
# Act
worker.process_addon_cache(catalog_text)
diff --git a/AddonManagerTest/gui/test_change_branch.py b/AddonManagerTest/gui/test_change_branch.py
deleted file mode 100644
index 6dd44fe9..00000000
--- a/AddonManagerTest/gui/test_change_branch.py
+++ /dev/null
@@ -1,231 +0,0 @@
-# SPDX-License-Identifier: LGPL-2.1-or-later
-# ***************************************************************************
-# * *
-# * Copyright (c) 2025 The FreeCAD Project Association AISBL *
-# * *
-# * This file is part of FreeCAD. *
-# * *
-# * FreeCAD 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. *
-# * *
-# * FreeCAD 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 FreeCAD. If not, see *
-# * . *
-# * *
-# ***************************************************************************
-
-"""Test the Change Branch GUI code"""
-
-# pylint: disable=wrong-import-position, deprecated-module, too-many-return-statements
-
-import sys
-import unittest
-from unittest.mock import patch, Mock, MagicMock
-
-from AddonManagerTest.gui.gui_mocks import DialogWatcher, AsynchronousMonitor
-
-from change_branch import ChangeBranchDialog
-
-from addonmanager_freecad_interface import translate
-from addonmanager_git import GitFailed
-
-try:
- from PySide import QtCore, QtWidgets
-except ImportError:
- try:
- from PySide6 import QtCore, QtWidgets
- except ImportError:
- from PySide2 import QtCore, QtWidgets
-
-
-class MockFilter(QtCore.QSortFilterProxyModel):
- """Replaces a filter with a non-filter that simply always returns whatever it's given"""
-
- def mapToSource(self, something):
- return something
-
-
-class MockChangeBranchDialogModel(QtCore.QAbstractTableModel):
- """Replace a data-connected model with a static one for testing"""
-
- branches = [
- {"ref_name": "ref1", "upstream": "us1"},
- {"ref_name": "ref2", "upstream": "us2"},
- {"ref_name": "ref3", "upstream": "us3"},
- ]
- current_branch = "ref1"
- DataSortRole = QtCore.Qt.UserRole
- RefAccessRole = QtCore.Qt.UserRole + 1
-
- def __init__(self, _: str, parent=None) -> None:
- super().__init__(parent)
-
- def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
- """Number of rows: should always return 3"""
- if parent.isValid():
- return 0
- return len(self.branches)
-
- def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
- """Number of columns (identical to non-mocked version)"""
- if parent.isValid():
- return 0
- return 3 # Local name, remote name, date
-
- def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole):
- """Mock returns static untranslated strings for DisplayRole, no tooltips at all, and
- otherwise matches the non-mock version"""
- if not index.isValid():
- return None
- row = index.row()
- column = index.column()
- if role == QtCore.Qt.DisplayRole:
- if column == 2:
- return "date"
- if column == 0:
- return "ref_name"
- if column == 1:
- return "upstream"
- return None
- if role == MockChangeBranchDialogModel.DataSortRole:
- return None
- if role == MockChangeBranchDialogModel.RefAccessRole:
- return self.branches[row]
- return None
-
- def headerData(
- self,
- section: int,
- orientation: QtCore.Qt.Orientation,
- role: int = QtCore.Qt.DisplayRole,
- ):
- """Mock returns untranslated strings for DisplayRole, and no tooltips at all"""
- if orientation == QtCore.Qt.Vertical:
- return None
- if role != QtCore.Qt.DisplayRole:
- return None
- if section == 0:
- return "Local"
- if section == 1:
- return "Remote tracking"
- if section == 2:
- return "Last Updated"
- return None
-
- def currentBranch(self) -> str:
- """Mock returns a static string stored in the class: that string could be modified to
- return something else by tests that require it."""
- return self.current_branch
-
-
-class TestChangeBranchGui(unittest.TestCase):
- """Tests for the ChangeBranch GUI code"""
-
- MODULE = "test_change_branch" # file name without extension
-
- def setUp(self):
- pass
-
- def tearDown(self):
- pass
-
- @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
- @patch("change_branch.initialize_git", new=Mock(return_value=None))
- def test_no_git(self):
- """If git is not present, a dialog saying so is presented"""
- # Arrange
- gui = ChangeBranchDialog("/some/path")
- ref = {"ref_name": "foo/bar", "upstream": "us1"}
- dialog_watcher = DialogWatcher(
- "AddonManager_CannotFindGitDialog",
- QtWidgets.QDialogButtonBox.Ok,
- )
-
- # Act
- gui.change_branch("/foo/bar/baz", ref)
-
- # Assert
- self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
-
- @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
- @patch("change_branch.initialize_git")
- def test_git_failed(self, init_git: MagicMock):
- """If git fails when attempting to change branches, a dialog saying so is presented"""
- # Arrange
- git_manager = MagicMock()
- git_manager.checkout = MagicMock()
- git_manager.checkout.side_effect = GitFailed()
- init_git.return_value = git_manager
- gui = ChangeBranchDialog("/some/path")
- ref = {"ref_name": "foo/bar", "upstream": "us1"}
- dialog_watcher = DialogWatcher(
- "AddonManager_GitOperationFailedDialog",
- QtWidgets.QDialogButtonBox.Ok,
- )
-
- # Act
- gui.change_branch("/foo/bar/baz", ref)
-
- # Assert
- self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
-
- @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
- @patch("change_branch.initialize_git", new=MagicMock)
- def test_branch_change_succeeded(self):
- """If nothing gets thrown, then the process is assumed to have worked, and the appropriate
- signal is emitted."""
-
- # Arrange
- gui = ChangeBranchDialog("/some/path")
- ref = {"ref_name": "foo/bar", "upstream": "us1"}
- monitor = AsynchronousMonitor(gui.branch_changed)
-
- # Act
- gui.change_branch("/foo/bar/baz", ref)
-
- # Assert
- monitor.wait_for_at_most(10) # Should be effectively instantaneous
- self.assertTrue(monitor.good())
-
- @patch("change_branch.ChangeBranchDialogFilter", new=MockFilter)
- @patch("change_branch.ChangeBranchDialogModel", new=MockChangeBranchDialogModel)
- @patch("change_branch.initialize_git", new=MagicMock)
- def test_warning_is_shown_when_dialog_is_accepted(self):
- """If the dialog is accepted (e.g. a branch change is requested) then a warning dialog is
- displayed, and gives the opportunity to cancel. If cancelled, no signal is emitted."""
- # Arrange
- gui = ChangeBranchDialog("/some/path")
- gui.ui.exec = MagicMock()
- gui.ui.exec.return_value = QtWidgets.QDialog.Accepted
- gui.ui.tableView.selectedIndexes = MagicMock()
- gui.ui.tableView.selectedIndexes.return_value = [MagicMock()]
- gui.ui.tableView.selectedIndexes.return_value[0].isValid = MagicMock()
- gui.ui.tableView.selectedIndexes.return_value[0].isValid.return_value = True
- dialog_watcher = DialogWatcher(
- "AddonManager_DeveloperFeatureDialog",
- QtWidgets.QDialogButtonBox.Cancel,
- )
- monitor = AsynchronousMonitor(gui.branch_changed)
-
- # Act
- gui.exec()
-
- # Assert
- self.assertTrue(dialog_watcher.dialog_found, "Failed to find the expected dialog box")
- self.assertFalse(monitor.good()) # The watcher cancelled the op, so no signal is emitted
-
-
-if __name__ == "__main__":
- app = QtWidgets.QApplication(sys.argv)
- QtCore.QTimer.singleShot(0, unittest.main)
- if hasattr(app, "exec"):
- app.exec() # PySide6
- else:
- app.exec_() # PySide2
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 788b4f3a..7a03999f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -44,8 +44,6 @@ SET(AddonManager_SRCS
addonmanager_utilities.py
addonmanager_workers_startup.py
addonmanager_workers_utility.py
- change_branch.py
- change_branch.ui
compact_view.py
composite_view.py
dependency_resolution_dialog.ui
diff --git a/NetworkManager.py b/NetworkManager.py
index 56d256f0..30d6212e 100644
--- a/NetworkManager.py
+++ b/NetworkManager.py
@@ -317,9 +317,9 @@ def __launch_request(
if operation == QtNetwork.QNetworkAccessManager.GetOperation:
reply = self.QNAM.get(request)
elif operation == QtNetwork.QNetworkAccessManager.HeadOperation:
- reply = self.QNAM.sendCustomRequest(request, b"HEAD")
+ reply = self.QNAM.head(request)
else:
- raise NotImplementedError(f"Unknown operation {operation}")
+ raise NotImplementedError(f"Unknown operation {operation.name}")
self.replies[index] = reply
self.__last_started_index = index
@@ -619,6 +619,8 @@ def __reply_finished(self) -> None:
else:
if index in self.monitored_connections:
self.progress_complete.emit(index, response_code, "")
+ elif reply.operation() == QtNetwork.QNetworkAccessManager.HeadOperation:
+ self.content_length.emit(index, response_code, 0)
else:
self.completed.emit(index, response_code, None)
self.replies.pop(index)
diff --git a/Widgets/addonmanager_widget_addon_buttons.py b/Widgets/addonmanager_widget_addon_buttons.py
index 8ad8b1b6..dc487b6e 100644
--- a/Widgets/addonmanager_widget_addon_buttons.py
+++ b/Widgets/addonmanager_widget_addon_buttons.py
@@ -25,10 +25,11 @@
from enum import Enum, auto
import os
+from typing import List
from addonmanager_freecad_interface import translate
-from PySideWrapper import QtGui, QtWidgets
+from PySideWrapper import QtCore, QtGui, QtWidgets
class ButtonBarDisplayMode(Enum):
@@ -38,8 +39,15 @@ class ButtonBarDisplayMode(Enum):
class WidgetAddonButtons(QtWidgets.QWidget):
+
+ install_branch = QtCore.Signal(str)
+
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
+ self.setup_to_change_branch = False
+ self.is_addon_manager = False
+ self.actions = []
+ self.branch_menu = None
self.display_mode = ButtonBarDisplayMode.TextAndIcons
self._setup_ui()
self._set_icons()
@@ -53,6 +61,67 @@ def set_display_mode(self, mode: ButtonBarDisplayMode):
self._set_icons()
self.retranslateUi(None)
+ def set_can_check_for_updates(self, can_check_for_updates: bool):
+ """Only non-catalog addons have a separate update checker -- addons in the catalog don't
+ query their update status individually."""
+ self.update.setVisible(can_check_for_updates)
+
+ def set_installation_status(
+ self, installed: bool, available_branches: List[str], disabled: bool
+ ):
+ """Set up the buttons for a given installation status.
+ :param installed: Whether the addon is currently installed or not.
+ :param available_branches: The list of branches available -- cna be empty in which case it is
+ not presented to the user as an option to change.
+ :param disabled: Whether the addon is currently disabled."""
+ self.is_addon_manager = False
+ self.setup_to_change_branch = False
+ self.uninstall.setVisible(installed)
+ if not available_branches or len(available_branches) == 1 and not installed:
+ self.install.setVisible(not installed)
+ self.install.setMenu(None)
+ self.branch_menu = None
+ else:
+ self.install.setVisible(True)
+ self.branch_menu = QtWidgets.QMenu()
+ self.install.setMenu(self.branch_menu)
+ self.actions.clear()
+ if installed:
+ self.setup_to_change_branch = True
+ for branch in available_branches:
+ if hasattr(QtGui, "QAction"):
+ # Qt6
+ new_action = QtGui.QAction()
+ else:
+ # Qt5
+ new_action = QtWidgets.QAction()
+ new_action.setText(branch)
+ new_action.triggered.connect(self.action_activated)
+ self.actions.append(new_action)
+ self.branch_menu.addAction(new_action)
+
+ self.enable.setVisible(installed and disabled)
+ self.disable.setVisible(installed and not disabled)
+ self.retranslateUi(None)
+
+ def action_activated(self, _):
+ sender = self.sender()
+ if not sender:
+ return
+ if hasattr(sender, "text"):
+ self.install_branch.emit(sender.text())
+
+ def set_can_run(self, can_run: bool):
+ self.run_macro.setVisible(can_run)
+
+ def setup_for_addon_manager(self):
+ """If the addon in question is the Addon Manager itself, then we tweak some things: there
+ is no "disable" option, and "uninstall" becomes "revert to built-in"."""
+ self.disable.setVisible(False)
+ self.enable.setVisible(False)
+ self.is_addon_manager = True
+ self.retranslateUi(None)
+
def _setup_ui(self):
if self.layout():
self.setLayout(None) # TODO: Check this
@@ -65,7 +134,6 @@ def _setup_ui(self):
self.disable = QtWidgets.QPushButton(self)
self.update = QtWidgets.QPushButton(self)
self.run_macro = QtWidgets.QPushButton(self)
- self.change_branch = QtWidgets.QPushButton(self)
self.check_for_update = QtWidgets.QPushButton(self)
self.horizontal_layout.addWidget(self.back)
self.horizontal_layout.addStretch()
@@ -76,7 +144,6 @@ def _setup_ui(self):
self.horizontal_layout.addWidget(self.disable)
self.horizontal_layout.addWidget(self.update)
self.horizontal_layout.addWidget(self.run_macro)
- self.horizontal_layout.addWidget(self.change_branch)
self.setLayout(self.horizontal_layout)
def set_show_back_button(self, show: bool) -> None:
@@ -90,11 +157,18 @@ def _set_icons(self):
def retranslateUi(self, _):
self.check_for_update.setText(translate("AddonsInstaller", "Check for Update"))
- self.install.setText(translate("AddonsInstaller", "Install"))
- self.uninstall.setText(translate("AddonsInstaller", "Uninstall"))
+ if self.setup_to_change_branch:
+ self.install.setText(translate("AddonsInstaller", "Switch to branch"))
+ elif self.is_addon_manager:
+ self.install.setText(translate("AddonsInstaller", "Override built-in"))
+ else:
+ self.install.setText(translate("AddonsInstaller", "Install"))
self.disable.setText(translate("AddonsInstaller", "Disable"))
self.enable.setText(translate("AddonsInstaller", "Enable"))
self.update.setText(translate("AddonsInstaller", "Update"))
self.run_macro.setText(translate("AddonsInstaller", "Run"))
- self.change_branch.setText(translate("AddonsInstaller", "Change Branch…"))
self.back.setToolTip(translate("AddonsInstaller", "Return to Package List"))
+ if self.is_addon_manager:
+ self.uninstall.setText(translate("AddonsInstaller", "Revert to built-in"))
+ else:
+ self.uninstall.setText(translate("AddonsInstaller", "Uninstall"))
diff --git a/Widgets/addonmanager_widget_package_details_view.py b/Widgets/addonmanager_widget_package_details_view.py
index 105ba01b..d87f5007 100644
--- a/Widgets/addonmanager_widget_package_details_view.py
+++ b/Widgets/addonmanager_widget_package_details_view.py
@@ -202,30 +202,8 @@ def set_updated(self):
self.message_label.setStyleSheet("color:" + attention_color_string())
def _sync_ui_state(self):
- self._sync_button_state()
self._create_status_label_text()
- def _sync_button_state(self):
- self.button_bar.install.setVisible(not self.installed)
- self.button_bar.uninstall.setVisible(self.installed)
- if not self.installed:
- self.button_bar.disable.hide()
- self.button_bar.enable.hide()
- self.button_bar.update.hide()
- self.button_bar.check_for_update.hide()
- else:
- self.button_bar.update.setVisible(self.update_info.update_available)
- if self.update_info.detached_head:
- self.button_bar.check_for_update.hide()
- else:
- self.button_bar.check_for_update.setVisible(not self.update_info.update_available)
- if self.can_disable:
- self.button_bar.enable.setVisible(self.disabled)
- self.button_bar.disable.setVisible(not self.disabled)
- else:
- self.button_bar.enable.hide()
- self.button_bar.disable.hide()
-
def _create_status_label_text(self):
if self.installed:
installation_details = self._get_installation_details_string()
diff --git a/addonmanager_installation_manifest.py b/addonmanager_installation_manifest.py
new file mode 100644
index 00000000..f3097493
--- /dev/null
+++ b/addonmanager_installation_manifest.py
@@ -0,0 +1,177 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 The FreeCAD project association AISBL *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD 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. *
+# * *
+# * FreeCAD 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 FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+import json
+import os
+import threading
+import datetime
+from pathlib import Path
+from typing import Dict
+
+import addonmanager_freecad_interface as fci
+from Addon import Addon
+from AddonCatalog import AddonCatalog
+
+
+def most_recent_update(directory_path: str) -> datetime.datetime:
+ """Walk through a path and return the most recent update time."""
+ path = Path(directory_path)
+
+ if not path.exists():
+ raise FileNotFoundError(f"Directory not found: {path}")
+ if not path.is_dir():
+ raise NotADirectoryError(f"Path is not a directory: {path}")
+
+ latest_time = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc)
+ for file_path in path.rglob("*"):
+ if file_path.is_file():
+ try:
+ mtime = datetime.datetime.fromtimestamp(file_path.stat().st_mtime).astimezone()
+ if mtime > latest_time:
+ latest_time = mtime
+ except (PermissionError, OSError):
+ continue
+ return latest_time
+
+
+class InstallationManifest:
+ """The installation manifest tracks installation and updates of addons so that the Addon Manager
+ knows what branch is currently installed (this gets reflected in the change-branch UI for addons
+ that list multiple branches in the catalog). It also tracks the original installation time and
+ last updated time, which may eventually get used in the interface."""
+
+ lock = threading.Lock()
+
+ path_to_manifest_file = ""
+
+ def __init__(self, catalog: AddonCatalog = None):
+ self._manifest = {}
+ self.old_backups = []
+ self.unrecognized_directories = []
+ if not InstallationManifest.path_to_manifest_file:
+ InstallationManifest.path_to_manifest_file = os.path.join(
+ fci.DataPaths().mod_dir, "manifest.json"
+ )
+ if not os.path.exists(InstallationManifest.path_to_manifest_file):
+ self._migrate_to_manifest_file(catalog)
+ self.write_manifest()
+ else:
+ self.load_manifest()
+ if catalog:
+ self._scan_mod_path_for_extras(catalog)
+
+ def _migrate_to_manifest_file(self, catalog: AddonCatalog):
+ dirs_in_mod = os.listdir(fci.DataPaths().mod_dir)
+ for addon_id in dirs_in_mod:
+ branches = []
+ if catalog:
+ branches = catalog.get_available_branches(addon_id)
+ if branches:
+ branch_display_name = branches[0]
+ self._manifest[addon_id] = {
+ "addon_id": addon_id,
+ "migrated": True,
+ "first_installed": datetime.datetime.fromtimestamp(
+ 0, tz=datetime.timezone.utc
+ ).isoformat(),
+ "last_updated": most_recent_update(
+ os.path.join(fci.DataPaths().mod_dir, addon_id)
+ ).isoformat(),
+ "branch_display_name": branch_display_name,
+ "extra_files": [],
+ "freecad_version": "",
+ }
+
+ def load_manifest(self):
+ """Load the manifest from the disk"""
+ with InstallationManifest.lock:
+ with open(self.path_to_manifest_file, "r") as f:
+ self._manifest = json.load(f)
+
+ def write_manifest(self):
+ """Write the manifest to the disk"""
+ with InstallationManifest.lock:
+ with open(self.path_to_manifest_file, "w") as f:
+ json.dump(self._manifest, f, indent=2)
+
+ def _scan_mod_path_for_extras(self, catalog: AddonCatalog):
+ dirs_in_mod = os.listdir(fci.DataPaths().mod_dir)
+ for addon_id in dirs_in_mod:
+ if not os.path.isdir(os.path.join(fci.DataPaths().mod_dir, addon_id)):
+ continue
+ branches = catalog.get_available_branches(addon_id)
+ if not branches:
+ if "backup" in addon_id:
+ fci.Console.PrintMessage("Found old backup directory: " + addon_id + "\n")
+ self.old_backups.append(addon_id)
+ else:
+ fci.Console.PrintMessage(
+ "Found addon not in main AM catalog: " + addon_id + "\n"
+ )
+ self.unrecognized_directories.append(addon_id)
+
+ def record_new_installation(self, addon_id: str, addon: Addon, extra_files: list = None):
+ """Record the first installation of an addon.
+ :param addon_id: The addon ID
+ :param addon: The Addon object
+ :param extra_files: A list of extra files to record as installed
+ """
+ self._manifest[addon_id] = {
+ "addon_id": addon_id,
+ "migrated": False,
+ "first_installed": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ "branch_display_name": addon.branch_display_name,
+ "extra_files": [] if extra_files is None else extra_files,
+ "freecad_version": fci.Version()[0:3],
+ }
+ self.write_manifest()
+
+ def record_update(self, addon_id: str, addon: Addon, extra_files: list = None):
+ """Record the update of an addon that was already installed. Will raise an exception if the
+ addon is not already in the manifest.
+ :param addon_id: The addon ID
+ :param addon: The Addon object
+ :param extra_files: A list of extra files to record as installed (typically copied FCmacro files)
+ """
+ self._manifest[addon_id]["last_updated"] = datetime.datetime.now(
+ datetime.timezone.utc
+ ).isoformat()
+ self._manifest[addon_id]["branch_display_name"] = addon.branch_display_name
+ self._manifest[addon_id]["freecad_version"] = fci.Version()[0:3]
+ if extra_files:
+ all_extra_files = set(self._manifest[addon_id]["extra_files"])
+ all_extra_files.update(extra_files)
+ self._manifest[addon_id]["extra_files"] = list(all_extra_files)
+ self.write_manifest()
+
+ def remove(self, addon_id: str):
+ """Remove an addon from the manifest (that is, it was uninstalled -- we don't record the
+ uninstallation, it just gets dropped)."""
+ self._manifest.pop(addon_id, None)
+ self.write_manifest()
+
+ def contains(self, addon_id: str) -> bool:
+ return addon_id in self._manifest
+
+ def get_addon_info(self, addon_id: str) -> Dict:
+ return self._manifest[addon_id]
diff --git a/addonmanager_installer.py b/addonmanager_installer.py
index 95710b94..6d432ca1 100644
--- a/addonmanager_installer.py
+++ b/addonmanager_installer.py
@@ -40,6 +40,7 @@
from Addon import Addon
import addonmanager_utilities as utils
+from addonmanager_installation_manifest import InstallationManifest
from addonmanager_metadata import get_branch_from_metadata
from addonmanager_git import initialize_git, GitFailed
@@ -449,7 +450,17 @@ def _move_code_out_of_subdirectory(self, destination):
def _finalize_successful_installation(self):
"""Perform any necessary additional steps after installing the addon."""
self._update_metadata()
- self._install_macros()
+ extra_files = self._install_macros()
+ manifest = InstallationManifest()
+ if manifest.contains(self.addon_to_install.name):
+ manifest.record_update(
+ self.addon_to_install.name, self.addon_to_install, extra_files=extra_files
+ )
+ else:
+ manifest.record_new_installation(
+ self.addon_to_install.name, self.addon_to_install, extra_files=extra_files
+ )
+
self.success.emit(self.addon_to_install)
def _update_metadata(self):
@@ -463,7 +474,7 @@ def _update_metadata(self):
self.addon_to_install.installed_version = self.addon_to_install.metadata.version
self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml)
- def _install_macros(self):
+ def _install_macros(self) -> List[str]:
"""For any workbenches, copy FCMacro files into the macro directory. Exclude packages that
have preference packs, otherwise we will litter the macro directory with the pre and post
scripts."""
@@ -471,7 +482,7 @@ def _install_macros(self):
isinstance(self.addon_to_install, Addon)
and self.addon_to_install.contains_preference_pack()
):
- return
+ return []
if not os.path.exists(self.macro_installation_path):
os.makedirs(self.macro_installation_path)
@@ -503,6 +514,7 @@ def _install_macros(self):
)
for fcmacro_file in installed_macro_files:
f.write(fcmacro_file + "\n")
+ return installed_macro_files
@classmethod
def _validate_object(cls, addon: object):
@@ -585,6 +597,17 @@ def _write_installation_manifest(self, manifest):
)
fci.Console.PrintWarning(manifest_file)
+ # Update the primary manifest as well
+ manifest = InstallationManifest()
+ if manifest.contains(self.addon_to_install.name):
+ manifest.record_update(
+ self.addon_to_install.name, self.addon_to_install, extra_files=[]
+ )
+ else:
+ manifest.record_new_installation(
+ self.addon_to_install.name, self.addon_to_install, extra_files=[]
+ )
+
@classmethod
def _validate_object(cls, addon):
"""Make sure this object provides an attribute called "macro" with a method called
diff --git a/addonmanager_package_details_controller.py b/addonmanager_package_details_controller.py
index 104d2d23..58ec2718 100644
--- a/addonmanager_package_details_controller.py
+++ b/addonmanager_package_details_controller.py
@@ -39,7 +39,6 @@
from addonmanager_workers_startup import CheckSingleUpdateWorker
from addonmanager_git import GitManager, NoGitFound
from Addon import Addon
-from change_branch import ChangeBranchDialog
from addonmanager_readme_controller import ReadmeController
from Widgets.addonmanager_widget_package_details_view import UpdateInformation, WarningFlags
@@ -76,18 +75,32 @@ def __init__(self, widget=None):
self.ui.button_bar.install.clicked.connect(lambda: self.install.emit(self.addon))
self.ui.button_bar.uninstall.clicked.connect(lambda: self.uninstall.emit(self.addon))
self.ui.button_bar.update.clicked.connect(lambda: self.update.emit(self.addon))
- self.ui.button_bar.change_branch.clicked.connect(self.change_branch_clicked)
self.ui.button_bar.enable.clicked.connect(self.enable_clicked)
self.ui.button_bar.disable.clicked.connect(self.disable_clicked)
+ self.ui.button_bar.install_branch.connect(self.install_branch)
- def show_repo(self, repo: Addon) -> None:
+ def show_repo(self, addon: Addon) -> None:
"""The main entry point for this class shows the package details and related buttons
for the provided repo."""
- self.addon = repo
- self.readme_controller.set_addon(repo)
+ self.addon = addon
+ self.readme_controller.set_addon(addon)
self.original_disabled_state = self.addon.is_disabled()
- if repo is not None:
+ if addon is not None:
self.ui.button_bar.show()
+ branches = []
+ if addon.status() == Addon.Status.NOT_INSTALLED:
+ branches.append(addon.branch_display_name)
+ if addon.sub_addons:
+ branches.extend(addon.sub_addons.keys())
+ self.ui.button_bar.set_installation_status(
+ installed=addon.status() != Addon.Status.NOT_INSTALLED,
+ available_branches=branches,
+ disabled=addon.is_disabled(),
+ )
+ self.ui.button_bar.set_can_run(addon.contains_macro())
+ self.ui.button_bar.set_can_check_for_updates(True if addon.cache_directory else False)
+ if addon.name == "AddonManager":
+ self.ui.button_bar.setup_for_addon_manager() # Must happen AFTER other config steps
else:
self.ui.button_bar.hide()
@@ -98,8 +111,8 @@ def show_repo(self, repo: Addon) -> None:
installed = self.addon.status() != Addon.Status.NOT_INSTALLED
self.ui.set_installed(installed)
- if repo.metadata is not None:
- self.ui.set_url(get_repo_url_from_metadata(repo.metadata))
+ if addon.metadata is not None:
+ self.ui.set_url(get_repo_url_from_metadata(addon.metadata))
else:
self.ui.set_url(None) # to reset it and hide it
update_info = UpdateInformation()
@@ -107,33 +120,28 @@ def show_repo(self, repo: Addon) -> None:
update_info.unchecked = self.addon.status() == Addon.Status.UNCHECKED
update_info.update_available = self.addon.status() == Addon.Status.UPDATE_AVAILABLE
update_info.check_in_progress = False # TODO: Implement the "check in progress" status
- if repo.metadata:
- update_info.branch = get_branch_from_metadata(repo.metadata)
- update_info.version = str(repo.metadata.version)
- elif repo.macro:
- update_info.version = str(repo.macro.version)
+ if addon.metadata:
+ update_info.branch = get_branch_from_metadata(addon.metadata)
+ update_info.version = str(addon.metadata.version)
+ elif addon.macro:
+ update_info.version = str(addon.macro.version)
self.ui.set_update_available(update_info)
self.ui.set_location(
self.addon.macro_directory
- if repo.repo_type == Addon.Kind.MACRO
+ if addon.repo_type == Addon.Kind.MACRO
else os.path.join(self.addon.mod_directory, self.addon.name)
)
self.ui.set_disabled(self.addon.is_disabled())
- self.ui.allow_running(repo.repo_type == Addon.Kind.MACRO)
- self.ui.allow_disabling(repo.repo_type != Addon.Kind.MACRO)
+ self.ui.allow_running(addon.repo_type == Addon.Kind.MACRO)
+ self.ui.allow_disabling(addon.repo_type != Addon.Kind.MACRO)
- if repo.status() == Addon.Status.UNCHECKED:
- self.ui.button_bar.check_for_update.show()
- self.ui.button_bar.check_for_update.setText(
- translate("AddonsInstaller", "Check for Update")
- )
- self.ui.button_bar.check_for_update.setEnabled(True)
+ if addon.status() == Addon.Status.UNCHECKED:
if not self.update_check_thread:
self.update_check_thread = QtCore.QThread()
self.update_check_thread.setObjectName(
"PackageDetailsController update check thread"
)
- self.check_for_update_worker = CheckSingleUpdateWorker(repo)
+ self.check_for_update_worker = CheckSingleUpdateWorker(addon)
self.check_for_update_worker.moveToThread(self.update_check_thread)
self.update_check_thread.finished.connect(self.check_for_update_worker.deleteLater)
self.ui.button_bar.check_for_update.clicked.connect(
@@ -145,69 +153,7 @@ def show_repo(self, repo: Addon) -> None:
self.ui.button_bar.check_for_update.hide()
flags = WarningFlags()
- flags.required_freecad_version = self.requires_newer_freecad()
self.ui.set_warning_flags(flags)
- self.set_change_branch_button_state()
-
- def requires_newer_freecad(self) -> Optional[Version]:
- """If the current package is not installed, returns the first supported version of
- FreeCAD, if one is set, or None if no information is available (or if the package is
- already installed)."""
-
- # If it's not installed, check to see if it's for a newer version of FreeCAD
- if self.addon.status() == Addon.Status.NOT_INSTALLED and self.addon.metadata:
- # Only hide if ALL content items require a newer version, otherwise
- # it's possible that this package actually provides versions of itself
- # for newer and older versions
-
- first_supported_version = get_first_supported_freecad_version(self.addon.metadata)
- if first_supported_version is not None:
- fc_version = Version(from_list=fci.Version())
- if first_supported_version > fc_version:
- 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 git is available."""
-
- self.ui.button_bar.change_branch.hide()
-
- pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
- show_switcher = pref.GetBool("ShowBranchSwitcher", False)
- if not show_switcher:
- return
-
- # Is this repo installed? If not, return.
- if self.addon.status() == Addon.Status.NOT_INSTALLED:
- return
-
- # Is it a Macro? If so, return:
- if self.addon.repo_type == Addon.Kind.MACRO:
- return
-
- # Can we actually switch branches? If not, return.
- if not self.git_manager:
- return
-
- # Is there a .git subdirectory? If not, return.
- basedir = fci.getUserAppDataDir()
- path_to_git = os.path.join(basedir, "Mod", self.addon.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.button_bar.change_branch.show()
- self.ui.button_bar.change_branch.show()
-
- def change_branch_clicked(self) -> None:
- """Loads the branch-switching dialog"""
- basedir: str = fci.getUserAppDataDir()
- path_to_repo = os.path.join(basedir, "Mod", self.addon.name)
- change_branch_dialog = ChangeBranchDialog(path_to_repo, self.ui)
- change_branch_dialog.branch_changed.connect(self.branch_changed)
- change_branch_dialog.exec()
def enable_clicked(self) -> None:
"""Called by the Enable button, enables this Addon and updates GUI to reflect
@@ -235,6 +181,21 @@ def disable_clicked(self) -> None:
self.addon.set_status(self.original_status)
self.update_status.emit(self.addon)
+ def install_branch(self, branch: str):
+ if self.addon.branch_display_name == branch:
+ fci.Console.PrintMessage(
+ f"Installing active branch {branch} for {self.addon.display_name}\n"
+ )
+ self.install.emit(self.addon)
+ return
+ if branch not in self.addon.sub_addons:
+ fci.Console.PrintError(
+ f"Internal error: branch {branch} not found in sub_addons list for addon {self.addon.display_name}.\n"
+ )
+ return
+ fci.Console.PrintMessage(f"Installing sub-branch {branch} for {self.addon.display_name}\n")
+ self.install.emit(self.addon.sub_addons[branch])
+
def branch_changed(self, old_branch: str, name: str) -> None:
"""Displays a dialog confirming the branch changed, and tries to access the
metadata file from that branch."""
diff --git a/addonmanager_preferences_defaults.json b/addonmanager_preferences_defaults.json
index ea3ad661..94ccd498 100644
--- a/addonmanager_preferences_defaults.json
+++ b/addonmanager_preferences_defaults.json
@@ -31,5 +31,6 @@
"last_fetched_addon_catalog_cache_hash": "Cache never fetched, no hash available",
"last_fetched_macro_cache_hash": "Cache never fetched, no hash available",
"macro_cache_url": "https://addons.freecad.org/macro_cache.zip",
+ "old_backup_handling": "ask",
"readWarning2022": false
}
diff --git a/addonmanager_readme_controller.py b/addonmanager_readme_controller.py
index d7f43260..fe90623e 100644
--- a/addonmanager_readme_controller.py
+++ b/addonmanager_readme_controller.py
@@ -32,6 +32,7 @@
from typing import Optional
import NetworkManager
+from addonmanager_metadata import UrlType
translate = fci.translate
@@ -84,6 +85,28 @@ def set_addon(self, repo: Addon):
return
else:
self.url = utils.get_readme_url(repo)
+ if self.addon.metadata and self.addon.metadata.url:
+ for url in self.addon.metadata.url:
+ if url.type == UrlType.readme:
+ if self.url != url.location:
+ fci.Console.PrintLog("README url does not match expected location\n")
+ fci.Console.PrintLog(f"Expected: {self.url}\n")
+ fci.Console.PrintLog(f"package.xml contents: {url.location}\n")
+ fci.Console.PrintLog(
+ "Note to addon devs: package.xml now expects a"
+ " url to the raw MD data, now that Qt can render"
+ " it without having it transformed to HTML.\n"
+ )
+ self.url = url.location
+ if "/blob/" in self.url:
+ fci.Console.PrintLog("Attempting to replace 'blob' with 'raw'...\n")
+ self.url = self.url.replace("/blob/", "/raw/")
+ elif "/src/" in self.url and "codeberg" in self.url:
+ fci.Console.PrintLog(
+ "Attempting to replace 'src' with 'raw' in codeberg URL..."
+ )
+ self.url = self.url.replace("/src/", "/raw/")
+
self.widget.setUrl(self.url)
self.widget.setText(
diff --git a/addonmanager_uninstaller.py b/addonmanager_uninstaller.py
index 9066511b..c788b016 100644
--- a/addonmanager_uninstaller.py
+++ b/addonmanager_uninstaller.py
@@ -31,6 +31,7 @@
import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
from Addon import Addon
+from addonmanager_installation_manifest import InstallationManifest
try:
from PySide import QtCore # Use the FreeCAD wrapper
@@ -132,6 +133,8 @@ def run(self) -> bool:
"Could not find addon {} to remove it.",
).format(self.addon_to_remove.name)
if success:
+ manifest = InstallationManifest()
+ manifest.remove(self.addon_to_remove.name)
self.success.emit(self.addon_to_remove)
else:
self.failure.emit(self.addon_to_remove, error_message)
diff --git a/addonmanager_update_all_gui.py b/addonmanager_update_all_gui.py
index cacb2100..02059d8b 100644
--- a/addonmanager_update_all_gui.py
+++ b/addonmanager_update_all_gui.py
@@ -208,6 +208,7 @@ def _setup_main_dialog(self):
self.dialog.setObjectName("AddonManager_UpdateAllDialog")
self.dialog.table_view.setModel(self.model)
self.dialog.update_button.clicked.connect(self.update_button_clicked)
+ self.dialog.button_box.rejected.connect(self.finished.emit)
self.dialog.table_view.horizontalHeader().setStretchLastSection(False)
self.dialog.table_view.horizontalHeader().setSectionResizeMode(
diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py
index 815e6746..e3479b9b 100644
--- a/addonmanager_workers_startup.py
+++ b/addonmanager_workers_startup.py
@@ -33,6 +33,7 @@
import zipfile
from PySideWrapper import QtCore
+from addonmanager_installation_manifest import InstallationManifest
from addonmanager_macro import Macro
from Addon import Addon
@@ -45,7 +46,6 @@
translate = fci.translate
-# Workers only have one public method by design
# pylint: disable=c-extension-no-member, too-few-public-methods, too-many-instance-attributes
@@ -55,6 +55,7 @@ class CreateAddonListWorker(QtCore.QThread):
addon_repo = QtCore.Signal(object)
progress_made = QtCore.Signal(str, int, int)
+ old_backups_found = QtCore.Signal(object)
MAX_ATTEMPTS = 3
RETRY_DELAY_SECONDS = 3
@@ -226,13 +227,14 @@ def new_cache_available(cache_name: str) -> bool:
def process_addon_cache(self, catalog_text_data):
catalog = AddonCatalog(json.loads(catalog_text_data))
+ manifest = InstallationManifest(catalog)
for addon_id in catalog.get_available_addon_ids():
if addon_id in self.package_names:
# We already have something with this name, skip this one
fci.Console.PrintWarning(
translate(
"AddonsInstaller",
- "WARNING: User-provided custom addon {} is overriding the one in the official addon catalog\n",
+ "WARNING: Custom addon '{}' is overriding the one in the official addon catalog\n",
).format(addon_id)
)
continue
@@ -240,19 +242,56 @@ def process_addon_cache(self, catalog_text_data):
branches = catalog.get_available_branches(addon_id)
if not branches:
fci.Console.PrintWarning(
- f"Failed to find any compatible branches for {addon_id}. This is an internal error, please report it to the developers.\n"
+ f"Failed to find any compatible branches for '{addon_id}'. This is an internal error, please report it to the developers.\n"
)
continue
- main = branches[0]
- # TODO: add multiple branch information to the Addon class
- try:
- addon = catalog.get_addon_from_id(addon_id, main[0])
- self.addon_repo.emit(addon)
- except Exception as e:
+
+ installed_branch_name = None
+ if manifest.contains(addon_id):
+ # Then this addon is currently installed: make sure we use the correct branch
+ installed_branch_name = manifest.get_addon_info(addon_id)["branch_display_name"]
+ fci.Console.PrintLog(
+ f"Found installed addon '{addon_id}' with branch '{installed_branch_name}'\n"
+ )
+ addon_instances = {}
+ name_of_first_entry = None
+ for branch_display_name in branches:
+ try:
+ addon = catalog.get_addon_from_id(addon_id, branch_display_name)
+ addon_instances[branch_display_name] = addon
+ if name_of_first_entry is None:
+ name_of_first_entry = branch_display_name
+ else:
+ fci.Console.PrintMessage(
+ f"Found additional branch '{branch_display_name}' for addon {addon_id}\n"
+ )
+ except Exception as e:
+ # Any exception that occurs gets absorbed here in an attempt to find a working
+ # branch. Only if all proposed branches fail does this become an error that
+ # causes us to skip the addon
+ fci.Console.PrintWarning(
+ f"Could not load branch '{branch_display_name}' for addon {addon_id}: {str(e)}\n"
+ )
+ continue
+ if name_of_first_entry is None:
fci.Console.PrintError(
f"Failed to load the addon {addon_id} from the addon catalog, skipping it.\n"
)
- fci.Console.PrintError(str(e) + "\n")
+ continue
+ primary_branch_name = (
+ installed_branch_name if installed_branch_name else name_of_first_entry
+ )
+ for branch_display_name in branches:
+ if branch_display_name != primary_branch_name:
+ # Only add non-primary addons to the sub_addons list so that the primary addon
+ # doesn't list *itself* as a sub-addon
+ addon_instances[primary_branch_name].sub_addons[branch_display_name] = (
+ addon_instances[branch_display_name]
+ )
+ self.addon_repo.emit(addon_instances[primary_branch_name])
+
+ if manifest.old_backups:
+ self.old_backups_found.emit(manifest.old_backups)
def process_macro_cache(self, catalog_text_data):
cache_object: dict = json.loads(catalog_text_data)
diff --git a/change_branch.py b/change_branch.py
deleted file mode 100644
index 3d2c6e5f..00000000
--- a/change_branch.py
+++ /dev/null
@@ -1,316 +0,0 @@
-# SPDX-License-Identifier: LGPL-2.1-or-later
-# ***************************************************************************
-# * *
-# * Copyright (c) 2022-2025 The FreeCAD Project Association AISBL *
-# * *
-# * This file is part of FreeCAD. *
-# * *
-# * FreeCAD 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. *
-# * *
-# * FreeCAD 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 FreeCAD. If not, see *
-# * . *
-# * *
-# ***************************************************************************
-
-"""The Change Branch dialog and utility classes and methods"""
-
-import os
-from typing import Dict
-
-import addonmanager_freecad_interface as fci
-import addonmanager_utilities as utils
-from Widgets.addonmanager_utility_dialogs import MessageDialog
-
-from addonmanager_git import initialize_git, GitFailed
-
-try:
- from PySide import QtWidgets, QtCore
-except ImportError:
- try:
- from PySide6 import QtWidgets, QtCore
- except ImportError:
- from PySide2 import QtWidgets, QtCore # pylint: disable=deprecated-module
-
-translate = fci.translate
-
-
-class ChangeBranchDialog(QtWidgets.QWidget):
- """A dialog that displays available git branches and allows the user to select one to change
- to. Includes code that does that change, as well as some modal dialogs to warn them of the
- possible consequences and display various error messages."""
-
- branch_changed = QtCore.Signal(str, str)
-
- def __init__(self, path: str, parent=None):
- super().__init__(parent)
-
- self.ui = utils.loadUi(os.path.join(os.path.dirname(__file__), "change_branch.ui"))
- self.ui.setObjectName("AddonManager_ChangeBranchDialog")
-
- 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(
- 2, QtCore.Qt.DescendingOrder
- ) # Default to sorting by remote last-changed date
-
- # Figure out what row gets selected:
- git_manager = initialize_git()
- if git_manager is None:
- return
-
- row = 0
- self.current_ref = git_manager.current_branch(path)
- selection_model = self.ui.tableView.selectionModel()
- for ref in self.item_model.branches:
- if ref["ref_name"] == self.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)
- break
- row += 1
-
- # Make sure the column widths are OK:
- header = self.ui.tableView.horizontalHeader()
- header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
-
- def exec(self):
- """Run the Change Branch dialog and its various sub-dialogs. May result in the branch
- being changed. Code that cares if that happens should connect to the branch_changed
- signal."""
- 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["ref_name"] == self.item_model.current_branch:
- # This is the one we are already on... just return
- return
-
- result = MessageDialog.show_modal(
- MessageDialog.DialogType.ERROR,
- "AddonManager_DeveloperFeatureDialog",
- translate("AddonsInstaller", "DANGER: Developer feature"),
- translate(
- "AddonsInstaller",
- "DANGER: Switching branches is intended for developers and beta testers, "
- "and may result in broken, non-backwards compatible documents, instability, "
- "crashes, and/or the premature heat death of the universe. Are you sure you "
- "want to continue?",
- ),
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel,
- parent=self,
- )
- if result == QtWidgets.QMessageBox.Cancel:
- return
- if self.item_model.dirty:
- result = MessageDialog.show_modal(
- MessageDialog.DialogType.ERROR,
- "AddonManager_LocalChangesDialog",
- 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,
- parent=self,
- )
- if result == QtWidgets.QMessageBox.Cancel:
- return
-
- self.change_branch(self.item_model.path, ref)
-
- def change_branch(self, path: str, ref: Dict[str, str]) -> None:
- """Change the git clone in `path` to git ref `ref`. Emits the branch_changed signal
- on success."""
- remote_name = ref["ref_name"]
- _, _, local_name = ref["ref_name"].rpartition("/")
- gm = initialize_git()
- if gm is None:
- self._show_no_git_dialog()
- return
-
- try:
- if ref["upstream"]:
- gm.checkout(path, remote_name)
- else:
- gm.checkout(path, remote_name, args=["-b", local_name])
- self.branch_changed.emit(self.current_ref, local_name)
- except GitFailed:
- self._show_git_failed_dialog()
-
- def _show_no_git_dialog(self):
- MessageDialog.show_modal(
- MessageDialog.DialogType.ERROR,
- "AddonManager_CannotFindGitDialog",
- translate("AddonsInstaller", "Cannot find git"),
- translate(
- "AddonsInstaller",
- "Could not find git executable: cannot change branch",
- ),
- QtWidgets.QMessageBox.Ok,
- parent=self,
- )
-
- def _show_git_failed_dialog(self):
- MessageDialog.show_modal(
- MessageDialog.DialogType.ERROR,
- "AddonManager_GitOperationFailedDialog",
- translate("AddonsInstaller", "git operation failed"),
- translate(
- "AddonsInstaller",
- "Git returned an error code when attempting to change branch. There may be "
- "more details in the Report View.",
- ),
- QtWidgets.QMessageBox.Ok,
- parent=self,
- )
-
-
-class ChangeBranchDialogModel(QtCore.QAbstractTableModel):
- """The data for the dialog comes from git: this model handles the git interactions and
- returns branch information as its rows. Use user data in the RefAccessRole to get information
- about the git refs. RefAccessRole data is a dictionary defined by the GitManager class as the
- results of a `get_branches_with_info()` call."""
-
- branches = []
- DataSortRole = QtCore.Qt.UserRole
- RefAccessRole = QtCore.Qt.UserRole + 1
-
- def __init__(self, path: str, parent=None) -> None:
- super().__init__(parent)
-
- gm = initialize_git()
- self.path = path
- self.branches = gm.get_branches_with_info(path)
- self.current_branch = gm.current_branch(path)
- self.dirty = gm.dirty(path)
- self._remove_tracking_duplicates()
-
- def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
- """Returns the number of rows in the model, e.g. the number of branches."""
- if parent.isValid():
- return 0
- return len(self.branches)
-
- def columnCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
- """Returns the number of columns in the model, e.g. the number of entries in the git ref
- structure (currently 3, 'ref_name', 'upstream', and 'date')."""
- if parent.isValid():
- return 0
- return 3 # Local name, remote name, date
-
- def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.DisplayRole):
- """The data access method for this model. Supports four roles: ToolTipRole, DisplayRole,
- DataSortRole, and RefAccessRole."""
- if not index.isValid():
- return None
- row = index.row()
- column = index.column()
- if role == QtCore.Qt.ToolTipRole:
- return self.branches[row]["author"] + ": " + self.branches[row]["subject"]
- if role == QtCore.Qt.DisplayRole:
- return self._data_display_role(column, row)
- if role == ChangeBranchDialogModel.DataSortRole:
- return self._data_sort_role(column, row)
- if role == ChangeBranchDialogModel.RefAccessRole:
- return self.branches[row]
- return None
-
- def _data_display_role(self, column, row):
- dd = self.branches[row]
- if column == 2:
- if dd["date"] is not None:
- q_date = QtCore.QDateTime.fromString(dd["date"], QtCore.Qt.DateFormat.RFC2822Date)
- return QtCore.QLocale().toString(q_date, QtCore.QLocale.ShortFormat)
- return None
- if column == 0:
- return dd["ref_name"]
- if column == 1:
- return dd["upstream"]
- return None
-
- def _data_sort_role(self, column, row):
- if column == 2:
- if self.branches[row]["date"] is not None:
- q_date = QtCore.QDateTime.fromString(
- self.branches[row]["date"], QtCore.Qt.DateFormat.RFC2822Date
- )
- return q_date
- return None
- if column == 0:
- return self.branches[row]["ref_name"]
- if column == 1:
- return self.branches[row]["upstream"]
- return None
-
- def headerData(
- self,
- section: int,
- orientation: QtCore.Qt.Orientation,
- role: int = QtCore.Qt.DisplayRole,
- ):
- """Returns the header information for the data in this model."""
- if orientation == QtCore.Qt.Vertical:
- return None
- if role != QtCore.Qt.DisplayRole:
- return None
- if section == 0:
- return translate(
- "AddonsInstaller",
- "Local",
- "Table header for local git ref name",
- )
- if section == 1:
- return translate(
- "AddonsInstaller",
- "Remote tracking",
- "Table header for git remote tracking branch name",
- )
- if section == 2:
- return translate(
- "AddonsInstaller",
- "Last Updated",
- "Table header for git update date",
- )
- return None
-
- def _remove_tracking_duplicates(self):
- remote_tracking_branches = []
- branches_to_keep = []
- for branch in self.branches:
- if branch["upstream"]:
- remote_tracking_branches.append(branch["upstream"])
- for branch in self.branches:
- if (
- "HEAD" not in branch["ref_name"]
- and branch["ref_name"] not in remote_tracking_branches
- ):
- branches_to_keep.append(branch)
- self.branches = branches_to_keep
-
-
-class ChangeBranchDialogFilter(QtCore.QSortFilterProxyModel):
- """Uses the DataSortRole in the model to provide a comparison method to sort the data."""
-
- def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex):
- """Compare two git refs according to the DataSortRole in the model."""
- left_data = self.sourceModel().data(left, ChangeBranchDialogModel.DataSortRole)
- right_data = self.sourceModel().data(right, ChangeBranchDialogModel.DataSortRole)
- if left_data is None or right_data is None:
- return right_data is not None
- return left_data < right_data
diff --git a/change_branch.ui b/change_branch.ui
deleted file mode 100644
index 8a79b38a..00000000
--- a/change_branch.ui
+++ /dev/null
@@ -1,105 +0,0 @@
-
-
- change_branch
-
-
-
- 0
- 0
- 550
- 300
-
-
-
- Change Branch
-
-
- true
-
-
- -
-
-
- Change to branch
-
-
- true
-
-
-
- -
-
-
- QAbstractItemView::NoEditTriggers
-
-
- false
-
-
- true
-
-
- QAbstractItemView::SingleSelection
-
-
- QAbstractItemView::SelectRows
-
-
- true
-
-
- true
-
-
- true
-
-
-
- -
-
-
- Qt::Horizontal
-
-
- QDialogButtonBox::Cancel|QDialogButtonBox::Ok
-
-
-
-
-
-
-
-
- buttonBox
- accepted()
- change_branch
- accept()
-
-
- 248
- 254
-
-
- 157
- 274
-
-
-
-
- buttonBox
- rejected()
- change_branch
- reject()
-
-
- 316
- 260
-
-
- 286
- 274
-
-
-
-
-
diff --git a/composite_view.py b/composite_view.py
index d0f99955..4d80c522 100644
--- a/composite_view.py
+++ b/composite_view.py
@@ -139,14 +139,12 @@ def addon_selected(self, addon):
self.scroll_position = (
self.package_list.ui.listPackages.verticalScrollBar().sliderPosition()
)
- print(f"Saved slider position at {self.scroll_position}")
self.package_list.hide()
self.package_details.show()
self.package_details.button_bar.set_show_back_button(True)
def _back_button_clicked(self):
if self.display_style != AddonManagerDisplayStyle.COMPOSITE:
- print(f"Set slider position to {self.scroll_position}")
self.package_list.show()
self.package_details.hide()
# The following must be done *after* a cycle through the event loop
diff --git a/package.xml b/package.xml
index 1a697323..e2ab19e6 100644
--- a/package.xml
+++ b/package.xml
@@ -5,8 +5,8 @@
Addon Manager
Tool to install workbenches, macros, themes, etc.
Resources/icons/addon_manager.svg
- 2025.08.05
- 2025-08-05
+ 2025.08.08
+ 2025-08-08
Chris Hennes
Yorik van Havre
Jonathan Wiedemann
diff --git a/package_list.py b/package_list.py
index ec5d615d..35a6ca41 100644
--- a/package_list.py
+++ b/package_list.py
@@ -242,6 +242,17 @@ def append_item(self, repo: Addon) -> None:
self.repos.append(repo)
self.endInsertRows()
+ def remove_item(self, repo: Addon) -> None:
+ if repo not in self.repos:
+ # Nothing to remove, don't care
+ return
+ with self.write_lock:
+ self.beginRemoveRows(
+ QtCore.QModelIndex(), self.repos.index(repo), self.repos.index(repo)
+ )
+ self.repos.remove(repo)
+ self.endRemoveRows()
+
def clear(self) -> None:
"""Clear the model, removing all rows. Thread safe."""
if self.rowCount() > 0: