From 5412d89a533c6de142f8997d2220b550f324eca2 Mon Sep 17 00:00:00 2001 From: Wu JiangYu Date: Tue, 27 Oct 2026 13:16:43 +0800 Subject: [PATCH] feat: add folder menu registry for plugin menu extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add FolderMenuRegistry class to manage folder context menu extensions 2. Support plugin-provided custom menu items via registerProvider interface 3. Provide standard items (open-folder, undock-folder) plus plugin custom items 4. Validate provider methods via Qt meta-object system before registration 5. Prevent reserved IDs from being overridden by plugins 6. Filter invalid, duplicate, hidden, and malformed menu items 7. Revalidate actions at click time to handle plugin unload or state changes 8. Guard against stale action IDs and disabled actions during dispatch 9. Add unit tests covering registration, dispatch, validation, and lifecycle Log: Added support for folder context menu extensions from plugins Influence: 1. Test default menu shows only folder name and Undock when no provider registered 2. Verify custom actions from registered plugin are appended after standard items 3. Test that reserved IDs (open-folder, undock-folder) cannot be overridden by plugins 4. Verify duplicate, empty, or malformed menu item IDs are filtered out 5. Test hidden items (visible=false) are not displayed 6. Verify enabled/checkable/checked states are properly passed through 7. Test trigger dispatch only reaches the correct provider plugin 8. Verify actions are revalidated at click time and stale actions are rejected 9. Test provider destruction or unregistration restores the default menu 10. Verify registration is rejected for null, invalid, or thread- mismatched providers 11. Test that a second plugin cannot silently replace an existing provider for the same folder type feat: 添加文件夹菜单注册表以支持插件菜单扩展 1. 新增 FolderMenuRegistry 类用于管理文件夹上下文菜单扩展 2. 通过 registerProvider 接口支持插件提供的自定义菜单项 3. 提供标准菜单项(open-folder、undock-folder)以及插件自定义项 4. 注册前通过 Qt 元对象系统校验插件方法签名 5. 防止插件覆盖保留的菜单项 ID 6. 过滤无效、重复、隐藏及格式错误的菜单项 7. 点击时重新校验动作以处理插件卸载或状态变化 8. 派发时防止过期动作 ID 及已禁用动作 9. 添加单元测试覆盖注册、派发、校验及生命周期 Log: 新增插件文件夹上下文菜单扩展支持 Influence: 1. 测试未注册插件时默认菜单仅显示文件夹名称和取消停靠 2. 验证已注册插件的自定义动作追加在标准菜单项之后 3. 测试保留 ID(open-folder、undock-folder)无法被插件覆盖 4. 验证重复、空或格式错误的菜单项 ID 被过滤 5. 测试隐藏项(visible=false)不显示 6. 验证 enabled/checkable/checked 状态正确传递 7. 测试触发派发仅到达正确的插件提供者 8. 验证点击时动作重新校验,过期动作被拒绝 9. 测试插件销毁或注销后恢复默认菜单 10. 验证空指针、无效或线程不匹配的插件注册被拒绝 11. 测试第二个插件无法静默替换同一文件夹类型的现有提供者 PMS: TASK-397255 --- panels/dock/taskmanager/CMakeLists.txt | 2 + .../dock/taskmanager/foldermenuregistry.cpp | 90 ++++++++++++ panels/dock/taskmanager/foldermenuregistry.h | 31 ++++ tests/panels/dock/taskmanager/CMakeLists.txt | 10 ++ .../taskmanager/foldermenuregistrytests.cpp | 133 ++++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 panels/dock/taskmanager/foldermenuregistry.cpp create mode 100644 panels/dock/taskmanager/foldermenuregistry.h create mode 100644 tests/panels/dock/taskmanager/foldermenuregistrytests.cpp 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"