diff --git a/panels/dock/taskmanager/CMakeLists.txt b/panels/dock/taskmanager/CMakeLists.txt index 37aa7a1a4..a23567751 100644 --- a/panels/dock/taskmanager/CMakeLists.txt +++ b/panels/dock/taskmanager/CMakeLists.txt @@ -78,6 +78,8 @@ add_library(dock-taskmanager SHARED ${DBUS_INTERFACES} hoverpreviewproxymodel.h taskmanager.cpp taskmanager.h + foldermenuregistry.cpp + foldermenuregistry.h treelandwindow.cpp treelandwindow.h treelandwindowmonitor.cpp diff --git a/panels/dock/taskmanager/foldermenuregistry.cpp b/panels/dock/taskmanager/foldermenuregistry.cpp new file mode 100644 index 000000000..458dab261 --- /dev/null +++ b/panels/dock/taskmanager/foldermenuregistry.cpp @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "foldermenuregistry.h" + +#include +#include +#include + +namespace dock { +namespace { +const QString OpenFolder = QStringLiteral("open-folder"); +const QString UndockFolder = QStringLiteral("undock-folder"); +} + +FolderMenuRegistry::FolderMenuRegistry(QObject *parent) : QObject(parent) {} + +bool FolderMenuRegistry::registerProvider(const QString &folderType, QObject *provider) +{ + if (folderType.trimmed().isEmpty() || !provider || provider->thread() != thread()) return false; + const auto meta = provider->metaObject(); + const int actions = meta->indexOfMethod("folderMenuActions(QString)"); + const int trigger = meta->indexOfMethod("triggerFolderMenuAction(QString,QString)"); + if (actions < 0 || trigger < 0 || meta->method(actions).returnType() != QMetaType::QVariantList + || meta->method(trigger).returnType() != QMetaType::Void) return false; + // A different plugin may not silently replace a registered source. + const auto existing = m_providers.value(folderType); + if (existing && existing != provider) return false; + m_providers.insert(folderType, provider); + return true; +} + +void FolderMenuRegistry::unregisterProvider(const QString &folderType, QObject *provider) +{ + if (provider && m_providers.value(folderType) == provider) m_providers.remove(folderType); +} + +QVariantList FolderMenuRegistry::standardItems(const QString &folderName) +{ + return {QVariantMap{{"id", OpenFolder}, {"name", folderName}}, + QVariantMap{{"id", UndockFolder}, {"name", QCoreApplication::translate("FolderContextMenu", "Undock")}}}; +} + +QVariantList FolderMenuRegistry::customItems(QObject *provider, const QString &folderId) const +{ + QVariantList supplied; + if (!provider || folderId.isEmpty() + || !QMetaObject::invokeMethod(provider, "folderMenuActions", Qt::DirectConnection, + Q_RETURN_ARG(QVariantList, supplied), Q_ARG(QString, folderId))) return {}; + QVariantList items; + QSet used{OpenFolder, UndockFolder}; + for (const auto &value : supplied) { + const auto item = value.toMap(); + const QString id = item.value("id").toString(); + const QString name = item.value("name").toString(); + if (id.trimmed().isEmpty() || name.trimmed().isEmpty() || used.contains(id) + || !item.value("visible", true).toBool()) continue; + used.insert(id); + const bool checkable = item.value("checkable", false).toBool(); + items.append(QVariantMap{{"id", id}, {"name", name}, + {"enabled", item.value("enabled", true).toBool()}, + {"checkable", checkable}, + {"checked", checkable && item.value("checked", false).toBool()}}); + } + return items; +} + +QVariantList FolderMenuRegistry::menuItems(const QString &folderType, const QString &folderId, const QString &folderName) const +{ + auto items = standardItems(folderName); + items.append(customItems(m_providers.value(folderType), folderId)); + return items; +} + +bool FolderMenuRegistry::trigger(const QString &folderType, const QString &folderId, const QString &actionId) const +{ + const QPointer provider = m_providers.value(folderType); + if (!provider || actionId == OpenFolder || actionId == UndockFolder) return false; + // Revalidate at click time: a plugin may unload or disable an action while + // its native menu is open. Never dispatch arbitrary or stale action IDs. + const auto items = customItems(provider, folderId); + if (!provider || m_providers.value(folderType) != provider) return false; + for (const auto &value : items) { + const auto item = value.toMap(); + if (item.value("id").toString() != actionId || !item.value("enabled").toBool()) continue; + return QMetaObject::invokeMethod(provider, "triggerFolderMenuAction", Qt::DirectConnection, + Q_ARG(QString, folderId), Q_ARG(QString, actionId)); + } + return false; +} +} diff --git a/panels/dock/taskmanager/foldermenuregistry.h b/panels/dock/taskmanager/foldermenuregistry.h new file mode 100644 index 000000000..93ce5f5a4 --- /dev/null +++ b/panels/dock/taskmanager/foldermenuregistry.h @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +namespace dock { + +// Optional menu extensions owned by folder-source plugins. Providers remain +// owned by their plugin and expose the two invokables documented in docs/. +class FolderMenuRegistry : public QObject +{ + Q_OBJECT +public: + explicit FolderMenuRegistry(QObject *parent = nullptr); + + bool registerProvider(const QString &folderType, QObject *provider); + void unregisterProvider(const QString &folderType, QObject *provider); + static QVariantList standardItems(const QString &folderName); + QVariantList menuItems(const QString &folderType, const QString &folderId, const QString &folderName) const; + bool trigger(const QString &folderType, const QString &folderId, const QString &actionId) const; + +private: + QVariantList customItems(QObject *provider, const QString &folderId) const; + QHash> m_providers; +}; + +} diff --git a/tests/panels/dock/taskmanager/CMakeLists.txt b/tests/panels/dock/taskmanager/CMakeLists.txt index 57a5bfbd0..2131b6770 100644 --- a/tests/panels/dock/taskmanager/CMakeLists.txt +++ b/tests/panels/dock/taskmanager/CMakeLists.txt @@ -49,3 +49,13 @@ target_include_directories(rolegroupmodel_tests PRIVATE ) gtest_discover_tests(rolegroupmodel_tests) + +# Folder-source menu extensions are tested without a desktop or applet service. +add_executable(foldermenuregistry_tests + foldermenuregistrytests.cpp + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager/foldermenuregistry.cpp + ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager/foldermenuregistry.h +) +target_include_directories(foldermenuregistry_tests PRIVATE ${CMAKE_SOURCE_DIR}/panels/dock/taskmanager) +target_link_libraries(foldermenuregistry_tests PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test) +add_test(NAME folder_menu_registry COMMAND foldermenuregistry_tests) diff --git a/tests/panels/dock/taskmanager/foldermenuregistrytests.cpp b/tests/panels/dock/taskmanager/foldermenuregistrytests.cpp new file mode 100644 index 000000000..e2260dc4b --- /dev/null +++ b/tests/panels/dock/taskmanager/foldermenuregistrytests.cpp @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "foldermenuregistry.h" + +#include +#include + +class TestFolderMenuProvider : public QObject +{ + Q_OBJECT +public: + QVariantList actions{QVariantMap{{"id", "details"}, {"name", "Details"}}}; + QString requestedFolder; + QString triggeredFolder; + QString triggeredAction; + int triggerCount = 0; + Q_INVOKABLE QVariantList folderMenuActions(const QString &folderId) + { + requestedFolder = folderId; + return actions; + } + Q_INVOKABLE void triggerFolderMenuAction(const QString &folderId, const QString &actionId) + { + triggeredFolder = folderId; + triggeredAction = actionId; + ++triggerCount; + } +}; + +class FolderMenuRegistryTest : public QObject +{ + Q_OBJECT +private slots: + void defaultMenuOnlyContainsNameAndUndock() + { + dock::FolderMenuRegistry registry; + const auto items = registry.menuItems("launcher", "internal/folders/12", "My folder"); + QCOMPARE(items.size(), 2); + QCOMPARE(items[0].toMap().value("id").toString(), "open-folder"); + QCOMPARE(items[0].toMap().value("name").toString(), "My folder"); + QCOMPARE(items[1].toMap().value("id").toString(), "undock-folder"); + QVERIFY(!registry.trigger("launcher", "internal/folders/12", "details")); + } + void customActionsAreAppendedAndDispatchedToTheirSource() + { + dock::FolderMenuRegistry registry; + TestFolderMenuProvider launcher, files; + QVERIFY(registry.registerProvider("launcher", &launcher)); + QVERIFY(registry.registerProvider("files", &files)); + const auto items = registry.menuItems("launcher", "internal/folders/12", "My folder"); + QCOMPARE(items.size(), 3); + QCOMPARE(items[2].toMap().value("id").toString(), "details"); + QCOMPARE(launcher.requestedFolder, "internal/folders/12"); + QVERIFY(registry.trigger("launcher", "internal/folders/12", "details")); + QCOMPARE(launcher.triggeredFolder, "internal/folders/12"); + QCOMPARE(launcher.triggeredAction, "details"); + QCOMPARE(files.triggerCount, 0); + QCOMPARE(registry.menuItems("unregistered", "id", "Name").size(), 2); + } + void reservedDuplicateAndMalformedItemsCannotOverrideTheFramework() + { + dock::FolderMenuRegistry registry; + TestFolderMenuProvider provider; + provider.actions = { + QVariantMap{{"id", "open-folder"}, {"name", "Replace open"}}, + QVariantMap{{"id", "undock-folder"}, {"name", "Replace undock"}}, + QVariantMap{{"id", "details"}, {"name", "Details"}}, + QVariantMap{{"id", "details"}, {"name", "Duplicate"}}, + QVariantMap{{"id", ""}, {"name", "No identity"}}, + QVariantMap{{"id", "unnamed"}}, + QVariantMap{{"id", "hidden"}, {"name", "Hidden"}, {"visible", false}} + }; + QVERIFY(registry.registerProvider("launcher", &provider)); + QCOMPARE(registry.menuItems("launcher", "id", "Folder").size(), 3); + QVERIFY(!registry.trigger("launcher", "id", "open-folder")); + QVERIFY(!registry.trigger("launcher", "id", "undock-folder")); + QVERIFY(!registry.trigger("launcher", "id", "hidden")); + QVERIFY(!registry.trigger("launcher", "id", "unknown")); + QCOMPARE(provider.triggerCount, 0); + } + void enabledAndCheckedStateIsProvidedByPlugin() + { + dock::FolderMenuRegistry registry; + TestFolderMenuProvider provider; + provider.actions = {QVariantMap{{"id", "details"}, {"name", "Details"}, + {"enabled", false}, {"checkable", true}, {"checked", true}}}; + QVERIFY(registry.registerProvider("launcher", &provider)); + const auto custom = registry.menuItems("launcher", "id", "Folder")[2].toMap(); + QCOMPARE(custom.value("enabled").toBool(), false); + QCOMPARE(custom.value("checkable").toBool(), true); + QCOMPARE(custom.value("checked").toBool(), true); + QVERIFY(!registry.trigger("launcher", "id", "details")); + provider.actions = {QVariantMap{{"id", "details"}, {"name", "Details"}}}; + QVERIFY(registry.trigger("launcher", "id", "details")); + // Actions can change while a native menu is open. + provider.actions.clear(); + QVERIFY(!registry.trigger("launcher", "id", "details")); + QCOMPARE(provider.triggerCount, 1); + } + void registrationIsOwnedByItsPlugin() + { + dock::FolderMenuRegistry registry; + TestFolderMenuProvider first, other; + QObject invalid; + QVERIFY(!registry.registerProvider("", &first)); + QVERIFY(!registry.registerProvider("launcher", nullptr)); + QVERIFY(!registry.registerProvider("launcher", &invalid)); + QVERIFY(registry.registerProvider("launcher", &first)); + QVERIFY(registry.registerProvider("launcher", &first)); + QVERIFY(!registry.registerProvider("launcher", &other)); + registry.unregisterProvider("launcher", &other); + QCOMPARE(registry.menuItems("launcher", "id", "Folder").size(), 3); + registry.unregisterProvider("launcher", &first); + QCOMPARE(registry.menuItems("launcher", "id", "Folder").size(), 2); + QVERIFY(!registry.trigger("launcher", "id", "details")); + QVERIFY(registry.registerProvider("launcher", &other)); + } + void destroyingProviderRestoresTheDefaultMenu() + { + dock::FolderMenuRegistry registry; + auto provider = std::make_unique(); + QVERIFY(registry.registerProvider("launcher", provider.get())); + provider.reset(); + QCOMPARE(registry.menuItems("launcher", "id", "Folder").size(), 2); + QVERIFY(!registry.trigger("launcher", "id", "details")); + TestFolderMenuProvider replacement; + QVERIFY(registry.registerProvider("launcher", &replacement)); + QCOMPARE(registry.menuItems("launcher", "id", "Folder").size(), 3); + } +}; + +QTEST_GUILESS_MAIN(FolderMenuRegistryTest) +#include "foldermenuregistrytests.moc"