diff --git a/libsearch/CMakeLists.txt b/libsearch/CMakeLists.txt index 549d3420b4b3989c8aa7a7ef864e00394b35c189..a3362966c2f57f6198d92c1c798defd954721ccf 100644 --- a/libsearch/CMakeLists.txt +++ b/libsearch/CMakeLists.txt @@ -117,6 +117,16 @@ set(LIBUKUI_SEARCH_SRC pathsearch/path-ranker.cpp pathsearch/path-ranker.h pathsearch/path-search-request.h pathsearch/path-search-service.cpp pathsearch/path-search-service.h + filesystemmenu/peony-file-system-menu-action-converter.cpp filesystemmenu/peony-file-system-menu-action-converter.h + filesystemmenu/peony-file-system-menu-bridge-provider.cpp filesystemmenu/peony-file-system-menu-bridge-provider.h + filesystemmenu/peony/menu-plugin-iface.h + filesystemmenu/peony/plugin-iface.h + filesystemmenu/builtin-file-system-menu-provider.cpp filesystemmenu/builtin-file-system-menu-provider.h + filesystemmenu/file-system-menu-provider.h + filesystemmenu/file-system-menu-composer.cpp filesystemmenu/file-system-menu-composer.h + filesystemmenu/file-system-menu-runtime.cpp filesystemmenu/file-system-menu-runtime.h + filesystemmenu/single-file-system-menu-context.h + filesystemmenu/transient-file-system-menu-action-registry.cpp filesystemmenu/transient-file-system-menu-action-registry.h pluginmanage/plugin-info.h pluginmanage/plugin-manager.cpp pluginmanage/plugin-manager.h pluginmanage/search-plugin-manager.cpp pluginmanage/search-plugin-manager.h @@ -153,6 +163,15 @@ set(QRC_FILES resource1.qrc) file(GLOB TS_FILES ${CMAKE_CURRENT_SOURCE_DIR}/../translations/libukui-search/*.ts) set_source_files_properties(${TS_FILES} PROPERTIES OUTPUT_LOCATION ${CMAKE_BINARY_DIR}/libsearch/.qm) +set(UKUI_SEARCH_PEONY_EXTENSION_DIRS + "/usr/lib/${CMAKE_LIBRARY_ARCHITECTURE}/peony-extensions; /var/opt/system/lib/peony-extensions" + CACHE STRING "Semicolon-separated Peony menu plugin search directories") +file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/filesystemmenu) +configure_file( + filesystemmenu/peony-file-system-menu-bridge-config.h.in + ${CMAKE_CURRENT_BINARY_DIR}/filesystemmenu/peony-file-system-menu-bridge-config.h + @ONLY) + if (QT_VERSION_MAJOR EQUAL 5) qt5_create_translation(QM_FILES ${CMAKE_CURRENT_SOURCE_DIR} ${TS_FILES}) qt5_generate_repc(LIBUKUI_SEARCH_SRC index/monitor.rep REPLICA) @@ -217,6 +236,7 @@ include_directories( pathsearch plugininterface pluginmanage + filesystemmenu searchinterface searchinterface/searchtasks settingsearch diff --git a/libsearch/autotest/CMakeLists.txt b/libsearch/autotest/CMakeLists.txt index bad1b958d3e25f30ef4957047eb09eae6236380e..b65589799ea918564e500feae4f5e6c4544b328e 100644 --- a/libsearch/autotest/CMakeLists.txt +++ b/libsearch/autotest/CMakeLists.txt @@ -115,6 +115,22 @@ target_link_libraries(fileUtilsNonGuiTest PUBLIC add_test(NAME fileUtilsNonGuiTest COMMAND fileUtilsNonGuiTest) +add_executable(fileUtilsClipboardTest + file-utils-clipboard-test.cpp) + +target_link_libraries(fileUtilsClipboardTest PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Widgets + ukui-search + ) + +add_test(NAME fileUtilsClipboardTest COMMAND fileUtilsClipboardTest) +set_tests_properties(fileUtilsClipboardTest PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + add_executable(fileSearchServiceTest file-search-service-test.cpp) @@ -194,3 +210,19 @@ add_test(NAME legacySearchTaskTest COMMAND legacySearchTaskTest) set_tests_properties(legacySearchTaskTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + +add_executable(resultMenuSupportTest + result-menu-support-test.cpp) + +target_link_libraries(resultMenuSupportTest PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Test + ukui-search + ) + +add_test(NAME resultMenuSupportTest COMMAND resultMenuSupportTest) +set_tests_properties(resultMenuSupportTest PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) diff --git a/libsearch/autotest/file-utils-clipboard-test.cpp b/libsearch/autotest/file-utils-clipboard-test.cpp new file mode 100644 index 0000000000000000000000000000000000000000..facf35a929401b046b664668d6cfb5ecc200f0f9 --- /dev/null +++ b/libsearch/autotest/file-utils-clipboard-test.cpp @@ -0,0 +1,58 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include +#include +#include +#include + +#include "file-utils.h" + +using namespace UkuiSearch; + +class FileUtilsClipboardTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void copyUrisToClipboardWritesGnomeCopiedFilesMime(); +}; + +void FileUtilsClipboardTest::copyUrisToClipboardWritesGnomeCopiedFilesMime() +{ + QGuiApplication::clipboard()->clear(); + + const QStringList uris{QStringLiteral("file:///tmp/demo.txt")}; + QVERIFY(FileUtils::copyUrisToClipboard(uris, false)); + + const auto *mimeData = QGuiApplication::clipboard()->mimeData(); + QVERIFY(mimeData); + QVERIFY(mimeData->hasFormat("text/uri-list")); + QVERIFY(mimeData->hasFormat("x-special/gnome-copied-files")); + QCOMPARE( + QString::fromUtf8(mimeData->data("text/uri-list")).trimmed(), + QStringLiteral("file:///tmp/demo.txt")); + QCOMPARE( + QString::fromUtf8(mimeData->data("x-special/gnome-copied-files")), + QStringLiteral("copy\nfile:///tmp/demo.txt")); +} + +QTEST_MAIN(FileUtilsClipboardTest) + +#include "file-utils-clipboard-test.moc" diff --git a/libsearch/autotest/result-menu-support-test.cpp b/libsearch/autotest/result-menu-support-test.cpp new file mode 100644 index 0000000000000000000000000000000000000000..1193f22a4de87d2cae9d0e7de7742e076fb15c34 --- /dev/null +++ b/libsearch/autotest/result-menu-support-test.cpp @@ -0,0 +1,621 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "plugininterface/search-contract-types.h" +#include "file-utils.h" +#include "filesystemmenu/builtin-file-system-menu-provider.h" +#include "filesystemmenu/peony/menu-plugin-iface.h" +#include "filesystemmenu/peony-file-system-menu-action-converter.h" +#include "filesystemmenu/peony-file-system-menu-bridge-provider.h" +#include "filesystemmenu/file-system-menu-composer.h" +#include "filesystemmenu/file-system-menu-runtime.h" +#include "filesystemmenu/file-system-menu-item-definition.h" +#include "filesystemmenu/transient-file-system-menu-action-registry.h" + +using namespace UkuiSearch; + +namespace { + +QIcon makeSolidColorIcon(const QColor &color) +{ + QPixmap pixmap(16, 16); + pixmap.fill(color); + return QIcon(pixmap); +} + +QColor iconTopLeftColor(const QIcon &icon) +{ + return icon.pixmap(16, 16).toImage().pixelColor(0, 0); +} + +class StubMenuProvider : public FileSystemMenuProvider +{ +public: + ResultMenuItemDefinitions items; + + [[nodiscard]] ResultMenuItemDefinitions menuItemsFor(const SingleFileSystemMenuContext &) override + { + return items; + } + + [[nodiscard]] bool execute(const QString &, const SingleFileSystemMenuContext &) override + { + return false; + } +}; + +} // namespace + +class TestBridgeMenuPlugin : public QObject, public Peony::MenuPluginInterface +{ +public: + PluginType pluginType() override + { + return PluginInterface::MenuPlugin; + } + + const QString name() override + { + return QStringLiteral("mime-aware-plugin"); + } + + const QString description() override + { + return QStringLiteral("tests whether FileInfo is primed"); + } + + const QIcon icon() override + { + return pluginIcon; + } + + void setEnable(bool enable) override + { + m_enabled = enable; + } + + bool isEnable() override + { + return m_enabled; + } + + QString testPlugin() override + { + return QStringLiteral("ok"); + } + + QList menuActions(Types, const QString &, const QStringList &selectionUris) override + { + ++callCount; + if (selectionUris.isEmpty()) { + return {}; + } + + QList actions; + for (const auto &text : actionTexts) { + auto *action = new QAction(text, nullptr); + action->setIcon(actionIcon); + actions.append(action); + } + + if (prependNullAction) { + actions.prepend(nullptr); + } + return actions; + } + + int callCount = 0; + QIcon pluginIcon; + QIcon actionIcon; + QStringList actionTexts{QStringLiteral("Bridge plugin action")}; + bool prependNullAction = false; + +private: + bool m_enabled = false; +}; + +class ResultMenuSupportTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void buildsBuiltInFileMenuInExpectedOrder(); + void filesystemMenuItemRoleDefaultsToUnknown(); + void builtinActionsExposeExpectedRoles(); + void filesystemMenuItemsExposeExpectedRoles(); + void composerMovesPropertiesBehindBridgeItems(); + void builtinOpenActionUsesDefaultApplicationIcon(); + void bridgeLoadsActionsFromValidatedAllowListedPlugin(); + void bridgeSkipsNullActionsReturnedByPlugin(); + void bridgeParsesConfiguredPluginDirs(); + void bridgeFallsBackToPluginIconWhenActionIconMissing(); + void convertsQActionTreeIntoNeutralDefinitions(); + void assistantBridgeActionGetsAssistantSemanticWhenSingleLeafAction(); + void assistantBridgeActionsStayUnknownWhenPluginReturnsMultipleLeafActions(); + void nonAssistantBridgeActionsRemainUnknown(); + void excludesPluginsThatRequirePeonyRuntimeState(); + void executesPropertiesThroughInjectedCallback(); + void runtimeReturnsProviderComposedItems(); + void runtimeDispatchesActionsThroughComposer(); + void runtimeCloseSessionDelegatesToBridge(); + void returnsEmptyMenuForInvalidContext(); + void skipsUnsupportedWidgetActions(); +}; + +void ResultMenuSupportTest::buildsBuiltInFileMenuInExpectedOrder() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + BuiltinFileSystemMenuProvider provider; + FileSystemMenuComposer composer({&provider}); + + const auto items = composer.menuItemsFor(context); + + QCOMPARE(items.size(), 7); + QCOMPARE(items.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(items.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(items.at(2).kind(), ResultMenuItemDefinition::Kind::Separator); + QCOMPARE(items.at(3).id(), QStringLiteral("builtin.copyFile")); + QCOMPARE(items.at(4).id(), QStringLiteral("builtin.copyPath")); + QCOMPARE(items.at(5).kind(), ResultMenuItemDefinition::Kind::Separator); + QCOMPARE(items.at(6).id(), QStringLiteral("builtin.properties")); +} + +void ResultMenuSupportTest::filesystemMenuItemRoleDefaultsToUnknown() +{ + FileSystemMenuItemDefinition item; + + QCOMPARE(item.role(), FileSystemMenuItemDefinition::Role::Unknown); +} + +void ResultMenuSupportTest::builtinActionsExposeExpectedRoles() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + BuiltinFileSystemMenuProvider provider; + const auto items = provider.filesystemMenuItemsFor(context); + + QCOMPARE(items.at(0).role(), FileSystemMenuItemDefinition::Role::Open); + QCOMPARE(items.at(1).role(), FileSystemMenuItemDefinition::Role::Reveal); + QCOMPARE(items.at(3).role(), FileSystemMenuItemDefinition::Role::CopyFile); + QCOMPARE(items.at(4).role(), FileSystemMenuItemDefinition::Role::CopyPath); + QCOMPARE(items.constLast().role(), FileSystemMenuItemDefinition::Role::Properties); +} + +void ResultMenuSupportTest::filesystemMenuItemsExposeExpectedRoles() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + FileSystemMenuRuntime runtime; + const auto items = runtime.filesystemMenuItemsFor(context); + + QCOMPARE(items.at(0).role(), FileSystemMenuItemDefinition::Role::Open); + QCOMPARE(items.at(1).role(), FileSystemMenuItemDefinition::Role::Reveal); + QCOMPARE(items.at(3).role(), FileSystemMenuItemDefinition::Role::CopyFile); + QCOMPARE(items.at(4).role(), FileSystemMenuItemDefinition::Role::CopyPath); + QCOMPARE(items.constLast().role(), FileSystemMenuItemDefinition::Role::Properties); +} + +void ResultMenuSupportTest::composerMovesPropertiesBehindBridgeItems() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + BuiltinFileSystemMenuProvider builtinProvider; + StubMenuProvider bridgeProvider; + bridgeProvider.items = { + ResultMenuItemDefinition::separator(), + ResultMenuItemDefinition::action( + QStringLiteral("bridge.assistant"), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"))}; + FileSystemMenuComposer composer({&builtinProvider, &bridgeProvider}); + + const auto items = composer.menuItemsFor(context); + + QCOMPARE(items.size(), 9); + QCOMPARE(items.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(items.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(items.at(3).id(), QStringLiteral("builtin.copyFile")); + QCOMPARE(items.at(4).id(), QStringLiteral("builtin.copyPath")); + QCOMPARE(items.at(5).kind(), ResultMenuItemDefinition::Kind::Separator); + QCOMPARE(items.at(6).id(), QStringLiteral("bridge.assistant")); + QCOMPARE(items.at(7).kind(), ResultMenuItemDefinition::Kind::Separator); + QCOMPARE(items.at(8).id(), QStringLiteral("builtin.properties")); +} + +void ResultMenuSupportTest::builtinOpenActionUsesDefaultApplicationIcon() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString targetPath = tempDir.path() + QStringLiteral("/demo.txt"); + QFile targetFile(targetPath); + QVERIFY(targetFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)); + QVERIFY(targetFile.write("demo text") > 0); + targetFile.close(); + + SingleFileSystemMenuContext context; + context.targetUri = QUrl::fromLocalFile(targetPath).toString(); + context.targetLocalPath = targetPath; + context.containerUri = QUrl::fromLocalFile(tempDir.path()).toString(); + context.resourceType = ResourceType::File; + + BuiltinFileSystemMenuProvider provider; + const auto items = provider.menuItemsFor(context); + const QIcon expectedIcon = FileUtils::getDefaultApplicationIcon(targetPath); + + if (expectedIcon.isNull()) { + QSKIP("Current environment does not expose a default application icon for this file."); + } + + QCOMPARE(items.first().icon().pixmap(16, 16).toImage(), expectedIcon.pixmap(16, 16).toImage()); +} + +void ResultMenuSupportTest::returnsEmptyMenuForInvalidContext() +{ + FileSystemMenuComposer composer(QVector{}); + + QVERIFY(composer.menuItemsFor({}).isEmpty()); +} + +void ResultMenuSupportTest::bridgeLoadsActionsFromValidatedAllowListedPlugin() +{ + TestBridgeMenuPlugin plugin; + plugin.actionIcon = makeSolidColorIcon(QColor(QStringLiteral("#2a7fff"))); + PeonyFileSystemMenuBridgeProvider provider; + + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-print-pictures.so"), loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-print-pictures.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + const auto items = provider.menuItemsFor(context); + + QCOMPARE(plugin.callCount, 1); + QCOMPARE(items.size(), 1); + QCOMPARE(items.first().text(), QStringLiteral("Bridge plugin action")); + QCOMPARE(iconTopLeftColor(items.first().icon()), QColor(QStringLiteral("#2a7fff"))); +} + +void ResultMenuSupportTest::bridgeSkipsNullActionsReturnedByPlugin() +{ + TestBridgeMenuPlugin plugin; + plugin.prependNullAction = true; + PeonyFileSystemMenuBridgeProvider provider; + + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-print-pictures.so"), loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-print-pictures.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + provider.resetTransientContext(); + const auto actions = provider.loadMenuActions(context); + + QCOMPARE(actions.size(), 1); + QVERIFY(actions.first() != nullptr); +} + +void ResultMenuSupportTest::bridgeParsesConfiguredPluginDirs() +{ + PeonyFileSystemMenuBridgeProvider provider; + const QStringList expectedDirs{ + QStringLiteral("/opt/kylin/lib/aarch64-linux-gnu/peony-extensions"), + QStringLiteral("/usr/lib64/peony-extensions")}; + + const auto dirs = provider.pluginSearchDirsFromConfiguredValue( + QStringLiteral(" /opt/kylin/lib/aarch64-linux-gnu/peony-extensions;;" + "/usr/lib64/peony-extensions;" + "/opt/kylin/lib/aarch64-linux-gnu/peony-extensions ")); + + QCOMPARE(dirs, expectedDirs); +} + +void ResultMenuSupportTest::bridgeFallsBackToPluginIconWhenActionIconMissing() +{ + TestBridgeMenuPlugin plugin; + plugin.pluginIcon = makeSolidColorIcon(QColor(QStringLiteral("#e95420"))); + + PeonyFileSystemMenuBridgeProvider provider; + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-send-to-connectivity.so"), loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-send-to-connectivity.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + const auto items = provider.menuItemsFor(context); + + QCOMPARE(items.size(), 1); + QCOMPARE(iconTopLeftColor(items.first().icon()), QColor(QStringLiteral("#e95420"))); +} + +void ResultMenuSupportTest::convertsQActionTreeIntoNeutralDefinitions() +{ + QMenu root; + auto *assistant = root.addAction(QStringLiteral("发送到AI助手")); + assistant->setIcon(makeSolidColorIcon(QColor(QStringLiteral("#5e81ac")))); + auto *submenu = root.addMenu(QStringLiteral("打印")); + submenu->setIcon(makeSolidColorIcon(QColor(QStringLiteral("#a3be8c")))); + submenu->addAction(QStringLiteral("WPS打印")); + + TransientFileSystemMenuActionRegistry registry; + PeonyFileSystemMenuActionConverter converter(®istry); + + const auto items = converter.convert(root.actions(), QStringLiteral("ctx-1")); + + QCOMPARE(items.at(0).kind(), ResultMenuItemDefinition::Kind::Action); + QCOMPARE(items.at(1).kind(), ResultMenuItemDefinition::Kind::Submenu); + QVERIFY(items.at(0).id().startsWith(QStringLiteral("bridge."))); + QCOMPARE(iconTopLeftColor(items.at(0).icon()), QColor(QStringLiteral("#5e81ac"))); + QCOMPARE(iconTopLeftColor(items.at(1).icon()), QColor(QStringLiteral("#a3be8c"))); +} + +void ResultMenuSupportTest::assistantBridgeActionGetsAssistantSemanticWhenSingleLeafAction() +{ + TestBridgeMenuPlugin plugin; + plugin.actionTexts = QStringList{QStringLiteral("发送到AI助手")}; + + PeonyFileSystemMenuBridgeProvider provider; + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so"), + loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + const auto items = provider.menuItemsFor(context); + + QCOMPARE(items.size(), 1); + QCOMPARE(provider.filesystemMenuItemsFor(context).first().role(), + FileSystemMenuItemDefinition::Role::SendToAiAssistant); +} + +void ResultMenuSupportTest::assistantBridgeActionsStayUnknownWhenPluginReturnsMultipleLeafActions() +{ + TestBridgeMenuPlugin plugin; + plugin.actionTexts = QStringList{QStringLiteral("发送到AI助手"), QStringLiteral("配置AI")}; + + PeonyFileSystemMenuBridgeProvider provider; + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so"), + loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + const auto items = provider.menuItemsFor(context); + + QCOMPARE(items.size(), 2); + QCOMPARE(provider.filesystemMenuItemsFor(context).at(0).role(), + FileSystemMenuItemDefinition::Role::Unknown); + QCOMPARE(provider.filesystemMenuItemsFor(context).at(1).role(), + FileSystemMenuItemDefinition::Role::Unknown); +} + +void ResultMenuSupportTest::nonAssistantBridgeActionsRemainUnknown() +{ + TestBridgeMenuPlugin plugin; + plugin.actionTexts = QStringList{QStringLiteral("打印图片")}; + + PeonyFileSystemMenuBridgeProvider provider; + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->plugin = &plugin; + provider.m_loadedPlugins.insert(QStringLiteral("libpeony-print-pictures.so"), loadedPlugin); + const auto allowedPluginFiles = provider.allowListedPluginFiles(); + for (const auto &fileName : allowedPluginFiles) { + if (fileName != QStringLiteral("libpeony-print-pictures.so")) { + provider.m_failedPluginFiles.append(fileName); + } + } + + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + const auto items = provider.menuItemsFor(context); + + QCOMPARE(items.size(), 1); + QCOMPARE(provider.filesystemMenuItemsFor(context).first().role(), + FileSystemMenuItemDefinition::Role::Unknown); +} + +void ResultMenuSupportTest::excludesPluginsThatRequirePeonyRuntimeState() +{ + PeonyFileSystemMenuBridgeProvider provider; + + const auto pluginFiles = provider.allowListedPluginFiles(); + + QVERIFY(!pluginFiles.contains(QStringLiteral("libpeony-bluetooth-plugin.so"))); + QVERIFY(!pluginFiles.contains(QStringLiteral("libpeony-send-to-device.so"))); +} + +void ResultMenuSupportTest::executesPropertiesThroughInjectedCallback() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + BuiltinFileSystemMenuProvider provider; + provider.callbacks.showItemProperties = [&](const QStringList &uris, const QString &startupId) { + return uris == QStringList{QStringLiteral("file:///tmp/demo.txt")} && startupId.isEmpty(); + }; + + FileSystemMenuComposer composer({&provider}); + + QVERIFY(composer.executeAction(QStringLiteral("builtin.properties"), context)); +} + +void ResultMenuSupportTest::runtimeReturnsProviderComposedItems() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + FileSystemMenuRuntime runtime; + runtime.bridgeHooks().menuItems = [](const SingleFileSystemMenuContext &) { + return ResultMenuItemDefinitions{ + ResultMenuItemDefinition::separator(), + ResultMenuItemDefinition::action( + QStringLiteral("bridge.assistant"), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"))}; + }; + + const auto items = runtime.menuItemsFor(context); + + QCOMPARE(items.size(), 9); + QCOMPARE(items.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(items.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(items.at(3).id(), QStringLiteral("builtin.copyFile")); + QCOMPARE(items.at(4).id(), QStringLiteral("builtin.copyPath")); + QCOMPARE(items.at(6).id(), QStringLiteral("bridge.assistant")); + QCOMPARE(items.at(8).id(), QStringLiteral("builtin.properties")); +} + +void ResultMenuSupportTest::runtimeDispatchesActionsThroughComposer() +{ + SingleFileSystemMenuContext context; + context.targetUri = QStringLiteral("file:///tmp/demo.txt"); + context.targetLocalPath = QStringLiteral("/tmp/demo.txt"); + context.containerUri = QStringLiteral("file:///tmp"); + context.resourceType = ResourceType::File; + + FileSystemMenuRuntime runtime; + bool bridgeTriggered = false; + runtime.bridgeHooks().executeAction = + [&](const QString &actionId, const SingleFileSystemMenuContext &receivedContext) { + bridgeTriggered = (actionId == QStringLiteral("bridge.assistant") + && receivedContext.targetLocalPath == context.targetLocalPath); + return bridgeTriggered; + }; + + QVERIFY(runtime.executeAction(QStringLiteral("bridge.assistant"), context)); + QVERIFY(bridgeTriggered); +} + +void ResultMenuSupportTest::runtimeCloseSessionDelegatesToBridge() +{ + FileSystemMenuRuntime runtime; + bool closeCalled = false; + runtime.bridgeHooks().closeSession = [&]() { + closeCalled = true; + }; + + runtime.closeSession(); + + QVERIFY(closeCalled); +} + +void ResultMenuSupportTest::skipsUnsupportedWidgetActions() +{ + QMenu root; + root.addAction(new QWidgetAction(&root)); + + TransientFileSystemMenuActionRegistry registry; + PeonyFileSystemMenuActionConverter converter(®istry); + + QVERIFY(converter.convert(root.actions(), QStringLiteral("ctx-2")).isEmpty()); +} + +QTEST_MAIN(ResultMenuSupportTest) + +#include "result-menu-support-test.moc" diff --git a/libsearch/file-utils.cpp b/libsearch/file-utils.cpp index 30830a9d8694ddba2a3b918a549168622d150b6c..f39acbad94f1bddfbb10ea406b15747606ba2c14 100644 --- a/libsearch/file-utils.cpp +++ b/libsearch/file-utils.cpp @@ -21,7 +21,6 @@ */ #include "file-utils.h" #include -#include #include #include #include @@ -34,8 +33,8 @@ #include #include #include +#include #include -#include #include #include "gobject-template.h" #include "hanzi-to-pinyin.h" @@ -74,6 +73,82 @@ qreal applicationFontPointSize() return pointSize > 0 ? pointSize : nonGuiFontPointSize(); } +QString localFileContentType(const QString &path) +{ + auto file = wrapGFile(g_file_new_for_path(path.toUtf8().constData())); + auto fileInfo = wrapGFileInfo(g_file_query_info( + file.get()->get(), + "standard::content-type,standard::fast-content-type", + G_FILE_QUERY_INFO_NONE, + nullptr, + nullptr)); + if (!fileInfo || !fileInfo.get() || !fileInfo.get()->get()) { + return {}; + } + + const char *mimeType = g_file_info_get_content_type(fileInfo.get()->get()); + if (!mimeType && g_file_info_has_attribute(fileInfo.get()->get(), "standard::fast-content-type")) { + mimeType = g_file_info_get_attribute_string(fileInfo.get()->get(), "standard::fast-content-type"); + } + + return mimeType ? QString::fromUtf8(mimeType) : QString(); +} + +GAppInfo *defaultAppInfoForMimeType(const QString &mimeType) +{ + if (mimeType.isEmpty()) { + return nullptr; + } + + return g_app_info_get_default_for_type(mimeType.toUtf8().constData(), false); +} + +GAppInfo *defaultAppInfoForLocalFile(const QString &path) +{ + auto file = wrapGFile(g_file_new_for_path(path.toUtf8().constData())); + if (file && file.get() && file.get()->get()) { + // 先按“具体文件对象”查询默认处理器,结果更接近桌面环境真实会启动的应用。 + if (auto *handler = g_file_query_default_handler(file.get()->get(), nullptr, nullptr)) { + return handler; + } + } + + // 仅在无法直接按文件对象解析时,才回退到 MIME 级别的默认应用查询。 + return defaultAppInfoForMimeType(localFileContentType(path)); +} + +QIcon iconFromAppInfo(GAppInfo *appInfo) +{ + if (!G_IS_APP_INFO(appInfo)) { + return {}; + } + + // g_app_info_get_icon() 返回的是 borrowed reference: + // 这里只读取图标描述,不接管其生命周期,因此不需要额外 g_object_unref()。 + GIcon *gIcon = g_app_info_get_icon(appInfo); + if (!gIcon) { + return {}; + } + + if (G_IS_THEMED_ICON(gIcon)) { + const gchar *const *iconNames = g_themed_icon_get_names(G_THEMED_ICON(gIcon)); + for (auto p = iconNames; p && *p; ++p) { + const QIcon icon = IconLoader::loadIconXdg(QString::fromUtf8(*p)); + if (!icon.isNull()) { + return icon; + } + } + } else if (G_IS_FILE_ICON(gIcon)) { + GFile *file = g_file_icon_get_file(G_FILE_ICON(gIcon)); + const char *iconPath = file ? g_file_peek_path(file) : nullptr; + if (iconPath) { + return IconLoader::loadIconQt(QString::fromUtf8(iconPath)); + } + } + + return {}; +} + QString buildNonGuiSnippet(const QString &text, int start) { if (text.isEmpty()) { @@ -233,6 +308,18 @@ QMimeType FileUtils::getMimetype(const QString &path) { return type; } +QIcon FileUtils::getDefaultApplicationIcon(const QString &path) +{ + auto *appInfo = defaultAppInfoForLocalFile(path); + if (!G_IS_APP_INFO(appInfo)) { + return {}; + } + + const QIcon icon = iconFromAppInfo(appInfo); + g_object_unref(appInfo); + return icon; +} + QStringList FileUtils::findMultiToneWords(const QString &hanzi) { QStringList output, results; HanZiToPinYin::getInstance()->getResults(hanzi.toStdString(), results); @@ -264,43 +351,11 @@ int FileUtils::openFile(QString &path, bool openInDir) res = -1; } } else { - auto file = wrapGFile(g_file_new_for_uri(QUrl::fromLocalFile(path).toString().toUtf8().constData())); - auto fileInfo = wrapGFileInfo(g_file_query_info(file.get()->get(), - "standard::*," "time::*," "access::*," "mountable::*," "metadata::*," "trash::*," G_FILE_ATTRIBUTE_ID_FILE, - G_FILE_QUERY_INFO_NONE, - nullptr, - nullptr)); - QString mimeType = g_file_info_get_content_type (fileInfo.get()->get()); - if (mimeType == nullptr) { - if (g_file_info_has_attribute(fileInfo.get()->get(), "standard::fast-content-type")) { - mimeType = g_file_info_get_attribute_string(fileInfo.get()->get(), "standard::fast-content-type"); - } - } - - GError *error = nullptr; - GAppInfo *info = nullptr; /* - * g_app_info_get_default_for_type function get wrong default app, so we get the - * default app info from mimeapps.list, and chose the right default app for mimeType file - */ - QString mimeAppsListPath = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) - + "/.config/mimeapps.list"; - GKeyFile *keyfile = g_key_file_new(); - gboolean ret = g_key_file_load_from_file(keyfile, mimeAppsListPath.toUtf8(), G_KEY_FILE_NONE, &error); - if (false == ret) { - qWarning()<<"load mimeapps list error msg"<message; - info = g_app_info_get_default_for_type(mimeType.toUtf8().constData(), false); - g_error_free(error); - } else { - gchar *desktopApp = g_key_file_get_string(keyfile, "Default Applications", mimeType.toUtf8(), &error); - if (nullptr != desktopApp) { - info = (GAppInfo*)g_desktop_app_info_new(desktopApp); - g_free (desktopApp); - } else { - info = g_app_info_get_default_for_type(mimeType.toUtf8().constData(), false); - } - } - g_key_file_free (keyfile); + * “打开文件”和“展示默认应用图标”必须共用同一套默认应用解析逻辑, + * 否则很容易出现菜单上显示的是 A 图标,真正启动时却按 B 应用处理。 + */ + GAppInfo *info = defaultAppInfoForLocalFile(path); if (!G_IS_APP_INFO(info)) { res = -1; } else { @@ -839,3 +894,57 @@ QString FileUtils::getSnippetWithoutKeyword(const QString &content, int lineCoun } return snippet; } + +bool FileUtils::copyUrisToClipboard(const QStringList &uris, bool isCut) +{ + if (!guiApplicationInstance() || uris.isEmpty()) { + return false; + } + + QList urlList; + QStringList normalizedUris; + for (const auto &uri : uris) { + const QUrl url(uri); + if (!url.isValid()) { + continue; + } + + urlList.append(url); + normalizedUris.append(url.toString()); + } + + if (normalizedUris.isEmpty()) { + return false; + } + + auto *mimeData = new QMimeData(); + mimeData->setUrls(urlList); + mimeData->setData( + QStringLiteral("text/uri-list"), + normalizedUris.join(QStringLiteral("\r\n")).toUtf8()); + mimeData->setData( + QStringLiteral("x-special/gnome-copied-files"), + QStringLiteral("%1\n%2") + .arg( + isCut ? QStringLiteral("cut") : QStringLiteral("copy"), + normalizedUris.join(QStringLiteral("\n"))) + .toUtf8()); + QGuiApplication::clipboard()->setMimeData(mimeData); + return true; +} + +bool FileUtils::showItemProperties(const QStringList &uris, const QString &startupId) +{ + if (uris.isEmpty()) { + return false; + } + + QDBusMessage message = QDBusMessage::createMethodCall( + "org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1", + "ShowItemProperties"); + message.setArguments({uris, startupId}); + const QDBusMessage reply = QDBusConnection::sessionBus().call(message); + return reply.type() == QDBusMessage::ReplyMessage; +} diff --git a/libsearch/file-utils.h b/libsearch/file-utils.h index 859491eb253d004ba26ddb61a9b4b340691a6ee2..77613c6cdea8197e6f51afa0c480f5d591a7824a 100644 --- a/libsearch/file-utils.h +++ b/libsearch/file-utils.h @@ -27,6 +27,7 @@ #include #include #include "libsearch_global.h" +#include namespace UkuiSearch { class LIBSEARCH_EXPORT FileUtils { @@ -46,6 +47,8 @@ public: //parse text,docx..... static QMimeType getMimetype(const QString &path); + // 返回本地文件默认打开应用的图标;若当前没有可用默认应用,则返回空图标。 + static QIcon getDefaultApplicationIcon(const QString &path); static int openFile(QString &path, bool openInDir = false); static bool copyPath(QString &path); static QString escapeHtml(const QString &str); @@ -60,6 +63,8 @@ public: */ static bool isEncrypedOrUnsupport(const QString &path, const QString &suffix); static bool isOcrSupportSize(QString path); + static bool copyUrisToClipboard(const QStringList &uris, bool isCut = false); + static bool showItemProperties(const QStringList &uris, const QString &startupId = QString()); private: FileUtils(); diff --git a/libsearch/filesystemmenu/builtin-file-system-menu-provider.cpp b/libsearch/filesystemmenu/builtin-file-system-menu-provider.cpp new file mode 100644 index 0000000000000000000000000000000000000000..ba222cf046d0aad1815f15d60fca3771c1db7295 --- /dev/null +++ b/libsearch/filesystemmenu/builtin-file-system-menu-provider.cpp @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/builtin-file-system-menu-provider.h" + +#include + +#include "file-utils.h" + +namespace { + +const char *kTranslationContext = "BuiltinFileSystemMenuProvider"; + +} + +namespace UkuiSearch { + +BuiltinFileSystemMenuProvider::BuiltinFileSystemMenuProvider() +{ + // 默认行为全部直接走 FileUtils,保证没有额外 hook 时菜单也具备基础能力。 + callbacks.open = [](const QString &path) { + auto localPath = path; + return FileUtils::openFile(localPath, false) == 0; + }; + callbacks.reveal = [](const QString &path) { + auto localPath = path; + return FileUtils::openFile(localPath, true) == 0; + }; + callbacks.copyUris = [](const QStringList &uris, bool isCut) { + return FileUtils::copyUrisToClipboard(uris, isCut); + }; + callbacks.copyPath = [](const QString &path) { + auto localPath = path; + return FileUtils::copyPath(localPath); + }; + callbacks.showItemProperties = [](const QStringList &uris, const QString &startupId) { + return FileUtils::showItemProperties(uris, startupId); + }; +} + +QString BuiltinFileSystemMenuProvider::openActionId() +{ + return QStringLiteral("builtin.open"); +} + +QString BuiltinFileSystemMenuProvider::revealActionId() +{ + return QStringLiteral("builtin.reveal"); +} + +QString BuiltinFileSystemMenuProvider::copyFileActionId() +{ + return QStringLiteral("builtin.copyFile"); +} + +QString BuiltinFileSystemMenuProvider::copyPathActionId() +{ + return QStringLiteral("builtin.copyPath"); +} + +QString BuiltinFileSystemMenuProvider::propertiesActionId() +{ + return QStringLiteral("builtin.properties"); +} + +ResultMenuItemDefinitions BuiltinFileSystemMenuProvider::menuItemsFor(const SingleFileSystemMenuContext &context) +{ + ResultMenuItemDefinitions rawItems; + const auto items = filesystemMenuItemsFor(context); + rawItems.reserve(items.size()); + for (const auto &item : items) { + rawItems.append(item.item()); + } + return rawItems; +} + +FileSystemMenuItemDefinitions BuiltinFileSystemMenuProvider::filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) +{ + if (!context.isValid()) { + return {}; + } + + // “打开”菜单项需要尽量贴近实际启动行为: + // 1. 优先显示当前文件默认打开应用的图标; + // 2. 若当前没有可用默认应用,再回退到通用主题图标。 + auto openItem = ResultMenuItemDefinition::action( + openActionId(), + QCoreApplication::translate(kTranslationContext, "Open"), + QStringLiteral("document-open-symbolic")); + const QIcon defaultAppIcon = FileUtils::getDefaultApplicationIcon(context.targetLocalPath); + if (!defaultAppIcon.isNull()) { + openItem.setIcon(defaultAppIcon); + } + + auto revealItem = ResultMenuItemDefinition::action( + revealActionId(), + QCoreApplication::translate(kTranslationContext, "Open File Location"), + QStringLiteral("document-open-symbolic")); + + auto copyFileItem = ResultMenuItemDefinition::action( + copyFileActionId(), + QCoreApplication::translate(kTranslationContext, "Copy File"), + QStringLiteral("edit-copy-symbolic")); + + auto copyPathItem = ResultMenuItemDefinition::action( + copyPathActionId(), + QCoreApplication::translate(kTranslationContext, "Copy Path"), + QStringLiteral("edit-copy-symbolic")); + + auto propertiesItem = ResultMenuItemDefinition::action( + propertiesActionId(), + QCoreApplication::translate(kTranslationContext, "Properties"), + QStringLiteral("properties-symbolic")); + + // 文件和目录当前共用同一套基础动作骨架,差异交给具体回调实现处理。 + return { + makeFileSystemMenuItem(std::move(openItem), FileSystemMenuItemDefinition::Role::Open), + makeFileSystemMenuItem(std::move(revealItem), FileSystemMenuItemDefinition::Role::Reveal), + fileSystemSeparator(), + makeFileSystemMenuItem(std::move(copyFileItem), FileSystemMenuItemDefinition::Role::CopyFile), + makeFileSystemMenuItem(std::move(copyPathItem), FileSystemMenuItemDefinition::Role::CopyPath), + fileSystemSeparator(), + makeFileSystemMenuItem(std::move(propertiesItem), FileSystemMenuItemDefinition::Role::Properties)}; +} + +bool BuiltinFileSystemMenuProvider::execute(const QString &actionId, const SingleFileSystemMenuContext &context) +{ + if (!context.isValid()) { + return false; + } + + // builtin provider 只消费自己命名空间下的动作,其他 actionId 会继续交给后续 provider。 + if (actionId == openActionId()) { + return callbacks.open ? callbacks.open(context.targetLocalPath) : false; + } + if (actionId == revealActionId()) { + return callbacks.reveal ? callbacks.reveal(context.targetLocalPath) : false; + } + if (actionId == copyFileActionId()) { + return callbacks.copyUris ? callbacks.copyUris({context.targetUri}, false) : false; + } + if (actionId == copyPathActionId()) { + return callbacks.copyPath ? callbacks.copyPath(context.targetLocalPath) : false; + } + if (actionId == propertiesActionId()) { + return callbacks.showItemProperties ? callbacks.showItemProperties({context.targetUri}, {}) + : false; + } + + return false; +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/builtin-file-system-menu-provider.h b/libsearch/filesystemmenu/builtin-file-system-menu-provider.h new file mode 100644 index 0000000000000000000000000000000000000000..9157429e0896c7ac8f2744a90c070f0569e694af --- /dev/null +++ b/libsearch/filesystemmenu/builtin-file-system-menu-provider.h @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_BUILTIN_FILE_SYSTEM_MENU_PROVIDER_H +#define UKUI_SEARCH_BUILTIN_FILE_SYSTEM_MENU_PROVIDER_H + +#include + +#include + +#include "filesystemmenu/file-system-menu-provider.h" + +namespace UkuiSearch { + +class BuiltinFileSystemMenuProvider : public FileSystemMenuProvider +{ +public: + struct Callbacks + { + // 所有回调都遵循“返回是否已成功处理”的约定。 + // provider 构造时会先注入 FileUtils 默认实现,plugin 可按需覆盖其中某几项, + // 从而复用统一菜单结构,同时保留测试替身和宿主定制能力。 + std::function open; + std::function reveal; + std::function copyUris; + std::function copyPath; + std::function showItemProperties; + }; + + BuiltinFileSystemMenuProvider(); + + // 内建动作统一使用 builtin.* 命名空间,避免与 bridge 注册出来的临时动作冲突。 + [[nodiscard]] static QString openActionId(); + [[nodiscard]] static QString revealActionId(); + [[nodiscard]] static QString copyFileActionId(); + [[nodiscard]] static QString copyPathActionId(); + [[nodiscard]] static QString propertiesActionId(); + + [[nodiscard]] ResultMenuItemDefinitions menuItemsFor(const SingleFileSystemMenuContext &context) override; + [[nodiscard]] FileSystemMenuItemDefinitions filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) override; + [[nodiscard]] bool execute(const QString &actionId, const SingleFileSystemMenuContext &context) override; + + // 直接公开 callbacks,是为了让 file/directory plugin 在 hooks 更新时能够“原地重建默认值, + // 再覆写个别回调”,同时保持 provider 对象地址稳定,避免影响外部 composer 持有的指针。 + Callbacks callbacks; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_BUILTIN_FILE_SYSTEM_MENU_PROVIDER_H diff --git a/libsearch/filesystemmenu/file-system-menu-composer.cpp b/libsearch/filesystemmenu/file-system-menu-composer.cpp new file mode 100644 index 0000000000000000000000000000000000000000..1c5ecaee11a10c4e4351af46072806941c7369cd --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-composer.cpp @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/file-system-menu-composer.h" + +#include "filesystemmenu/builtin-file-system-menu-provider.h" + +namespace UkuiSearch { + +namespace { + +void removeDuplicateSeparators(ResultMenuItemDefinitions *items) +{ + if (!items) { + return; + } + + for (int i = 1; i < items->size();) { + if (items->at(i - 1).kind() == ResultMenuItemDefinition::Kind::Separator + && items->at(i).kind() == ResultMenuItemDefinition::Kind::Separator) { + items->remove(i); + continue; + } + ++i; + } +} + +void trimTrailingSeparators(ResultMenuItemDefinitions *items) +{ + if (!items) { + return; + } + + while (!items->isEmpty() + && items->constLast().kind() == ResultMenuItemDefinition::Kind::Separator) { + items->removeLast(); + } +} + +void movePropertiesActionToEnd(ResultMenuItemDefinitions *items) +{ + if (!items) { + return; + } + + int propertiesIndex = -1; + for (int i = 0; i < items->size(); ++i) { + if (items->at(i).id() == BuiltinFileSystemMenuProvider::propertiesActionId()) { + propertiesIndex = i; + break; + } + } + if (propertiesIndex < 0 || propertiesIndex == items->size() - 1) { + return; + } + + // “属性”在文件管理器语义里始终是菜单尾项。 + // 这里不改 provider 注册顺序,而是在最终拼装结果上做一次稳定化整理, + // 这样既能保持动作分发链路不变,也能让 bridge 扩展项自然落在属性之前。 + const auto propertiesItem = items->at(propertiesIndex); + items->remove(propertiesIndex); + + removeDuplicateSeparators(items); + trimTrailingSeparators(items); + if (!items->isEmpty()) { + items->append(ResultMenuItemDefinition::separator()); + } + items->append(propertiesItem); +} + +} // namespace + +FileSystemMenuComposer::FileSystemMenuComposer(QVector providers) + : m_providers(std::move(providers)) +{} + +ResultMenuItemDefinitions FileSystemMenuComposer::menuItemsFor(const SingleFileSystemMenuContext &context) +{ + if (!context.isValid()) { + return {}; + } + + ResultMenuItemDefinitions items; + for (auto *provider : m_providers) { + if (!provider) { + continue; + } + + // 菜单项按 provider 注册顺序直接拼接,调用方通过 provider 排序控制最终展示顺序。 + items += provider->menuItemsFor(context); + } + + movePropertiesActionToEnd(&items); + return items; +} + +bool FileSystemMenuComposer::executeAction( + const QString &actionId, const SingleFileSystemMenuContext &context) +{ + if (actionId.isEmpty() || !context.isValid()) { + return false; + } + + for (auto *provider : m_providers) { + // 动作分发也遵循同样的顺序语义:谁先认领谁处理。 + // 这样 provider 可以各自维护 actionId 命名空间,而无需中心化索引。 + if (provider && provider->execute(actionId, context)) { + return true; + } + } + + return false; +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/file-system-menu-composer.h b/libsearch/filesystemmenu/file-system-menu-composer.h new file mode 100644 index 0000000000000000000000000000000000000000..48fcea80e9d3c7ee533ccd08d19e48eee2fd6240 --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-composer.h @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_MENU_COMPOSER_H +#define UKUI_SEARCH_FILE_SYSTEM_MENU_COMPOSER_H + +#include + +#include "filesystemmenu/file-system-menu-provider.h" + +namespace UkuiSearch { + +// 将多个文件系统菜单 provider 顺序拼接成最终右键菜单。 +// 当前典型顺序是: +// 1. 内建 provider:打开、打开文件位置、复制路径、属性等; +// 2. peony bridge provider:蓝牙、图片打印、WPS 打印等扩展动作。 +// 这里保持一个轻量顺序组合器即可,不额外维护复杂的全局动作注册关系。 +class FileSystemMenuComposer +{ +public: + explicit FileSystemMenuComposer(QVector providers = {}); + + [[nodiscard]] ResultMenuItemDefinitions menuItemsFor(const SingleFileSystemMenuContext &context); + [[nodiscard]] bool executeAction(const QString &actionId, const SingleFileSystemMenuContext &context); + +private: + QVector m_providers; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_FILE_SYSTEM_MENU_COMPOSER_H diff --git a/libsearch/filesystemmenu/file-system-menu-item-definition.h b/libsearch/filesystemmenu/file-system-menu-item-definition.h new file mode 100644 index 0000000000000000000000000000000000000000..7d1164380ea6c5e1e8d9ae09f52a23d4d8859163 --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-item-definition.h @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_MENU_ITEM_DEFINITION_H +#define UKUI_SEARCH_FILE_SYSTEM_MENU_ITEM_DEFINITION_H + +#include + +#include + +#include "plugininterface/result-menu-item-definition.h" + +namespace UkuiSearch { + +struct FileSystemMenuItemDefinition +{ + enum class Role + { + Unknown = 0, + Open, + Reveal, + CopyFile, + CopyPath, + Properties, + SendToAiAssistant + }; + + [[nodiscard]] const ResultMenuItemDefinition &item() const { return m_item; } + [[nodiscard]] ResultMenuItemDefinition &item() { return m_item; } + [[nodiscard]] Role role() const { return m_role; } + void setRole(Role role) { m_role = role; } + +private: + ResultMenuItemDefinition m_item; + Role m_role = Role::Unknown; + + friend FileSystemMenuItemDefinition makeFileSystemMenuItem(ResultMenuItemDefinition item, Role role); +}; + +using FileSystemMenuItemDefinitions = QVector; + +inline FileSystemMenuItemDefinition makeFileSystemMenuItem(ResultMenuItemDefinition item, + FileSystemMenuItemDefinition::Role role + = FileSystemMenuItemDefinition::Role::Unknown) +{ + FileSystemMenuItemDefinition definition; + definition.m_item = std::move(item); + definition.m_role = role; + return definition; +} + +inline FileSystemMenuItemDefinition fileSystemAction(QString actionId, + QString actionText, + QString actionIconName = {}, + bool actionEnabled = true, + bool actionVisible = true, + FileSystemMenuItemDefinition::Role role + = FileSystemMenuItemDefinition::Role::Unknown) +{ + return makeFileSystemMenuItem(ResultMenuItemDefinition::action(std::move(actionId), + std::move(actionText), + std::move(actionIconName), + actionEnabled, + actionVisible), + role); +} + +inline FileSystemMenuItemDefinition fileSystemSeparator(bool itemVisible = true) +{ + return makeFileSystemMenuItem(ResultMenuItemDefinition::separator(itemVisible)); +} + +inline FileSystemMenuItemDefinition fileSystemSubmenu( + QString itemText, + FileSystemMenuItemDefinitions childItems, + QString itemIconName = {}, + bool itemEnabled = true, + bool itemVisible = true) +{ + ResultMenuItemDefinitions rawChildren; + rawChildren.reserve(childItems.size()); + for (const auto &child : childItems) { + rawChildren.append(child.item()); + } + + return makeFileSystemMenuItem(ResultMenuItemDefinition::submenu(std::move(itemText), + std::move(rawChildren), + std::move(itemIconName), + itemEnabled, + itemVisible)); +} + +} // namespace UkuiSearch + +Q_DECLARE_METATYPE(UkuiSearch::FileSystemMenuItemDefinition::Role) +Q_DECLARE_METATYPE(UkuiSearch::FileSystemMenuItemDefinition) +Q_DECLARE_METATYPE(UkuiSearch::FileSystemMenuItemDefinitions) + +#endif // UKUI_SEARCH_FILE_SYSTEM_MENU_ITEM_DEFINITION_H diff --git a/libsearch/filesystemmenu/file-system-menu-provider.h b/libsearch/filesystemmenu/file-system-menu-provider.h new file mode 100644 index 0000000000000000000000000000000000000000..7f3b93c3d590e339583601dcd0ce400af66f93ab --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-provider.h @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_MENU_PROVIDER_H +#define UKUI_SEARCH_FILE_SYSTEM_MENU_PROVIDER_H + +#include + +#include "filesystemmenu/file-system-menu-item-definition.h" +#include "plugininterface/result-menu-item-definition.h" +#include "filesystemmenu/single-file-system-menu-context.h" + +namespace UkuiSearch { + +// 文件系统类搜索结果菜单 provider 的统一抽象。 +// 上层只关心两件事: +// 1. 给定一个单资源上下文,能生成哪些菜单项; +// 2. 用户点击动作后,谁来处理对应 actionId。 +// 这样内建动作和 peony bridge 就能通过同一接口被组合。 +class FileSystemMenuProvider +{ +public: + virtual ~FileSystemMenuProvider() = default; + + [[nodiscard]] virtual FileSystemMenuItemDefinitions filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) + { + FileSystemMenuItemDefinitions items; + const auto rawItems = menuItemsFor(context); + items.reserve(rawItems.size()); + for (const auto &item : rawItems) { + items.append(makeFileSystemMenuItem(item)); + } + return items; + } + + // 生成中立菜单模型。 + // 这里返回 ResultMenuItemDefinition,而不是直接暴露 QAction/QMenu, + // 是为了让菜单数据可以跨不同前端层复用。 + [[nodiscard]] virtual ResultMenuItemDefinitions menuItemsFor(const SingleFileSystemMenuContext &context) + = 0; + // 执行动作。 + // provider 只需要在自己识别该 actionId 时返回 true;其余情况交给组合器继续分发。 + [[nodiscard]] virtual bool execute(const QString &actionId, const SingleFileSystemMenuContext &context) + = 0; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_FILE_SYSTEM_MENU_PROVIDER_H diff --git a/libsearch/filesystemmenu/file-system-menu-runtime.cpp b/libsearch/filesystemmenu/file-system-menu-runtime.cpp new file mode 100644 index 0000000000000000000000000000000000000000..e5dff56c9ade223b7c810db0f633cdacd1f3728f --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-runtime.cpp @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/file-system-menu-runtime.h" + +namespace UkuiSearch { + +FileSystemMenuRuntime::FileSystemMenuRuntime() + : m_composer({&m_builtinProvider, &m_bridgeProvider}) +{} + +BuiltinFileSystemMenuProvider::Callbacks &FileSystemMenuRuntime::builtinCallbacks() +{ + return m_builtinProvider.callbacks; +} + +PeonyFileSystemMenuBridgeProvider::Hooks &FileSystemMenuRuntime::bridgeHooks() +{ + return m_bridgeProvider.hooks; +} + +FileSystemMenuItemDefinitions FileSystemMenuRuntime::filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) +{ + FileSystemMenuItemDefinitions items; + if (!context.isValid()) { + return items; + } + + for (auto *provider : {static_cast(&m_builtinProvider), + static_cast(&m_bridgeProvider)}) { + if (!provider) { + continue; + } + items += provider->filesystemMenuItemsFor(context); + } + return items; +} + +ResultMenuItemDefinitions FileSystemMenuRuntime::menuItemsFor(const SingleFileSystemMenuContext &context) +{ + return m_composer.menuItemsFor(context); +} + +bool FileSystemMenuRuntime::executeAction( + const QString &actionId, const SingleFileSystemMenuContext &context) +{ + return m_composer.executeAction(actionId, context); +} + +void FileSystemMenuRuntime::closeSession() +{ + m_bridgeProvider.closeActiveContextMenuSession(); +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/file-system-menu-runtime.h b/libsearch/filesystemmenu/file-system-menu-runtime.h new file mode 100644 index 0000000000000000000000000000000000000000..30cb773d0639bd24ec9d7e73ff3176f10cf04007 --- /dev/null +++ b/libsearch/filesystemmenu/file-system-menu-runtime.h @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_MENU_RUNTIME_H +#define UKUI_SEARCH_FILE_SYSTEM_MENU_RUNTIME_H + +#include "filesystemmenu/builtin-file-system-menu-provider.h" +#include "filesystemmenu/file-system-menu-composer.h" +#include "filesystemmenu/file-system-menu-item-definition.h" +#include "filesystemmenu/peony-file-system-menu-bridge-provider.h" + +namespace UkuiSearch { + +class FileSystemMenuRuntime +{ +public: + FileSystemMenuRuntime(); + + [[nodiscard]] BuiltinFileSystemMenuProvider::Callbacks &builtinCallbacks(); + [[nodiscard]] PeonyFileSystemMenuBridgeProvider::Hooks &bridgeHooks(); + [[nodiscard]] FileSystemMenuItemDefinitions filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context); + [[nodiscard]] ResultMenuItemDefinitions menuItemsFor(const SingleFileSystemMenuContext &context); + [[nodiscard]] bool executeAction(const QString &actionId, const SingleFileSystemMenuContext &context); + void closeSession(); + +private: + BuiltinFileSystemMenuProvider m_builtinProvider; + PeonyFileSystemMenuBridgeProvider m_bridgeProvider; + FileSystemMenuComposer m_composer; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_FILE_SYSTEM_MENU_RUNTIME_H diff --git a/libsearch/filesystemmenu/peony-file-system-menu-action-converter.cpp b/libsearch/filesystemmenu/peony-file-system-menu-action-converter.cpp new file mode 100644 index 0000000000000000000000000000000000000000..b32cb90be01e57893803310e3ba1cd9ae599c2cf --- /dev/null +++ b/libsearch/filesystemmenu/peony-file-system-menu-action-converter.cpp @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/peony-file-system-menu-action-converter.h" + +#include +#include +#include +#include + +namespace UkuiSearch { + +namespace { + +QString iconNameOf(const QIcon &icon) +{ + return icon.name(); +} + +FileSystemMenuItemDefinition::Role roleOf(QAction *action) +{ + if (!action) { + return FileSystemMenuItemDefinition::Role::Unknown; + } + + bool ok = false; + const auto value = action->property(PeonyFileSystemMenuActionConverter::kRolePropertyName).toInt(&ok); + if (!ok) { + return FileSystemMenuItemDefinition::Role::Unknown; + } + + return static_cast(value); +} + +} // namespace + +PeonyFileSystemMenuActionConverter::PeonyFileSystemMenuActionConverter(TransientFileSystemMenuActionRegistry *registry) + : m_registry(registry) +{} + +FileSystemMenuItemDefinitions PeonyFileSystemMenuActionConverter::convertToFileSystemItems( + const QList &actions, const QString &contextKey) const +{ + FileSystemMenuItemDefinitions items; + for (auto *action : actions) { + bool ok = false; + const auto item = convertAction(action, contextKey, &ok); + if (ok) { + items.append(makeFileSystemMenuItem(std::move(item), roleOf(action))); + } + } + return items; +} + +ResultMenuItemDefinitions PeonyFileSystemMenuActionConverter::convert( + const QList &actions, const QString &contextKey) const +{ + ResultMenuItemDefinitions items; + const auto typedItems = convertToFileSystemItems(actions, contextKey); + items.reserve(typedItems.size()); + for (const auto &item : typedItems) { + items.append(item.item()); + } + return items; +} + +ResultMenuItemDefinition PeonyFileSystemMenuActionConverter::convertAction( + QAction *action, const QString &contextKey, bool *ok) const +{ + if (ok) { + *ok = false; + } + + if (!action || qobject_cast(action)) { + // QWidgetAction 往往依赖真实 widget 承载,当前中立菜单模型无法无损表达, + // 因此这里显式跳过,而不是生成一个语义不完整的占位项。 + return {}; + } + + if (action->isSeparator()) { + if (ok) { + *ok = true; + } + return ResultMenuItemDefinition::separator(action->isVisible()); + } + + if (auto *submenu = action->menu()) { + // 子菜单递归转换。只有至少有一个可呈现子项时才保留该子菜单节点。 + const auto children = convert(submenu->actions(), contextKey); + if (children.isEmpty()) { + return {}; + } + + if (ok) { + *ok = true; + } + auto item = ResultMenuItemDefinition::submenu( + action->text(), + children, + iconNameOf(action->icon()), + action->isEnabled(), + action->isVisible()); + item.setIcon(action->icon()); + return item; + } + + if (!m_registry) { + return {}; + } + + // peony 返回的 QAction 生命周期不由搜索侧创建,因此这里使用 QPointer 做弱引用保护。 + // 如果原始 action 已被销毁,点击旧 actionId 时会安全失败,而不是悬空访问。 + const QPointer guardedAction(action); + const auto actionId = m_registry->registerAction(contextKey, [guardedAction] { + if (!guardedAction || !guardedAction->isEnabled()) { + return false; + } + + // 仍然让原始 QAction 自己触发,插件内部既有槽函数和状态机无需感知桥接层存在。 + guardedAction->trigger(); + return true; + }); + if (actionId.isEmpty()) { + return {}; + } + + if (ok) { + *ok = true; + } + auto item = ResultMenuItemDefinition::action( + actionId, + action->text(), + iconNameOf(action->icon()), + action->isEnabled(), + action->isVisible()); + item.setIcon(action->icon()); + return item; +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/peony-file-system-menu-action-converter.h b/libsearch/filesystemmenu/peony-file-system-menu-action-converter.h new file mode 100644 index 0000000000000000000000000000000000000000..a6fad08e841bbb54087564c954d0734eb82f59a1 --- /dev/null +++ b/libsearch/filesystemmenu/peony-file-system-menu-action-converter.h @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_ACTION_CONVERTER_H +#define UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_ACTION_CONVERTER_H + +#include + +#include "filesystemmenu/file-system-menu-item-definition.h" +#include "plugininterface/result-menu-item-definition.h" +#include "filesystemmenu/transient-file-system-menu-action-registry.h" + +class QAction; + +namespace UkuiSearch { + +// 将 peony 插件返回的 QAction/QMenu 树转换为搜索侧统一菜单模型。 +// 转换时有两个核心目标: +// 1. 尽量保留原始文本、图标、可见/可用状态以及子菜单层级; +// 2. 把“点击 QAction”改写成可序列化的 actionId,并交给 registry 管理。 +class PeonyFileSystemMenuActionConverter +{ +public: + static constexpr const char *kRolePropertyName = "_ukuiSearch.fileSystemRole"; + + explicit PeonyFileSystemMenuActionConverter(TransientFileSystemMenuActionRegistry *registry); + + [[nodiscard]] FileSystemMenuItemDefinitions convertToFileSystemItems( + const QList &actions, const QString &contextKey) const; + [[nodiscard]] ResultMenuItemDefinitions convert( + const QList &actions, const QString &contextKey) const; + +private: + // 单个 QAction 可能被转换成分隔线、普通动作、子菜单,或因为无法表达而被丢弃。 + [[nodiscard]] ResultMenuItemDefinition convertAction( + QAction *action, const QString &contextKey, bool *ok) const; + +private: + // registry 由 bridge provider 持有并管理生命周期;converter 只负责登记和转换。 + TransientFileSystemMenuActionRegistry *m_registry = nullptr; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_ACTION_CONVERTER_H diff --git a/libsearch/filesystemmenu/peony-file-system-menu-bridge-config.h.in b/libsearch/filesystemmenu/peony-file-system-menu-bridge-config.h.in new file mode 100644 index 0000000000000000000000000000000000000000..142cccbf3f917978fe7f96f567b20c7ac54efa6a --- /dev/null +++ b/libsearch/filesystemmenu/peony-file-system-menu-bridge-config.h.in @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_PEONY_MENU_BRIDGE_CONFIG_H +#define UKUI_SEARCH_PEONY_MENU_BRIDGE_CONFIG_H + +#define UKUI_SEARCH_PEONY_EXTENSION_DIRS "@UKUI_SEARCH_PEONY_EXTENSION_DIRS@" + +#endif // UKUI_SEARCH_PEONY_MENU_BRIDGE_CONFIG_H diff --git a/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.cpp b/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.cpp new file mode 100644 index 0000000000000000000000000000000000000000..34b242a4867bb7a73bb8dd1c28bca666aa21e7d4 --- /dev/null +++ b/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.cpp @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/peony-file-system-menu-bridge-provider.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "filesystemmenu/peony-file-system-menu-bridge-config.h" +#include "filesystemmenu/peony/menu-plugin-iface.h" + +namespace UkuiSearch { + +namespace { + +using Role = FileSystemMenuItemDefinition::Role; + +void assertGuiThreadAccess() +{ + auto *app = QCoreApplication::instance(); + Q_ASSERT_X( + !app || QThread::currentThread() == app->thread(), + "PeonyFileSystemMenuBridgeProvider", + "File-system menu bridge must only be accessed on the GUI thread."); +} + +void clearRoleProperty(QAction *action) +{ + if (!action) { + return; + } + + action->setProperty(PeonyFileSystemMenuActionConverter::kRolePropertyName, QVariant()); + if (auto *submenu = action->menu()) { + const auto submenuActions = submenu->actions(); + for (auto *childAction : submenuActions) { + clearRoleProperty(childAction); + } + } +} + +bool isConvertibleLeafAction(QAction *action) +{ + return action && !action->isSeparator() && !action->menu() && !qobject_cast(action); +} + +void annotateBridgeRoles(const QString &pluginFileName, const QList &actions) +{ + for (auto *action : actions) { + clearRoleProperty(action); + } + + if (pluginFileName != QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so")) { + return; + } + + QList leafActions; + for (auto *action : actions) { + if (isConvertibleLeafAction(action)) { + leafActions.append(action); + } + } + + if (leafActions.size() != 1) { + return; + } + + leafActions.first()->setProperty( + PeonyFileSystemMenuActionConverter::kRolePropertyName, + static_cast(Role::SendToAiAssistant)); +} + +} // namespace + +PeonyFileSystemMenuBridgeProvider::PeonyFileSystemMenuBridgeProvider() + : m_converter(&m_registry) +{} + +PeonyFileSystemMenuBridgeProvider::~PeonyFileSystemMenuBridgeProvider() = default; + +ResultMenuItemDefinitions PeonyFileSystemMenuBridgeProvider::menuItemsFor(const SingleFileSystemMenuContext &context) +{ + assertGuiThreadAccess(); + + if (!context.isValid()) { + return {}; + } + + if (hooks.menuItems) { + return hooks.menuItems(context); + } + + // bridge 菜单是“按次生成”的瞬时数据。 + // 每次弹菜单前都重置上下文,避免上一次菜单注册的 actionId 错误复用到下一次点击。 + resetTransientContext(); + const auto actions = loadMenuActions(context); + return m_converter.convert(actions, m_activeContextKey); +} + +FileSystemMenuItemDefinitions PeonyFileSystemMenuBridgeProvider::filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) +{ + assertGuiThreadAccess(); + + if (!context.isValid()) { + return {}; + } + + if (hooks.fileSystemMenuItems) { + return hooks.fileSystemMenuItems(context); + } + + resetTransientContext(); + const auto actions = loadMenuActions(context); + return m_converter.convertToFileSystemItems(actions, m_activeContextKey); +} + +bool PeonyFileSystemMenuBridgeProvider::execute(const QString &actionId, const SingleFileSystemMenuContext &context) +{ + assertGuiThreadAccess(); + Q_UNUSED(context); + + if (hooks.executeAction) { + return hooks.executeAction(actionId, context); + } + + return m_registry.trigger(actionId); +} + +void PeonyFileSystemMenuBridgeProvider::closeActiveContextMenuSession() +{ + assertGuiThreadAccess(); + + if (hooks.closeSession) { + hooks.closeSession(); + return; + } + + if (!m_activeContextKey.isEmpty()) { + m_registry.clearContext(m_activeContextKey); + m_activeContextKey.clear(); + } + + // 菜单销毁后,当前这棵 QAction/QMenu 临时对象树已经没有继续保活的必要。 + m_actionOwner.reset(); +} + +void PeonyFileSystemMenuBridgeProvider::resetTransientContext() +{ + if (!m_activeContextKey.isEmpty()) { + // 先移除上一轮 actionId -> callback 映射,再切换上下文 key。 + m_registry.clearContext(m_activeContextKey); + } + + m_activeContextKey = QStringLiteral("bridge-context.%1").arg(++m_contextGeneration); + // 重新创建 owner,相当于整体丢弃上一轮 action 树对象。 + m_actionOwner = std::make_unique(); +} + +void PeonyFileSystemMenuBridgeProvider::adoptActionTree(QAction *action) +{ + if (!action || !m_actionOwner) { + return; + } + + // peony 插件可能返回没有 parent 的 QAction/QMenu; + // 搜索侧虽然不会直接展示这些对象,但在用户点击前仍需保证它们持续存活。 + if (auto *submenu = action->menu(); submenu && !submenu->parent()) { + submenu->QObject::setParent(m_actionOwner.get()); + } + if (!action->parent()) { + action->setParent(m_actionOwner.get()); + } +} + +QStringList PeonyFileSystemMenuBridgeProvider::pluginSearchDirs() const +{ + return pluginSearchDirsFromConfiguredValue(QString::fromUtf8(UKUI_SEARCH_PEONY_EXTENSION_DIRS)); +} + +QStringList PeonyFileSystemMenuBridgeProvider::pluginSearchDirsFromConfiguredValue( + const QString &configuredDirs) const +{ + QStringList dirs; + const auto configuredItems = configuredDirs.split(QLatin1Char(';'), Qt::SkipEmptyParts); + for (const auto &configuredItem : configuredItems) { + const auto dir = QDir::cleanPath(configuredItem.trimmed()); + if (dir.isEmpty() || dir == QStringLiteral(".")) { + continue; + } + + dirs.append(dir); + } + // 同一路径可能因书写差异重复出现,这里统一去重。 + dirs.removeDuplicates(); + return dirs; +} + +QStringList PeonyFileSystemMenuBridgeProvider::allowListedPluginFiles() const +{ + return { + QStringLiteral("libpeony-send-to-connectivity.so"), + QStringLiteral("libpeony-menu-plugin-kylin-aiassistant.so"), + QStringLiteral("libpeony-print-pictures.so"), + QStringLiteral("libpeony-wpsprint-menu-plugin.so")}; +} + +QList PeonyFileSystemMenuBridgeProvider::loadMenuActions(const SingleFileSystemMenuContext &context) +{ + QList actions; + for (const auto &fileName : allowListedPluginFiles()) { + auto *plugin = ensurePluginLoaded(fileName); + if (!plugin) { + continue; + } + + // 对 peony 插件接口来说: + // 1. DirectoryView 用来近似表达“基于目录上下文的右键菜单”; + // 2. containerUri 是当前上下文目录; + // 3. 选中列表当前只支持单 URI。 + const auto pluginActions = plugin->menuActions( + Peony::MenuPluginInterface::DirectoryView, context.containerUri, {context.targetUri}); + annotateBridgeRoles(fileName, pluginActions); + for (auto *action : pluginActions) { + if (!action) { + continue; + } + + // 某些插件把图标挂在 plugin 自身上,这里补一层兜底,避免搜索页出现无图标菜单项。 + if (action->icon().isNull()) { + action->setIcon(plugin->icon()); + } + adoptActionTree(action); + actions.append(action); + } + } + return actions; +} + +Peony::MenuPluginInterface *PeonyFileSystemMenuBridgeProvider::ensurePluginLoaded(const QString &fileName) +{ + if (m_failedPluginFiles.contains(fileName)) { + return nullptr; + } + + const auto existing = m_loadedPlugins.find(fileName); + if (existing != m_loadedPlugins.end()) { + return existing.value()->plugin; + } + + QString pluginPath; + for (const auto &dirPath : pluginSearchDirs()) { + const auto candidate = QDir(dirPath).filePath(fileName); + if (QFileInfo::exists(candidate)) { + pluginPath = candidate; + break; + } + } + if (pluginPath.isEmpty()) { + m_failedPluginFiles.append(fileName); + return nullptr; + } + + auto loadedPlugin = QSharedPointer::create(); + loadedPlugin->loader = std::make_unique(pluginPath); + if (!loadedPlugin->loader->load()) { + m_failedPluginFiles.append(fileName); + return nullptr; + } + + auto *instance = loadedPlugin->loader->instance(); + auto *plugin = qobject_cast(instance); + if (!plugin) { + // 即便 so 能被加载,只要接口类型不匹配,也视作不可用插件并缓存失败结果。 + m_failedPluginFiles.append(fileName); + return nullptr; + } + + loadedPlugin->plugin = plugin; + auto *pluginPtr = loadedPlugin->plugin; + m_loadedPlugins.insert(fileName, loadedPlugin); + return pluginPtr; +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.h b/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.h new file mode 100644 index 0000000000000000000000000000000000000000..d9c6b2d7ea3b5d190ccda66fbce139330e07f5ab --- /dev/null +++ b/libsearch/filesystemmenu/peony-file-system-menu-bridge-provider.h @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_BRIDGE_PROVIDER_H +#define UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_BRIDGE_PROVIDER_H + +#include +#include +#include +#include +#include + +#include "filesystemmenu/peony-file-system-menu-action-converter.h" +#include "filesystemmenu/file-system-menu-provider.h" + +class QPluginLoader; +class ResultMenuSupportTest; + +namespace Peony { +class MenuPluginInterface; +} + +namespace UkuiSearch { + +class PeonyFileSystemMenuBridgeProvider : public FileSystemMenuProvider +{ +public: + struct Hooks + { + // hooks 主要用于测试替身和局部替换: + // 1. menuItems 可绕过真实插件加载,直接返回确定性菜单; + // 2. executeAction 可替换 registry.trigger,便于验证动作分发链路。 + std::function menuItems; + std::function fileSystemMenuItems; + std::function executeAction; + std::function closeSession; + }; + + PeonyFileSystemMenuBridgeProvider(); + ~PeonyFileSystemMenuBridgeProvider() override; + + [[nodiscard]] FileSystemMenuItemDefinitions filesystemMenuItemsFor( + const SingleFileSystemMenuContext &context) override; + [[nodiscard]] ResultMenuItemDefinitions menuItemsFor( + const SingleFileSystemMenuContext &context) override; + [[nodiscard]] bool execute(const QString &actionId, const SingleFileSystemMenuContext &context) override; + void closeActiveContextMenuSession(); + + Hooks hooks; + +private: + friend class ::ResultMenuSupportTest; + + // 该 provider 只在 GUI 线程使用: + // 1. 会按次创建和销毁临时 QAction/QMenu 对象树; + // 2. 会直接调用由 QPluginLoader 创建的插件 QObject 实例。 + struct LoadedPlugin + { + // loader 必须与插件实例共同存活;仅保留 instance 指针会导致插件被提前卸载。 + std::unique_ptr loader; + Peony::MenuPluginInterface *plugin = nullptr; + }; + + // 开始一轮新的右键菜单会话: + // 1. 清掉上一轮 transient action 映射; + // 2. 生成新的 contextKey; + // 3. 创建新的 actionOwner,托管本轮 QAction/QMenu 对象树。 + void resetTransientContext(); + // 接管 QAction 及其子菜单的 QObject 所有权,避免插件返回无 parent 对象后泄漏。 + void adoptActionTree(QAction *action); + // 插件目录只来自构建期配置,不通过 peony 安装布局推断,避免引入编译期循环依赖。 + [[nodiscard]] QStringList pluginSearchDirs() const; + [[nodiscard]] QStringList pluginSearchDirsFromConfiguredValue( + const QString &configuredDirs) const; + // 仅放行经过筛选的一小部分插件,避免把 peony 完整右键菜单直接搬进搜索页。 + [[nodiscard]] QStringList allowListedPluginFiles() const; + // 按“父目录 URI + 选中 URI 列表”调用 peony 插件接口,取回 QAction 树。 + [[nodiscard]] QList loadMenuActions(const SingleFileSystemMenuContext &context); + // 懒加载并缓存插件实例;同名插件在进程内最多初始化一次。 + [[nodiscard]] Peony::MenuPluginInterface *ensurePluginLoaded(const QString &fileName); + +private: + // 每次 menuItemsFor() 都会生成新的 bridge-context.N,用于给临时动作分组回收。 + mutable quint64 m_contextGeneration = 0; + mutable QString m_activeContextKey; + // 托管当前这一轮菜单产生的 QAction/QMenu 对象树;下次 reset 时整体替换即可释放旧对象。 + mutable std::unique_ptr m_actionOwner; + mutable TransientFileSystemMenuActionRegistry m_registry; + mutable PeonyFileSystemMenuActionConverter m_converter; + // 已成功加载的插件缓存,避免每次弹菜单都重复走 QPluginLoader。 + QHash> m_loadedPlugins; + // 失败缓存:不存在、加载失败或接口不匹配的插件文件都只尝试一次。 + QStringList m_failedPluginFiles; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_PEONY_FILE_SYSTEM_MENU_BRIDGE_PROVIDER_H diff --git a/libsearch/filesystemmenu/peony/menu-plugin-iface.h b/libsearch/filesystemmenu/peony/menu-plugin-iface.h new file mode 100644 index 0000000000000000000000000000000000000000..7c3fa028575aada35421205324718fec2cb27b32 --- /dev/null +++ b/libsearch/filesystemmenu/peony/menu-plugin-iface.h @@ -0,0 +1,52 @@ +/* + * Peony-Qt's Library + * + * Copyright (C) 2020, KylinSoft Co., Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this library. If not, see . + * + * Authors: Yue Lan + * + */ + +#ifndef UKUI_SEARCH_PEONY_MENU_PLUGIN_IFACE_H +#define UKUI_SEARCH_PEONY_MENU_PLUGIN_IFACE_H + +#include +#include +#include + +#include "filesystemmenu/peony/plugin-iface.h" + +#define MenuPluginInterface_iid "org.ukui.peony-qt.plugin-iface.MenuPluginInterface" + +namespace Peony { + +class MenuPluginInterface : public PluginInterface +{ +public: + enum Type { Invalid, DirectoryView, SideBar, DesktopWindow, Other }; + Q_DECLARE_FLAGS(Types, Type) + + virtual ~MenuPluginInterface() {} + virtual QString testPlugin() = 0; + virtual QList menuActions(Types types, const QString &uri, const QStringList &selectionUris)= 0; +}; + +} // namespace Peony + +Q_DECLARE_OPERATORS_FOR_FLAGS(Peony::MenuPluginInterface::Types) +Q_DECLARE_INTERFACE(Peony::MenuPluginInterface, MenuPluginInterface_iid) + +#endif // UKUI_SEARCH_PEONY_MENU_PLUGIN_IFACE_H diff --git a/libsearch/filesystemmenu/peony/plugin-iface.h b/libsearch/filesystemmenu/peony/plugin-iface.h new file mode 100644 index 0000000000000000000000000000000000000000..ecbe70e49d6216e1f5c82b1426f49df985f69bfc --- /dev/null +++ b/libsearch/filesystemmenu/peony/plugin-iface.h @@ -0,0 +1,64 @@ +/* + * Peony-Qt's Library + * + * Copyright (C) 2020, KylinSoft Co., Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this library. If not, see . + * + * Authors: Yue Lan + * + */ + +#ifndef UKUI_SEARCH_PEONY_PLUGIN_IFACE_H +#define UKUI_SEARCH_PEONY_PLUGIN_IFACE_H + +#include +#include + +namespace Peony { + +class PluginInterface +{ +public: + enum PluginType { + Invalid, + MenuPlugin, + PreviewPagePlugin, + DirectoryViewPlugin, + DirectoryViewPlugin2, + ToolBarPlugin, + PropertiesWindowPlugin, + ColumnProviderPlugin, + StylePlugin, + VFSPlugin, + EmblemPlugin, + SideBarPlugin, + VFSINFOPlugin, + SearchPlugin, + Other + }; + + virtual ~PluginInterface() {} + + virtual PluginType pluginType() = 0; + virtual const QString name() = 0; + virtual const QString description() = 0; + virtual const QIcon icon() = 0; + virtual void setEnable(bool enable) = 0; + virtual bool isEnable() = 0; +}; + +} // namespace Peony + +#endif // UKUI_SEARCH_PEONY_PLUGIN_IFACE_H diff --git a/libsearch/filesystemmenu/single-file-system-menu-context.h b/libsearch/filesystemmenu/single-file-system-menu-context.h new file mode 100644 index 0000000000000000000000000000000000000000..5089d76af0a116a750d7e7f427f88497e6fe4a39 --- /dev/null +++ b/libsearch/filesystemmenu/single-file-system-menu-context.h @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_SINGLE_FILE_SYSTEM_MENU_CONTEXT_H +#define UKUI_SEARCH_SINGLE_FILE_SYSTEM_MENU_CONTEXT_H + +#include +#include +#include +#include + +#include "plugininterface/search-contract-types.h" + +namespace UkuiSearch { + +// 单文件/目录右键菜单上下文。 +// 这里同时保留本地路径和 URI,是因为两类调用方的诉求不同: +// 1. 搜索侧内建动作更适合直接使用绝对路径; +// 2. peony 菜单插件接口使用 URI 作为上下文与选中项输入; +// 3. 两份表示并存后,可以在进入菜单链路前做一致性校验,尽早拦截脏数据。 +struct SingleFileSystemMenuContext +{ + // 被点击结果对应的本地绝对路径,例如 /home/user/a.txt。 + QString targetLocalPath; + // 被点击结果对应的 file:// URI,语义上应与 targetLocalPath 指向同一资源。 + QString targetUri; + // 被点击结果所在父目录的 file:// URI。 + // 搜索结果页虽然不是文件管理器目录视图,但 peony 插件仍需要一个“当前目录”语义, + // 因此这里约定用 target 的直接父目录来模拟上下文。 + QString containerUri; + // 当前仅支持本地文件和本地目录两类资源。 + ResourceType::Type resourceType = ResourceType::Unknown; + + [[nodiscard]] bool isValid() const + { + // 这条菜单链路只服务于文件系统资源,其他结果类型直接拒绝。 + if (resourceType != ResourceType::File && resourceType != ResourceType::Directory) { + return false; + } + + // 必须是绝对路径,避免相对路径在不同工作目录下产生歧义。 + if (!QDir::isAbsolutePath(targetLocalPath)) { + return false; + } + + const QUrl parsedTargetUri(targetUri); + // targetUri 必须是合法的本地文件 URI,且能无损回转到 targetLocalPath。 + // 这样可以保证“路径视角”和“URI 视角”描述的是同一个对象。 + if (!parsedTargetUri.isValid() || !parsedTargetUri.isLocalFile() + || parsedTargetUri.toLocalFile() != targetLocalPath) { + return false; + } + + const QUrl parsedContainerUri(containerUri); + // 右键菜单桥目前只支持本地父目录上下文,不接受远程或虚拟 URI。 + if (!parsedContainerUri.isValid() || !parsedContainerUri.isLocalFile()) { + return false; + } + + const QFileInfo targetInfo(targetLocalPath); + // containerUri 必须就是 target 的直接父目录。 + // 这样 bridge 传给 peony 的“上下文目录 + 选中项”才是自洽的。 + return parsedContainerUri.toLocalFile() == targetInfo.dir().absolutePath(); + } +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_SINGLE_FILE_SYSTEM_MENU_CONTEXT_H diff --git a/libsearch/filesystemmenu/transient-file-system-menu-action-registry.cpp b/libsearch/filesystemmenu/transient-file-system-menu-action-registry.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6284af451b7f9f3c4c89b1dbffc7cd04de5a4cc5 --- /dev/null +++ b/libsearch/filesystemmenu/transient-file-system-menu-action-registry.cpp @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "filesystemmenu/transient-file-system-menu-action-registry.h" + +namespace UkuiSearch { + +QString TransientFileSystemMenuActionRegistry::registerAction( + const QString &contextKey, const std::function &callback) +{ + if (!callback) { + return {}; + } + + // bridge.* 只在当前进程、当前菜单生命周期内有效,不应被外部长期缓存。 + const auto actionId = QStringLiteral("bridge.%1").arg(++m_nextActionId); + m_callbacks.insert(actionId, callback); + m_contextActions[contextKey].append(actionId); + return actionId; +} + +bool TransientFileSystemMenuActionRegistry::trigger(const QString &actionId) const +{ + const auto it = m_callbacks.constFind(actionId); + if (it == m_callbacks.constEnd() || !it.value()) { + return false; + } + + return it.value()(); +} + +void TransientFileSystemMenuActionRegistry::clearContext(const QString &contextKey) +{ + // 先从反向索引取走,再逐个删除正向回调映射,确保同一 context 只会被清一次。 + const auto actionIds = m_contextActions.take(contextKey); + for (const auto &actionId : actionIds) { + m_callbacks.remove(actionId); + } +} + +void TransientFileSystemMenuActionRegistry::clearAll() +{ + m_callbacks.clear(); + m_contextActions.clear(); +} + +} // namespace UkuiSearch diff --git a/libsearch/filesystemmenu/transient-file-system-menu-action-registry.h b/libsearch/filesystemmenu/transient-file-system-menu-action-registry.h new file mode 100644 index 0000000000000000000000000000000000000000..40748a534aaa1d32fd8c2205ea71424b92a9d09d --- /dev/null +++ b/libsearch/filesystemmenu/transient-file-system-menu-action-registry.h @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_TRANSIENT_FILE_SYSTEM_MENU_ACTION_REGISTRY_H +#define UKUI_SEARCH_TRANSIENT_FILE_SYSTEM_MENU_ACTION_REGISTRY_H + +#include + +#include +#include +#include + +namespace UkuiSearch { + +// peony QAction/QMenu 到中立菜单动作 ID 的短生命周期注册表。 +// peony 插件返回的是运行时对象指针,而搜索侧对外暴露的是字符串 actionId, +// 因此这里负责建立 actionId -> callback 的临时映射,并按 contextKey 成组回收。 +class TransientFileSystemMenuActionRegistry +{ +public: + // 为某一轮右键菜单上下文注册动作。 + // contextKey 由 bridge 每次生成菜单时创建,用来把这一轮菜单里的所有动作绑在一起。 + [[nodiscard]] QString registerAction( + const QString &contextKey, const std::function &callback); + // 触发 actionId 对应的临时回调;动作已过期或不存在时返回 false。 + [[nodiscard]] bool trigger(const QString &actionId) const; + // 按上下文批量清理临时动作,避免旧菜单 actionId 泄漏到下一次菜单会话。 + void clearContext(const QString &contextKey); + void clearAll(); + +private: + // 单进程内自增即可,不要求跨会话稳定;真正用于分组回收的是 contextKey。 + quint64 m_nextActionId = 0; + QHash> m_callbacks; + // 反向索引:某个 contextKey 下面注册过哪些 actionId。 + QHash m_contextActions; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_TRANSIENT_FILE_SYSTEM_MENU_ACTION_REGISTRY_H diff --git a/libsearch/plugininterface/context-menu-session-lifecycle-interface.h b/libsearch/plugininterface/context-menu-session-lifecycle-interface.h new file mode 100644 index 0000000000000000000000000000000000000000..4bb577accaa60986d02e01896c25adb7e8d9cab8 --- /dev/null +++ b/libsearch/plugininterface/context-menu-session-lifecycle-interface.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_CONTEXT_MENU_SESSION_LIFECYCLE_INTERFACE_H +#define UKUI_SEARCH_CONTEXT_MENU_SESSION_LIFECYCLE_INTERFACE_H + +namespace UkuiSearch { + +// 可选的右键菜单会话生命周期接口。 +// 只有那些在菜单生成阶段创建了“需要在菜单销毁后主动回收”的瞬时状态的 plugin, +// 才需要实现它;普通搜索 plugin 可以完全忽略这个接口。 +class ContextMenuSessionLifecycleInterface +{ +public: + virtual ~ContextMenuSessionLifecycleInterface() = default; + + virtual void closeActiveContextMenuSession() = 0; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_CONTEXT_MENU_SESSION_LIFECYCLE_INTERFACE_H diff --git a/libsearch/plugininterface/result-menu-item-definition.cpp b/libsearch/plugininterface/result-menu-item-definition.cpp index e39b5937503f0b46fb4e4f0908892e5d17739348..41be0218450ef7c507fb55a05c10d1453311192e 100644 --- a/libsearch/plugininterface/result-menu-item-definition.cpp +++ b/libsearch/plugininterface/result-menu-item-definition.cpp @@ -29,6 +29,7 @@ public: ResultMenuItemDefinition::Kind m_kind = ResultMenuItemDefinition::Kind::Action; QString m_id; QString m_text; + QIcon m_icon; QString m_iconName; bool m_enabled = true; bool m_visible = true; @@ -57,6 +58,7 @@ ResultMenuItemDefinition::~ResultMenuItemDefinition() = default; ResultMenuItemDefinition::ResultMenuItemDefinition(Kind itemKind, QString actionId, QString itemText, + QIcon itemIcon, QString itemIconName, bool itemEnabled, bool itemVisible, @@ -66,6 +68,7 @@ ResultMenuItemDefinition::ResultMenuItemDefinition(Kind itemKind, d->m_kind = itemKind; d->m_id = std::move(actionId); d->m_text = std::move(itemText); + d->m_icon = std::move(itemIcon); d->m_iconName = std::move(itemIconName); d->m_enabled = itemEnabled; d->m_visible = itemVisible; @@ -81,6 +84,7 @@ ResultMenuItemDefinition ResultMenuItemDefinition::action(QString actionId, return ResultMenuItemDefinition(Kind::Action, std::move(actionId), std::move(actionText), + {}, std::move(actionIconName), actionEnabled, actionVisible, @@ -89,7 +93,7 @@ ResultMenuItemDefinition ResultMenuItemDefinition::action(QString actionId, ResultMenuItemDefinition ResultMenuItemDefinition::separator(bool itemVisible) { - return ResultMenuItemDefinition(Kind::Separator, {}, {}, {}, true, itemVisible, {}); + return ResultMenuItemDefinition(Kind::Separator, {}, {}, {}, {}, true, itemVisible, {}); } ResultMenuItemDefinition ResultMenuItemDefinition::submenu(QString itemText, @@ -101,6 +105,7 @@ ResultMenuItemDefinition ResultMenuItemDefinition::submenu(QString itemText, return ResultMenuItemDefinition(Kind::Submenu, {}, std::move(itemText), + {}, std::move(itemIconName), itemEnabled, itemVisible, @@ -137,6 +142,16 @@ void ResultMenuItemDefinition::setText(const QString &itemText) d->m_text = itemText; } +QIcon ResultMenuItemDefinition::icon() const +{ + return d->m_icon; +} + +void ResultMenuItemDefinition::setIcon(const QIcon &itemIcon) +{ + d->m_icon = itemIcon; +} + QString ResultMenuItemDefinition::iconName() const { return d->m_iconName; diff --git a/libsearch/plugininterface/result-menu-item-definition.h b/libsearch/plugininterface/result-menu-item-definition.h index 23f9579c38d9e320fed6f2d8dde028cb178cd4be..458bef37fb5ac2980b1e3b16a3df09fb03f5ed00 100644 --- a/libsearch/plugininterface/result-menu-item-definition.h +++ b/libsearch/plugininterface/result-menu-item-definition.h @@ -18,6 +18,7 @@ #ifndef UKUI_SEARCH_RESULT_MENU_ITEM_DEFINITION_H #define UKUI_SEARCH_RESULT_MENU_ITEM_DEFINITION_H +#include #include #include #include @@ -38,6 +39,7 @@ class LIBSEARCH_EXPORT ResultMenuItemDefinition Q_PROPERTY(Kind kind READ kind WRITE setKind) Q_PROPERTY(QString id READ id WRITE setId) Q_PROPERTY(QString text READ text WRITE setText) + Q_PROPERTY(QIcon icon READ icon WRITE setIcon) Q_PROPERTY(QString iconName READ iconName WRITE setIconName) Q_PROPERTY(bool enabled READ enabled WRITE setEnabled) Q_PROPERTY(bool visible READ visible WRITE setVisible) @@ -79,6 +81,9 @@ public: [[nodiscard]] QString text() const; void setText(const QString &itemText); + [[nodiscard]] QIcon icon() const; + void setIcon(const QIcon &itemIcon); + [[nodiscard]] QString iconName() const; void setIconName(const QString &itemIconName); @@ -95,6 +100,7 @@ private: ResultMenuItemDefinition(Kind itemKind, QString actionId, QString itemText, + QIcon itemIcon, QString itemIconName, bool itemEnabled, bool itemVisible, diff --git a/ukui-search-qml/autotest/CMakeLists.txt b/ukui-search-qml/autotest/CMakeLists.txt index cfc54f34a1e03bbb707903b63b3fbf7b6d4ac647..c250c05ea94abb7e62c6831eba9e24643b9c2cc0 100644 --- a/ukui-search-qml/autotest/CMakeLists.txt +++ b/ukui-search-qml/autotest/CMakeLists.txt @@ -146,6 +146,7 @@ target_include_directories(file-search-engine-test PRIVATE ${CMAKE_SOURCE_DIR}/libchinese-segmentation ${CMAKE_SOURCE_DIR}/libsearch/autotest ${CMAKE_CURRENT_SOURCE_DIR}/../widgets/org.ukui.search.fileSearch/plugin + ${CMAKE_CURRENT_SOURCE_DIR}/../widgets/common/plugin ${CMAKE_SOURCE_DIR}/libsearch ${CMAKE_SOURCE_DIR}/libsearch/dirwatcher ${CMAKE_SOURCE_DIR}/libsearch/plugininterface @@ -163,6 +164,7 @@ target_link_libraries(file-search-engine-test PRIVATE PkgConfig::${GSETTINGS_PKG} chinese-segmentation ukui-search + ukui-search-widgets-common ukui-quick::framework ukui-quick::core xapian @@ -184,6 +186,7 @@ target_include_directories(directory-search-plugin-test PRIVATE ${CMAKE_SOURCE_DIR}/libsearch/dirwatcher ${CMAKE_SOURCE_DIR}/libsearch/plugininterface ${CMAKE_CURRENT_SOURCE_DIR}/../widgets/org.ukui.search.directorySearch/plugin + ${CMAKE_CURRENT_SOURCE_DIR}/../widgets/common/plugin ${${GSETTINGS_PKG}_INCLUDE_DIRS} ) @@ -197,6 +200,7 @@ target_link_libraries(directory-search-plugin-test PRIVATE Qt${QT_VERSION_MAJOR}::Widgets PkgConfig::${GSETTINGS_PKG} ukui-search + ukui-search-widgets-common ukui-quick::framework ukui-quick::core ) @@ -256,3 +260,32 @@ add_test(NAME ai-answer-plugin-test COMMAND ai-answer-plugin-test) set_tests_properties(ai-answer-plugin-test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + +add_executable(file-system-detail-actions-feature-test + file-system-detail-actions-feature-test.cpp +) + +target_include_directories(file-system-detail-actions-feature-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src + ${CMAKE_SOURCE_DIR}/libsearch + ${CMAKE_SOURCE_DIR}/libsearch/plugininterface + ${CMAKE_CURRENT_SOURCE_DIR}/../widgets/common/plugin +) + +target_link_libraries(file-system-detail-actions-feature-test PRIVATE + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Qml + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::Test + Qt${QT_VERSION_MAJOR}::Widgets + ukui-search + ukui-quick::framework + ukui-quick::core + ukui-search-widgets-common +) + +add_test(NAME file-system-detail-actions-feature-test COMMAND file-system-detail-actions-feature-test) +set_tests_properties(file-system-detail-actions-feature-test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" +) diff --git a/ukui-search-qml/autotest/directory-search-plugin-test.cpp b/ukui-search-qml/autotest/directory-search-plugin-test.cpp index b394fbba13fd72a3d20cffc043a167022a853561..0fcc61b70c3cd2bfed5fb9013d996a2f3120b74c 100644 --- a/ukui-search-qml/autotest/directory-search-plugin-test.cpp +++ b/ukui-search-qml/autotest/directory-search-plugin-test.cpp @@ -20,13 +20,16 @@ #include #include #include +#include #include #include +#include #include #include #include "data-queue.h" #include "directory-search-plugin.h" +#include namespace { @@ -59,6 +62,8 @@ private Q_SLOTS: void pluginDropsStaleResultsFromOlderSearch(); void pluginDispatchesOpenRevealAndCopyActions(); void pluginExposesTypedContextActions(); + void contextMenuItems_includeBuiltInSingleUriActions(); + void bridgeUsesParentUriAsContextAndSelectedUriAsSelection(); void pluginUsesDirectorySearchServiceWhenSearchHookMissing(); void pluginFallsBackToBaseNameAndPublishesDescriptions(); void pluginRoutesDefaultActionsToUnderlyingHandlers(); @@ -70,6 +75,9 @@ private Q_SLOTS: void pluginMarksDirectoryResultsVisibleInBestMatch(); void pluginPublishesOnlyTopRequestedResultsWhenStreaming(); void pluginForwardsRequestedMaxResultsToSearchHook(); + void registersDetailActionFeatureProvider(); + void detailFeatureUsesDirectorySpecificFeatureKey(); + void directoryDetailFeatureOmitsAssistantSemantic(); }; void DirectorySearchPluginTest::pluginMapsDirectoryCandidateToGenericResult() @@ -187,19 +195,72 @@ void DirectorySearchPluginTest::pluginDispatchesOpenRevealAndCopyActions() void DirectorySearchPluginTest::pluginExposesTypedContextActions() { DirectorySearchPlugin plugin; + DirectorySearchPluginHooks hooks; + hooks.menuBridgeItems = [](const UkuiSearch::SingleFileSystemMenuContext &) { + return UkuiSearch::ResultMenuItemDefinitions{}; + }; + plugin.setHooks(hooks); + UkuiSearch::ResultData result; result.setActionKey(QStringLiteral("/tmp/demo")); + result.setResourceType(UkuiSearch::ResourceType::Directory); const auto actions = plugin.contextMenuItems(result); - QCOMPARE(actions.size(), 3); - QCOMPARE(actions.at(0).id(), QStringLiteral("open")); - QCOMPARE(actions.at(1).id(), QStringLiteral("reveal")); - QCOMPARE(actions.at(2).id(), QStringLiteral("copyPath")); + QCOMPARE(actions.size(), 7); + QCOMPARE(actions.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(actions.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(actions.at(3).id(), QStringLiteral("builtin.copyFile")); + QCOMPARE(actions.at(4).id(), QStringLiteral("builtin.copyPath")); + QCOMPARE(actions.at(6).id(), QStringLiteral("builtin.properties")); QVERIFY(actions.at(0).enabled()); QVERIFY(actions.at(0).visible()); QCOMPARE(actions.at(0).kind(), UkuiSearch::ResultMenuItemDefinition::Kind::Action); } +void DirectorySearchPluginTest::contextMenuItems_includeBuiltInSingleUriActions() +{ + DirectorySearchPlugin plugin; + DirectorySearchPluginHooks hooks; + hooks.menuBridgeItems = [](const UkuiSearch::SingleFileSystemMenuContext &) { + return UkuiSearch::ResultMenuItemDefinitions{}; + }; + plugin.setHooks(hooks); + + UkuiSearch::ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo-dir")); + result.setResourceType(UkuiSearch::ResourceType::Directory); + + const auto actions = plugin.contextMenuItems(result); + + QCOMPARE(actions.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(actions.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(actions.constLast().id(), QStringLiteral("builtin.properties")); +} + +void DirectorySearchPluginTest::bridgeUsesParentUriAsContextAndSelectedUriAsSelection() +{ + DirectorySearchPlugin plugin; + QString capturedContextUri; + QStringList capturedSelections; + + DirectorySearchPluginHooks hooks; + hooks.menuBridgeItems = [&](const UkuiSearch::SingleFileSystemMenuContext &context) { + capturedContextUri = context.containerUri; + capturedSelections = QStringList{context.targetUri}; + return UkuiSearch::ResultMenuItemDefinitions{}; + }; + plugin.setHooks(hooks); + + UkuiSearch::ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo-dir")); + result.setResourceType(UkuiSearch::ResourceType::Directory); + const auto items = plugin.contextMenuItems(result); + Q_UNUSED(items); + + QCOMPARE(capturedContextUri, QStringLiteral("file:///tmp")); + QCOMPARE(capturedSelections, QStringList{QStringLiteral("file:///tmp/demo-dir")}); +} + void DirectorySearchPluginTest::pluginUsesDirectorySearchServiceWhenSearchHookMissing() { QTemporaryDir tempDir; @@ -309,12 +370,12 @@ void DirectorySearchPluginTest::pluginUsesResolvedPlanInputWhenStreaming() return UkuiSearch::PathSearchPlanInput {true}; }; hooks.streamSearch = [&sawIndexedPlan](const UkuiSearch::DirectorySearchRequest &request, - const UkuiSearch::PathSearchPlanInput &planInput, - const std::function &, - const std::function &onCandidate) { - sawIndexedPlan = planInput.fileIndexEnabled; + const UkuiSearch::PathSearchPlanInput &planInput, + const std::function &, + const std::function &onCandidate) { + sawIndexedPlan = planInput.fileIndexEnabled; onCandidate(makeCandidate(QStringLiteral("/tmp/indexed-dir"), request.keywords.join(QString()))); - }; + }; plugin.setHooks(hooks); auto queue = std::make_shared>(); @@ -333,14 +394,14 @@ void DirectorySearchPluginTest::pluginResolvesPlanInputOnMainThread() DirectorySearchPluginHooks hooks; hooks.resolvePlanInput = [&plugin, &resolvedOnMainThread] { resolvedOnMainThread.store(QThread::currentThread() == plugin.thread()); - return UkuiSearch::PathSearchPlanInput {true}; + return UkuiSearch::PathSearchPlanInput{true}; }; hooks.streamSearch = [](const UkuiSearch::DirectorySearchRequest &request, - const UkuiSearch::PathSearchPlanInput &, - const std::function &, - const std::function &onCandidate) { + const UkuiSearch::PathSearchPlanInput &, + const std::function &, + const std::function &onCandidate) { onCandidate(makeCandidate(QStringLiteral("/tmp/%1").arg(request.keywords.join(QString())))); - }; + }; plugin.setHooks(hooks); auto queue = std::make_shared>(); @@ -358,13 +419,13 @@ void DirectorySearchPluginTest::pluginKeepsQueueAliveUntilQueuedPublishCompletes DirectorySearchPluginHooks hooks; hooks.streamSearch = [&streamStarted, &allowStreamToFinish](const UkuiSearch::DirectorySearchRequest &, - const UkuiSearch::PathSearchPlanInput &, - const std::function &, - const std::function &onCandidate) { - streamStarted.release(); - allowStreamToFinish.acquire(); + const UkuiSearch::PathSearchPlanInput &, + const std::function &, + const std::function &onCandidate) { + streamStarted.release(); + allowStreamToFinish.acquire(); onCandidate(makeCandidate(QStringLiteral("/tmp/queued-dir"), QStringLiteral("queued-dir"))); - }; + }; plugin.setHooks(hooks); auto queue = std::make_shared>(); @@ -391,14 +452,14 @@ void DirectorySearchPluginTest::pluginPublishesFirstStreamedResultBeforeSearchCo DirectorySearchPluginHooks hooks; hooks.streamSearch = [&streamCompleted, &firstCandidatePublished, &allowStreamCompletion](const UkuiSearch::DirectorySearchRequest &, - const UkuiSearch::PathSearchPlanInput &, - const std::function &, - const std::function &onCandidate) { + const UkuiSearch::PathSearchPlanInput &, + const std::function &, + const std::function &onCandidate) { onCandidate(makeCandidate(QStringLiteral("/tmp/early-dir"), QStringLiteral("early-dir"))); - firstCandidatePublished.release(); - allowStreamCompletion.acquire(); - streamCompleted.store(true); - }; + firstCandidatePublished.release(); + allowStreamCompletion.acquire(); + streamCompleted.store(true); + }; plugin.setHooks(hooks); auto queue = std::make_shared>(); @@ -419,13 +480,13 @@ void DirectorySearchPluginTest::pluginPublishesStreamedResultWithoutMainThreadPu DirectorySearchPluginHooks hooks; hooks.streamSearch = [&firstCandidatePublished, &allowStreamCompletion](const UkuiSearch::DirectorySearchRequest &, - const UkuiSearch::PathSearchPlanInput &, - const std::function &, - const std::function &onCandidate) { + const UkuiSearch::PathSearchPlanInput &, + const std::function &, + const std::function &onCandidate) { onCandidate(makeCandidate(QStringLiteral("/tmp/no-pump-dir"), QStringLiteral("no-pump-dir"))); - firstCandidatePublished.release(); - allowStreamCompletion.acquire(); - }; + firstCandidatePublished.release(); + allowStreamCompletion.acquire(); + }; plugin.setHooks(hooks); auto queue = std::make_shared>(); @@ -450,7 +511,7 @@ void DirectorySearchPluginTest::pluginPublishesOnlyTopRequestedResultsWhenStream DirectorySearchPluginHooks hooks; hooks.buildRequest = [](const QString &keyword, int) { UkuiSearch::DirectorySearchRequest request; - request.keywords = QStringList {keyword}; + request.keywords = QStringList{keyword}; request.maxResults = 2; return request; }; @@ -510,12 +571,12 @@ void DirectorySearchPluginTest::pluginPublishesOnlyTopRequestedResultsWhenStream void DirectorySearchPluginTest::pluginForwardsRequestedMaxResultsToSearchHook() { DirectorySearchPlugin plugin; - std::atomic_int capturedMaxResults {-1}; + std::atomic_int capturedMaxResults{-1}; DirectorySearchPluginHooks hooks; hooks.buildRequest = [](const QString &keyword, int) { UkuiSearch::DirectorySearchRequest request; - request.keywords = QStringList {keyword}; + request.keywords = QStringList{keyword}; request.maxResults = 2; return request; }; @@ -528,7 +589,7 @@ void DirectorySearchPluginTest::pluginForwardsRequestedMaxResultsToSearchHook() midScore.finalScore = 20.0; auto highScore = makeCandidate(QStringLiteral("/tmp/directory-high"), QStringLiteral("directory-high")); highScore.finalScore = 30.0; - return QVector {lowScore, midScore, highScore}; + return QVector{lowScore, midScore, highScore}; }; plugin.setHooks(hooks); @@ -564,6 +625,71 @@ void DirectorySearchPluginTest::pluginForwardsRequestedMaxResultsToSearchHook() QVERIFY(!finalResultsByPath.contains(QStringLiteral("/tmp/directory-low"))); } +void DirectorySearchPluginTest::registersDetailActionFeatureProvider() +{ + DirectorySearchPlugin plugin; + + const auto providers = plugin.featureProviders(); + + QCOMPARE(providers.size(), 1); + QVERIFY(dynamic_cast(providers.constFirst()) != nullptr); +} + +void DirectorySearchPluginTest::detailFeatureUsesDirectorySpecificFeatureKey() +{ + DirectorySearchPlugin plugin; + + const auto providers = plugin.featureProviders(); + QCOMPARE(providers.size(), 1); + + auto *provider = dynamic_cast(providers.constFirst()); + QVERIFY(provider != nullptr); + QVERIFY(provider->hasFeature(QStringLiteral("org.ukui.search.directorySearch.detailActions"))); + + QObject *feature + = provider->feature(QStringLiteral("org.ukui.search.directorySearch.detailActions")); + QVERIFY(feature != nullptr); + QVERIFY(feature->property("actionsModel").value() != nullptr); +} + +void DirectorySearchPluginTest::directoryDetailFeatureOmitsAssistantSemantic() +{ + DirectorySearchPlugin plugin; + DirectorySearchPluginHooks hooks; + hooks.detailMenuBridgeItems = [](const UkuiSearch::SingleFileSystemMenuContext &context) { + if (context.resourceType != UkuiSearch::ResourceType::File) { + return UkuiSearch::FileSystemMenuItemDefinitions {}; + } + + return UkuiSearch::FileSystemMenuItemDefinitions { + UkuiSearch::fileSystemAction(QStringLiteral("bridge.ai"), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"), + true, + true, + UkuiSearch::FileSystemMenuItemDefinition::Role::SendToAiAssistant)}; + }; + plugin.setHooks(hooks); + + const auto providers = plugin.featureProviders(); + QCOMPARE(providers.size(), 1); + auto *provider = dynamic_cast(providers.constFirst()); + QVERIFY(provider != nullptr); + QObject *feature + = provider->feature(QStringLiteral("org.ukui.search.directorySearch.detailActions")); + QVERIFY(feature != nullptr); + + QVERIFY(QMetaObject::invokeMethod(feature, + "rebuildForResult", + Q_ARG(QString, QStringLiteral("/tmp/demo-dir")), + Q_ARG(int, int(UkuiSearch::ResourceType::Directory)))); + + auto *actionsModel + = qobject_cast(feature->property("actionsModel").value()); + QVERIFY(actionsModel != nullptr); + QCOMPARE(actionsModel->rowCount(), 3); +} + QTEST_MAIN(DirectorySearchPluginTest) #include "directory-search-plugin-test.moc" diff --git a/ukui-search-qml/autotest/file-search-engine-test.cpp b/ukui-search-qml/autotest/file-search-engine-test.cpp index e0a3c4fabe046135472e7f1abc1d5dd42c196920..916387a4f49990a5692e1001f3f46c86a3afd7fc 100644 --- a/ukui-search-qml/autotest/file-search-engine-test.cpp +++ b/ukui-search-qml/autotest/file-search-engine-test.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -45,6 +46,7 @@ #include "thumbnail-result.h" #include "thumbnail-service.h" #include "thumbnail-service-test-peer.h" +#include namespace { @@ -193,6 +195,11 @@ private Q_SLOTS: void aggregatorBuildsOneGenericResultPerUniquePath(); void aggregatorPreservesStreamOrderForHostSorting(); void aggregatorMarksFileResultsVisibleInBestMatch(); + void contextMenuItems_includeBuiltInSingleUriActions(); + void missingBridgeLeavesBuiltInMenuIntact(); + void registersDetailActionFeatureProvider(); + void detailFeatureUsesFileSpecificFeatureKey(); + void detailFeatureAndContextMenuDoNotShareBridgeRuntimeState(); }; void FileSearchEngineTest::init() @@ -1312,6 +1319,149 @@ void FileSearchEngineTest::aggregatorMarksFileResultsVisibleInBestMatch() QVERIFY(results.first().showInBestMatch()); } +void FileSearchEngineTest::contextMenuItems_includeBuiltInSingleUriActions() +{ + FileSearchPlugin plugin; + FileSearchPluginHooks hooks; + hooks.menuBridgeItems = [](const UkuiSearch::SingleFileSystemMenuContext &) { + return UkuiSearch::ResultMenuItemDefinitions{}; + }; + plugin.setProviderHooks(hooks); + + UkuiSearch::ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo.txt")); + result.setResourceType(UkuiSearch::ResourceType::File); + + const auto actions = plugin.contextMenuItems(result); + + QCOMPARE(actions.at(0).id(), QStringLiteral("builtin.open")); + QCOMPARE(actions.at(1).id(), QStringLiteral("builtin.reveal")); + QCOMPARE(actions.at(3).id(), QStringLiteral("builtin.copyFile")); + QCOMPARE(actions.at(4).id(), QStringLiteral("builtin.copyPath")); + QCOMPARE(actions.constLast().id(), QStringLiteral("builtin.properties")); +} + +void FileSearchEngineTest::missingBridgeLeavesBuiltInMenuIntact() +{ + FileSearchPlugin plugin; + FileSearchPluginHooks hooks; + hooks.menuBridgeItems = [](const UkuiSearch::SingleFileSystemMenuContext &) { + return UkuiSearch::ResultMenuItemDefinitions{}; + }; + plugin.setProviderHooks(hooks); + + UkuiSearch::ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo.txt")); + result.setResourceType(UkuiSearch::ResourceType::File); + + const auto items = plugin.contextMenuItems(result); + + QVERIFY(std::any_of(items.begin(), items.end(), [](const auto &item) { + return item.id() == QStringLiteral("builtin.open"); + })); + QVERIFY(std::none_of(items.begin(), items.end(), [](const auto &item) { + return item.id().startsWith(QStringLiteral("bridge.")); + })); +} + +void FileSearchEngineTest::registersDetailActionFeatureProvider() +{ + FileSearchPlugin plugin; + + const auto providers = plugin.featureProviders(); + + QCOMPARE(providers.size(), 1); + QVERIFY(dynamic_cast(providers.constFirst()) != nullptr); +} + +void FileSearchEngineTest::detailFeatureUsesFileSpecificFeatureKey() +{ + FileSearchPlugin plugin; + + const auto providers = plugin.featureProviders(); + QCOMPARE(providers.size(), 1); + + auto *provider = dynamic_cast(providers.constFirst()); + QVERIFY(provider != nullptr); + QVERIFY(provider->hasFeature(QStringLiteral("org.ukui.search.fileSearch.detailActions"))); + + QObject *feature = provider->feature(QStringLiteral("org.ukui.search.fileSearch.detailActions")); + QVERIFY(feature != nullptr); + QVERIFY(feature->property("actionsModel").value() != nullptr); +} + +void FileSearchEngineTest::detailFeatureAndContextMenuDoNotShareBridgeRuntimeState() +{ + FileSearchPlugin plugin; + int bridgeGeneration = 0; + + FileSearchPluginHooks hooks; + hooks.menuBridgeItems = [&](const UkuiSearch::SingleFileSystemMenuContext &) { + return ResultMenuItemDefinitions { + ResultMenuItemDefinition::action(QStringLiteral("bridge.ai.%1").arg(++bridgeGeneration), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"))}; + }; + hooks.detailMenuBridgeItems = [&](const UkuiSearch::SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions { + fileSystemAction(QStringLiteral("bridge.ai.%1").arg(++bridgeGeneration), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"), + true, + true, + FileSystemMenuItemDefinition::Role::SendToAiAssistant)}; + }; + hooks.menuBridgeExecuteAction = [](const QString &actionId, + const UkuiSearch::SingleFileSystemMenuContext &) { + return actionId.startsWith(QStringLiteral("bridge.ai.")); + }; + plugin.setProviderHooks(hooks); + + UkuiSearch::ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo.txt")); + result.setResourceType(UkuiSearch::ResourceType::File); + + const auto contextMenuItems = plugin.contextMenuItems(result); + QVERIFY(!contextMenuItems.isEmpty()); + QString contextMenuBridgeActionId; + for (const auto &item : contextMenuItems) { + if (item.id().startsWith(QStringLiteral("bridge.ai."))) { + contextMenuBridgeActionId = item.id(); + break; + } + } + QVERIFY(contextMenuBridgeActionId.startsWith(QStringLiteral("bridge.ai."))); + + const auto providers = plugin.featureProviders(); + QCOMPARE(providers.size(), 1); + auto *provider = dynamic_cast(providers.constFirst()); + QVERIFY(provider != nullptr); + QObject *feature = provider->feature(QStringLiteral("org.ukui.search.fileSearch.detailActions")); + QVERIFY(feature != nullptr); + QVERIFY(QMetaObject::invokeMethod(feature, + "rebuildForResult", + Q_ARG(QString, QStringLiteral("/tmp/demo.txt")), + Q_ARG(int, int(UkuiSearch::ResourceType::File)))); + + auto *actionsModel = qobject_cast(feature->property("actionsModel").value()); + QVERIFY(actionsModel != nullptr); + const auto roles = actionsModel->roleNames(); + int idRole = -1; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + if (it.value() == QByteArrayLiteral("id")) { + idRole = it.key(); + break; + } + } + QVERIFY(idRole > 0); + + const QString detailBridgeActionId = actionsModel->index(0, 0).data(idRole).toString(); + QVERIFY(detailBridgeActionId.startsWith(QStringLiteral("bridge.ai."))); + QVERIFY(detailBridgeActionId != contextMenuBridgeActionId); + + QVERIFY(plugin.executeAction(contextMenuBridgeActionId, result)); +} + QTEST_MAIN(FileSearchEngineTest) #include "file-search-engine-test.moc" diff --git a/ukui-search-qml/autotest/file-system-detail-actions-feature-test.cpp b/ukui-search-qml/autotest/file-system-detail-actions-feature-test.cpp new file mode 100644 index 0000000000000000000000000000000000000000..5a00d1c754c6ecf01c795147316034398de76bce --- /dev/null +++ b/ukui-search-qml/autotest/file-system-detail-actions-feature-test.cpp @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include +#include + +#include "filesystemmenu/file-system-menu-runtime.h" +#include "plugininterface/result-menu-item-definition.h" +#include "plugininterface/search-contract-types.h" +#include "file-system-detail-actions-feature.h" + +using namespace UkuiSearch; + +namespace { + +int roleForName(const QAbstractItemModel *model, const QByteArray &name) +{ + const auto roles = model ? model->roleNames() : QHash {}; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + if (it.value() == name) { + return it.key(); + } + } + + return -1; +} + +QString idAt(const QAbstractItemModel *model, int row) +{ + const int idRole = roleForName(model, "id"); + return idRole > 0 ? model->index(row, 0).data(idRole).toString() : QString(); +} + +FileSystemMenuItemDefinition assistantAction(const QString &actionId) +{ + return fileSystemAction( + actionId, + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"), + true, + true, + FileSystemMenuItemDefinition::Role::SendToAiAssistant); +} + +} // namespace + +class FileSystemDetailActionsFeatureTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void rebuildFileResultProducesExpectedActions(); + void rebuildFromResultDataUsesResultPayload(); + void rebuildDirectoryResultOmitsAssistantAction(); + void invalidInputClearsModel(); + void triggerRejectsUnknownActionId(); + void triggerRejectsDisabledBridgeActionId(); + void rebuildInvalidatesOldBridgeActionIds(); +}; + +void FileSystemDetailActionsFeatureTest::rebuildFileResultProducesExpectedActions() +{ + FileSystemDetailActionsFeature feature; + int generation = 0; + feature.runtime().bridgeHooks().fileSystemMenuItems = [&](const SingleFileSystemMenuContext &context) { + if (context.resourceType != ResourceType::File) { + return FileSystemMenuItemDefinitions {}; + } + + return FileSystemMenuItemDefinitions { + assistantAction(QStringLiteral("bridge.ai.%1").arg(++generation))}; + }; + + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + QCOMPARE(model->rowCount(), 4); + QCOMPARE(idAt(model, 0), QStringLiteral("bridge.ai.1")); + QCOMPARE(idAt(model, 1), QStringLiteral("builtin.open")); + QCOMPARE(idAt(model, 2), QStringLiteral("builtin.reveal")); + QCOMPARE(idAt(model, 3), QStringLiteral("builtin.copyPath")); +} + +void FileSystemDetailActionsFeatureTest::rebuildFromResultDataUsesResultPayload() +{ + FileSystemDetailActionsFeature feature; + feature.runtime().bridgeHooks().fileSystemMenuItems = [](const SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions { + assistantAction(QStringLiteral("bridge.ai")), + }; + }; + + ResultData result; + result.setActionKey(QStringLiteral("/tmp/demo.txt")); + result.setResourceType(ResourceType::File); + + feature.rebuildForResult(result); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + QCOMPARE(model->rowCount(), 4); + QCOMPARE(idAt(model, 0), QStringLiteral("bridge.ai")); +} + +void FileSystemDetailActionsFeatureTest::rebuildDirectoryResultOmitsAssistantAction() +{ + FileSystemDetailActionsFeature feature; + feature.runtime().bridgeHooks().fileSystemMenuItems = [](const SingleFileSystemMenuContext &context) { + if (context.resourceType == ResourceType::File) { + return FileSystemMenuItemDefinitions {assistantAction(QStringLiteral("bridge.ai"))}; + } + + return FileSystemMenuItemDefinitions {}; + }; + + feature.rebuildForResult(QStringLiteral("/tmp/demo-dir"), int(ResourceType::Directory)); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + QCOMPARE(model->rowCount(), 3); + QCOMPARE(idAt(model, 0), QStringLiteral("builtin.open")); + QCOMPARE(idAt(model, 1), QStringLiteral("builtin.reveal")); + QCOMPARE(idAt(model, 2), QStringLiteral("builtin.copyPath")); +} + +void FileSystemDetailActionsFeatureTest::invalidInputClearsModel() +{ + FileSystemDetailActionsFeature feature; + feature.runtime().bridgeHooks().fileSystemMenuItems = [](const SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions {assistantAction(QStringLiteral("bridge.ai"))}; + }; + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + QVERIFY(model->rowCount() > 0); + + feature.rebuildForResult(QStringLiteral("relative/path.txt"), int(ResourceType::File)); + QCOMPARE(model->rowCount(), 0); +} + +void FileSystemDetailActionsFeatureTest::triggerRejectsUnknownActionId() +{ + FileSystemDetailActionsFeature feature; + int executeCount = 0; + feature.runtime().bridgeHooks().fileSystemMenuItems = [](const SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions {assistantAction(QStringLiteral("bridge.ai"))}; + }; + feature.runtime().bridgeHooks().executeAction = + [&executeCount](const QString &, const SingleFileSystemMenuContext &) { + ++executeCount; + return true; + }; + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + QVERIFY(!feature.trigger(QStringLiteral("bridge.unknown"))); + QCOMPARE(executeCount, 0); +} + +void FileSystemDetailActionsFeatureTest::triggerRejectsDisabledBridgeActionId() +{ + FileSystemDetailActionsFeature feature; + int executeCount = 0; + feature.runtime().bridgeHooks().fileSystemMenuItems = [](const SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions { + fileSystemAction(QStringLiteral("bridge.ai"), + QStringLiteral("发送到AI助手"), + QStringLiteral("kylin-ai-symbolic"), + false, + true, + FileSystemMenuItemDefinition::Role::SendToAiAssistant)}; + }; + feature.runtime().bridgeHooks().executeAction = + [&executeCount](const QString &, const SingleFileSystemMenuContext &) { + ++executeCount; + return true; + }; + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + QCOMPARE(model->rowCount(), 4); + QVERIFY(!feature.trigger(QStringLiteral("bridge.ai"))); + QCOMPARE(executeCount, 0); +} + +void FileSystemDetailActionsFeatureTest::rebuildInvalidatesOldBridgeActionIds() +{ + FileSystemDetailActionsFeature feature; + int generation = 0; + feature.runtime().bridgeHooks().fileSystemMenuItems = [&](const SingleFileSystemMenuContext &) { + return FileSystemMenuItemDefinitions { + assistantAction(QStringLiteral("bridge.ai.%1").arg(++generation))}; + }; + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + auto *model = feature.actionsModel(); + QVERIFY(model != nullptr); + const QString firstBridgeActionId = idAt(model, 0); + QVERIFY(!firstBridgeActionId.isEmpty()); + + feature.rebuildForResult(QStringLiteral("/tmp/demo.txt"), int(ResourceType::File)); + + const QString rebuiltBridgeActionId = idAt(model, 0); + QVERIFY(!rebuiltBridgeActionId.isEmpty()); + QVERIFY(firstBridgeActionId != rebuiltBridgeActionId); + QVERIFY(!feature.trigger(firstBridgeActionId)); +} + +QTEST_MAIN(FileSystemDetailActionsFeatureTest) + +#include "file-system-detail-actions-feature-test.moc" diff --git a/ukui-search-qml/autotest/search-session-test.cpp b/ukui-search-qml/autotest/search-session-test.cpp index 2e9b5c5a302db195baa636deacfe87a209e81c4b..b8a0477191789a76218236afeff805ede4bfb802 100644 --- a/ukui-search-qml/autotest/search-session-test.cpp +++ b/ukui-search-qml/autotest/search-session-test.cpp @@ -16,6 +16,7 @@ #include #include "best-match-model.h" +#include "context-menu-session-lifecycle-interface.h" #include "generic-result-role-names.h" #define private public #include "plugin-result-model.h" @@ -688,6 +689,7 @@ private: class FakeSearchWidgetPlugin : public QObject, public UkuiQuick::WidgetInterface, public UkuiSearch::SearchExecutionInterface, + public UkuiSearch::ContextMenuSessionLifecycleInterface, public UkuiSearch::ResultMetadataProviderInterface, public UkuiSearch::ResultVisibilityHintInterface { @@ -747,6 +749,11 @@ public: [[nodiscard]] QString defaultFilterKey() const override { return m_defaultFilterKey; } + void closeActiveContextMenuSession() override + { + ++m_closeActiveContextMenuSessionCallCount; + } + void setVisibleResults(const QList &results) override { m_lastVisibleResults = results; @@ -807,6 +814,11 @@ public: [[nodiscard]] int prioritizeCallCount() const { return m_prioritizeCallCount; } + [[nodiscard]] int closeActiveContextMenuSessionCallCount() const + { + return m_closeActiveContextMenuSessionCallCount; + } + void resetVisibilityInstrumentation() { m_lastVisibleResults.clear(); @@ -834,6 +846,7 @@ private: int m_visibleResultsCallCount = 0; UkuiSearch::ResultData m_lastPrioritizedResult; int m_prioritizeCallCount = 0; + int m_closeActiveContextMenuSessionCallCount = 0; }; class FakePageCategoryPlugin : public QObject, @@ -927,10 +940,13 @@ private Q_SLOTS: void bestMatchModelRanksCanonicalEquivalentGraphemeSubsequenceMatches(); void resultMenuItemDefinitionUsesValueSemantics(); void resultActionControllerBuildsMenuForSelectedRow(); + void resultActionControllerBuildsMenuIconsFromDirectIcons(); void resultActionControllerBuildsSubmenuAndSeparatorItems(); void resultActionControllerClearDeletesMenu(); void resultActionControllerCreatesNewMenuPerOpen(); void resultActionControllerExecutesMenuActionWhenMenuStartsHiding(); + void resultActionControllerNotifiesPluginWhenMenuIsDestroyed(); + void resultActionControllerNotifiesPluginWhenBestMatchMenuIsDestroyed(); void resultActionControllerClearsContextWhenTargetChanges(); void resultActionControllerKeepsContextWhenBestMatchTopRowStaysSame(); void resultActionControllerClearsContextWhenBestMatchRowChanges(); @@ -2503,6 +2519,37 @@ void SearchSessionTest::resultActionControllerBuildsMenuForSelectedRow() QVERIFY(action->isVisible()); } +void SearchSessionTest::resultActionControllerBuildsMenuIconsFromDirectIcons() +{ + SelectableResultListModel sourceModel; + sourceModel.append({QStringLiteral("alpha"), 10, QDateTime::currentDateTime()}); + + auto openAction = UkuiSearch::ResultMenuItemDefinition::action( + QStringLiteral("open:alpha"), QStringLiteral("Open alpha")); + openAction.setIcon(makeSolidColorIcon(QColor(QStringLiteral("#2a7fff")))); + + auto submenu = UkuiSearch::ResultMenuItemDefinition::submenu( + QStringLiteral("More"), + {UkuiSearch::ResultMenuItemDefinition::action( + QStringLiteral("copy:alpha"), QStringLiteral("Copy alpha"))}); + submenu.setIcon(makeSolidColorIcon(QColor(QStringLiteral("#e95420")))); + + sourceModel.setContextMenuItems({openAction, submenu}, QStringLiteral("open:alpha")); + + UkuiSearch::ResultActionController controller; + QVERIFY(controller.openContext(&sourceModel, 0, nullptr, 0, 0)); + + auto *menu = controller.menu(); + QVERIFY(menu); + QCOMPARE(menu->actions().size(), 2); + QCOMPARE( + iconTopLeftColor(menu->actions().at(0)->icon()), QColor(QStringLiteral("#2a7fff"))); + QVERIFY(menu->actions().at(1)->menu()); + QCOMPARE( + iconTopLeftColor(menu->actions().at(1)->menu()->icon()), + QColor(QStringLiteral("#e95420"))); +} + void SearchSessionTest::resultActionControllerBuildsSubmenuAndSeparatorItems() { SelectableResultListModel sourceModel; @@ -2598,6 +2645,64 @@ void SearchSessionTest::resultActionControllerExecutesMenuActionWhenMenuStartsHi QCOMPARE(sourceModel.lastExecutedResult().name(), QStringLiteral("alpha")); } +void SearchSessionTest::resultActionControllerNotifiesPluginWhenMenuIsDestroyed() +{ + FakeSearchWidgetPlugin plugin; + plugin.setTypedMenuItems( + {UkuiSearch::ResultMenuItemDefinition::action( + QStringLiteral("open:alpha"), QStringLiteral("Open alpha"))}, + QStringLiteral("open:alpha")); + + UkuiSearch::PluginResultModel model(nullptr); + model.m_executionInterfaceOverride = &plugin; + appendPluginResults( + model, + {makePluginResult(QStringLiteral("file://alpha"), QStringLiteral("alpha"), 2026, 10)}); + + UkuiSearch::ResultActionController controller; + QVERIFY(controller.openContext(&model, 0, nullptr, 0, 0)); + + QPointer menu = controller.menu(); + QVERIFY(menu); + QCOMPARE(plugin.closeActiveContextMenuSessionCallCount(), 0); + + controller.clear(); + + QTRY_VERIFY(menu.isNull()); + QCOMPARE(plugin.closeActiveContextMenuSessionCallCount(), 1); +} + +void SearchSessionTest::resultActionControllerNotifiesPluginWhenBestMatchMenuIsDestroyed() +{ + FakeSearchWidgetPlugin filesPlugin; + filesPlugin.setTypedMenuItems( + {UkuiSearch::ResultMenuItemDefinition::action( + QStringLiteral("open:alpha"), QStringLiteral("Open alpha"))}, + QStringLiteral("open:alpha")); + + UkuiSearch::PluginResultModel files(nullptr); + files.m_executionInterfaceOverride = &filesPlugin; + appendPluginResults( + files, + {makePluginResult(QStringLiteral("file://alpha"), QStringLiteral("alpha"), 2027, 10)}); + + UkuiSearch::BestMatchModel model; + model.setKeyword(QStringLiteral("alpha")); + model.setSources({{1, 0, &files}}); + + UkuiSearch::ResultActionController controller; + QVERIFY(controller.openContext(&model, 0, nullptr, 0, 0)); + + QPointer menu = controller.menu(); + QVERIFY(menu); + QCOMPARE(filesPlugin.closeActiveContextMenuSessionCallCount(), 0); + + controller.clear(); + + QTRY_VERIFY(menu.isNull()); + QCOMPARE(filesPlugin.closeActiveContextMenuSessionCallCount(), 1); +} + void SearchSessionTest::resultActionControllerClearsContextWhenTargetChanges() { SelectableResultListModel sourceModel; diff --git a/ukui-search-qml/src/best-match-model.cpp b/ukui-search-qml/src/best-match-model.cpp index caf820f3e1dd80f069873535ba80d2ab451fa8f8..7392c58256e71dfd7404aa3ada673e4af0377c0f 100644 --- a/ukui-search-qml/src/best-match-model.cpp +++ b/ukui-search-qml/src/best-match-model.cpp @@ -397,6 +397,14 @@ bool BestMatchModel::executeAction(const QString &actionId, const QPersistentMod return false; } +void BestMatchModel::contextMenuClosed(const QPersistentModelIndex &target) +{ + auto *sourceModelObject = const_cast(target.model()); + if (auto *sourceActions = actionInterface(sourceModelObject)) { + sourceActions->contextMenuClosed(target); + } +} + int BestMatchModel::currentIndex() const { if (m_selectionSession) { diff --git a/ukui-search-qml/src/best-match-model.h b/ukui-search-qml/src/best-match-model.h index b377d9cfd0c6a768f04a2480fb8bdb24ec190268..080a51455f83a4c9506fe1fd95490307ea77bfee 100644 --- a/ukui-search-qml/src/best-match-model.h +++ b/ukui-search-qml/src/best-match-model.h @@ -49,6 +49,7 @@ public: ResultMenuItemDefinitions contextMenuItems(const QPersistentModelIndex &target) const override; Q_INVOKABLE QString defaultActionId(const QPersistentModelIndex &target) const override; Q_INVOKABLE bool executeAction(const QString &actionId, const QPersistentModelIndex &target) override; + void contextMenuClosed(const QPersistentModelIndex &target) override; int currentIndex() const; void setSelectionSession(SearchSession *session); Q_INVOKABLE void setCurrentIndex(int index); diff --git a/ukui-search-qml/src/plugin-result-model.cpp b/ukui-search-qml/src/plugin-result-model.cpp index 85870014ef8be7410e10734f11adae92eebd9c8e..72ba1f70942f144d20da5413a13ff3f7946cdecb 100644 --- a/ukui-search-qml/src/plugin-result-model.cpp +++ b/ukui-search-qml/src/plugin-result-model.cpp @@ -24,6 +24,7 @@ #include "generic-result-role-names.h" #include "result-selection-utils.h" #include "search-history-recorder-feature.h" +#include "context-menu-session-lifecycle-interface.h" #include "result-metadata-provider-interface.h" #include "search-execution-interface.h" @@ -314,6 +315,19 @@ bool PluginResultModel::executeAction(const QString &actionId, const QPersistent return executeAction(actionId, resultForActionTarget(target)); } +void PluginResultModel::contextMenuClosed(const QPersistentModelIndex &target) +{ + Q_UNUSED(target); + + auto *lifecycleInterface + = dynamic_cast(executionInterface()); + if (!lifecycleInterface) { + return; + } + + lifecycleInterface->closeActiveContextMenuSession(); +} + ResultMenuItemDefinitions PluginResultModel::contextMenuItems(const ResultData &result) const { if (result == ResultData {}) { diff --git a/ukui-search-qml/src/plugin-result-model.h b/ukui-search-qml/src/plugin-result-model.h index 761142c8405b99841f623e50097013fbf5fb5fbb..84b6af5ddb165765cbceec4f4827d272226aa1cc 100644 --- a/ukui-search-qml/src/plugin-result-model.h +++ b/ukui-search-qml/src/plugin-result-model.h @@ -75,6 +75,7 @@ public: ResultMenuItemDefinitions contextMenuItems(const QPersistentModelIndex &target) const override; QString defaultActionId(const QPersistentModelIndex &target) const override; bool executeAction(const QString &actionId, const QPersistentModelIndex &target) override; + void contextMenuClosed(const QPersistentModelIndex &target) override; ResultMenuItemDefinitions contextMenuItems(const ResultData &result) const; QString defaultActionId(const ResultData &result) const; bool executeAction(const QString &actionId, const ResultData &result); diff --git a/ukui-search-qml/src/result-action-controller.cpp b/ukui-search-qml/src/result-action-controller.cpp index bbfd3de2b99f5873ec64cf9f943d86ec58efe83a..ec47df083b2405b54c2d15e9a8364d6d6cbdd721 100644 --- a/ukui-search-qml/src/result-action-controller.cpp +++ b/ukui-search-qml/src/result-action-controller.cpp @@ -11,6 +11,20 @@ namespace UkuiSearch { +namespace { + +void notifyContextMenuClosed(QObject *modelObject, const QPersistentModelIndex &target) +{ + auto *actionsModel = dynamic_cast(modelObject); + if (!actionsModel) { + return; + } + + actionsModel->contextMenuClosed(target); +} + +} // namespace + ResultActionController::ResultActionController(QObject *parent) : QObject(parent) { @@ -71,6 +85,7 @@ bool ResultActionController::openContext(QObject *model, int row, QObject *sourc return; } + notifyContextMenuClosed(m_currentModelObject.data(), m_currentTarget); m_menu = nullptr; releaseContextBindings(); m_currentTarget = QPersistentModelIndex(); @@ -306,8 +321,16 @@ void ResultActionController::releaseMenu() } auto *menu = m_menu.data(); + const auto target = m_currentTarget; + const QPointer modelObject = m_currentModelObject; m_menu = nullptr; disconnect(menu, nullptr, this, nullptr); + // clear()/重新弹菜单这类路径会主动 disconnect 当前 menu 与 controller 的旧连接, + // 因此这里补挂一个只负责“菜单对象真正销毁后再通知模型”的 destroyed 回调, + // 确保 plugin 侧能在精确的生命周期点释放 peony QAction/QMenu 临时对象树。 + QObject::connect(menu, &QObject::destroyed, [modelObject, target] { + notifyContextMenuClosed(modelObject.data(), target); + }); if (menu->isVisible()) { menu->close(); return; @@ -337,7 +360,9 @@ bool ResultActionController::rebuildMenu(QMenu *menu, const ResultMenuItemDefini auto *action = menu->addAction(item.text()); action->setData(item.id()); - if (!item.iconName().isEmpty()) { + if (!item.icon().isNull()) { + action->setIcon(item.icon()); + } else if (!item.iconName().isEmpty()) { action->setIcon(QIcon::fromTheme(item.iconName())); } action->setEnabled(item.enabled()); @@ -361,7 +386,9 @@ bool ResultActionController::rebuildMenu(QMenu *menu, const ResultMenuItemDefini case ResultMenuItemDefinition::Kind::Submenu: { auto *submenu = new QMenu(menu); submenu->setTitle(item.text()); - if (!item.iconName().isEmpty()) { + if (!item.icon().isNull()) { + submenu->setIcon(item.icon()); + } else if (!item.iconName().isEmpty()) { submenu->setIcon(QIcon::fromTheme(item.iconName())); } diff --git a/ukui-search-qml/src/result-action-model-interface.h b/ukui-search-qml/src/result-action-model-interface.h index 5131370db5df19f51ea9ea99ba40855e170a99fd..42211617f7ad93d9e52622399331f3530ee7d7e7 100644 --- a/ukui-search-qml/src/result-action-model-interface.h +++ b/ukui-search-qml/src/result-action-model-interface.h @@ -18,6 +18,10 @@ public: virtual ResultMenuItemDefinitions contextMenuItems(const QPersistentModelIndex &target) const = 0; virtual QString defaultActionId(const QPersistentModelIndex &target) const = 0; virtual bool executeAction(const QString &actionId, const QPersistentModelIndex &target) = 0; + virtual void contextMenuClosed(const QPersistentModelIndex &target) + { + Q_UNUSED(target); + } }; } // namespace UkuiSearch diff --git a/ukui-search-qml/src/result-sort-model.cpp b/ukui-search-qml/src/result-sort-model.cpp index 1939fea8beb78a514365bc1fe7c75dd075057210..0ece6226bf4ee42ab300af24cf8e8531697552a8 100644 --- a/ukui-search-qml/src/result-sort-model.cpp +++ b/ukui-search-qml/src/result-sort-model.cpp @@ -357,6 +357,14 @@ bool ResultSortModel::executeAction(const QString &actionId, const QPersistentMo return false; } +void ResultSortModel::contextMenuClosed(const QPersistentModelIndex &target) +{ + auto *sourceModelObject = const_cast(target.model()); + if (auto *sourceActions = actionInterface(sourceModelObject)) { + sourceActions->contextMenuClosed(target); + } +} + int ResultSortModel::resolveCurrentIndex() const { if (!m_selectionSession || m_selectionSession->activeInstanceId() != m_instanceId) { diff --git a/ukui-search-qml/src/result-sort-model.h b/ukui-search-qml/src/result-sort-model.h index ea9eda1b1d9bd47956770286d4166afb21fbbe50..711eb8ce508974d712786948b8b4a8620b20ffbe 100644 --- a/ukui-search-qml/src/result-sort-model.h +++ b/ukui-search-qml/src/result-sort-model.h @@ -65,6 +65,7 @@ public: ResultMenuItemDefinitions contextMenuItems(const QPersistentModelIndex &target) const override; Q_INVOKABLE QString defaultActionId(const QPersistentModelIndex &target) const override; Q_INVOKABLE bool executeAction(const QString &actionId, const QPersistentModelIndex &target) override; + void contextMenuClosed(const QPersistentModelIndex &target) override; int currentIndex() const; void setSelectionSession(SearchSession *session, int instanceId); Q_INVOKABLE void setCurrentIndex(int index); diff --git a/ukui-search-qml/src/search-top-results-model.cpp b/ukui-search-qml/src/search-top-results-model.cpp index 7116740f4d3e0548457a0e9d05366afd353ed8d1..48f311a348681e0b30e96941f651cc918ad261c3 100644 --- a/ukui-search-qml/src/search-top-results-model.cpp +++ b/ukui-search-qml/src/search-top-results-model.cpp @@ -212,6 +212,14 @@ bool SearchTopResultsModel::executeAction(const QString &actionId, const QPersis return false; } +void SearchTopResultsModel::contextMenuClosed(const QPersistentModelIndex &target) +{ + auto *sourceModelObject = const_cast(target.model()); + if (auto *sourceActions = actionInterface(sourceModelObject)) { + sourceActions->contextMenuClosed(target); + } +} + int SearchTopResultsModel::currentIndex() const { return m_currentIndexCache; diff --git a/ukui-search-qml/src/search-top-results-model.h b/ukui-search-qml/src/search-top-results-model.h index 5935197610bd8a6ec4a6df07395554ac0f64e6e1..133bd2439d2bd834f19547d24219b0994aac2450 100644 --- a/ukui-search-qml/src/search-top-results-model.h +++ b/ukui-search-qml/src/search-top-results-model.h @@ -42,6 +42,7 @@ public: ResultMenuItemDefinitions contextMenuItems(const QPersistentModelIndex &target) const override; Q_INVOKABLE QString defaultActionId(const QPersistentModelIndex &target) const override; Q_INVOKABLE bool executeAction(const QString &actionId, const QPersistentModelIndex &target) override; + void contextMenuClosed(const QPersistentModelIndex &target) override; int currentIndex() const; void setSelectionSession(SearchSession *session, int instanceId); Q_INVOKABLE void setCurrentIndex(int index); diff --git a/ukui-search-qml/widgets/CMakeLists.txt b/ukui-search-qml/widgets/CMakeLists.txt index 1a1d833ae58261c6eaaad5c1f5e7b97b041ecf05..712ad17eaa30f1a92a88b121782f49707fd0bc95 100644 --- a/ukui-search-qml/widgets/CMakeLists.txt +++ b/ukui-search-qml/widgets/CMakeLists.txt @@ -1,6 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(widgets VERSION 4.23) +add_subdirectory(common) add_subdirectory(org.ukui.search.fileSearch) add_subdirectory(org.ukui.search.directorySearch) add_subdirectory(org.ukui.search.aiAnswer) diff --git a/ukui-search-qml/widgets/common/CMakeLists.txt b/ukui-search-qml/widgets/common/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..d41456653352d82792ccbaf0d7815fb8cc6aa26f --- /dev/null +++ b/ukui-search-qml/widgets/common/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) + +project(ukui-search-widgets-common VERSION 4.23) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(QT NAMES Qt6 Qt5 COMPONENTS Core Gui REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui REQUIRED) +find_package(ukui-quick COMPONENTS framework REQUIRED) + +add_library(ukui-search-widgets-common STATIC + plugin/detail-action-spec.h + plugin/detail-action-list-model.cpp + plugin/detail-action-list-model.h + plugin/file-system-detail-actions-feature.cpp + plugin/file-system-detail-actions-feature.h + plugin/file-system-detail-actions-feature-provider.cpp + plugin/file-system-detail-actions-feature-provider.h +) + +set_target_properties(ukui-search-widgets-common PROPERTIES + POSITION_INDEPENDENT_CODE ON +) + +target_include_directories(ukui-search-widgets-common PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/plugin + ${CMAKE_SOURCE_DIR}/libsearch + ${CMAKE_SOURCE_DIR}/libsearch/plugininterface +) + +target_link_libraries(ukui-search-widgets-common PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + ukui-search + ukui-quick::framework +) diff --git a/ukui-search-qml/widgets/common/plugin/detail-action-list-model.cpp b/ukui-search-qml/widgets/common/plugin/detail-action-list-model.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6d960554d23334a231425363ae63289e47f9eb2c --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/detail-action-list-model.cpp @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "detail-action-list-model.h" + +namespace UkuiSearch { + +DetailActionListModel::DetailActionListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int DetailActionListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_actions.size(); +} + +QVariant DetailActionListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_actions.size()) { + return {}; + } + + const auto &action = m_actions.at(index.row()); + switch (role) { + case IdRole: + return action.id; + case TextRole: + return action.text; + case IconNameRole: + return action.iconName; + case EnabledRole: + return action.enabled; + case VisibleRole: + return action.visible; + case RoleRole: + return static_cast(action.role); + default: + return {}; + } +} + +QHash DetailActionListModel::roleNames() const +{ + return { + {IdRole, QByteArrayLiteral("id")}, + {TextRole, QByteArrayLiteral("text")}, + {IconNameRole, QByteArrayLiteral("iconName")}, + {EnabledRole, QByteArrayLiteral("enabled")}, + {VisibleRole, QByteArrayLiteral("visible")}, + {RoleRole, QByteArrayLiteral("role")}}; +} + +void DetailActionListModel::setActions(const QList &actions) +{ + beginResetModel(); + m_actions = actions; + endResetModel(); +} + +QList DetailActionListModel::actions() const +{ + return m_actions; +} + +} // namespace UkuiSearch diff --git a/ukui-search-qml/widgets/common/plugin/detail-action-list-model.h b/ukui-search-qml/widgets/common/plugin/detail-action-list-model.h new file mode 100644 index 0000000000000000000000000000000000000000..14085e3c0434c4efc4bd283cd5f9d65e45709a7c --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/detail-action-list-model.h @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_DETAIL_ACTION_LIST_MODEL_H +#define UKUI_SEARCH_DETAIL_ACTION_LIST_MODEL_H + +#include +#include + +#include "detail-action-spec.h" + +namespace UkuiSearch { + +class DetailActionListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + IdRole = Qt::UserRole + 1, + TextRole, + IconNameRole, + EnabledRole, + VisibleRole, + RoleRole + }; + Q_ENUM(Role) + + explicit DetailActionListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void setActions(const QList &actions); + [[nodiscard]] QList actions() const; + +private: + QList m_actions; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_DETAIL_ACTION_LIST_MODEL_H diff --git a/ukui-search-qml/widgets/common/plugin/detail-action-spec.h b/ukui-search-qml/widgets/common/plugin/detail-action-spec.h new file mode 100644 index 0000000000000000000000000000000000000000..2ec778b862e1b43067618d4a7253c30ec22bce10 --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/detail-action-spec.h @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_DETAIL_ACTION_SPEC_H +#define UKUI_SEARCH_DETAIL_ACTION_SPEC_H + +#include + +#include "filesystemmenu/file-system-menu-item-definition.h" + +namespace UkuiSearch { + +struct DetailActionSpec +{ + QString id; + QString text; + QString iconName; + bool enabled = true; + bool visible = true; + FileSystemMenuItemDefinition::Role role = FileSystemMenuItemDefinition::Role::Unknown; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_DETAIL_ACTION_SPEC_H diff --git a/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.cpp b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.cpp new file mode 100644 index 0000000000000000000000000000000000000000..18705d8467ccab046d15dab2d0a9918f22416f8b --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.cpp @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "file-system-detail-actions-feature-provider.h" + +#include "file-system-detail-actions-feature.h" + +namespace UkuiSearch { + +FileSystemDetailActionsFeatureProvider::FileSystemDetailActionsFeatureProvider( + const QString &featureKey, FileSystemDetailActionsFeature *feature, QObject *parent) + : QObject(parent) + , m_featureKey(featureKey) + , m_feature(feature) +{ +} + +bool FileSystemDetailActionsFeatureProvider::hasFeature(const QString &key) const +{ + return key == m_featureKey && m_feature; +} + +QObject *FileSystemDetailActionsFeatureProvider::feature(const QString &key) +{ + return hasFeature(key) ? m_feature : nullptr; +} + +QStringList FileSystemDetailActionsFeatureProvider::availableFeatures() const +{ + return m_feature ? QStringList {m_featureKey} : QStringList {}; +} + +} // namespace UkuiSearch diff --git a/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.h b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.h new file mode 100644 index 0000000000000000000000000000000000000000..66d50f4cf408a5fbfee0fd43b6bc77427dd58fe2 --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature-provider.h @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_PROVIDER_H +#define UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_PROVIDER_H + +#include +#include + +#include + +namespace UkuiSearch { + +class FileSystemDetailActionsFeature; + +class FileSystemDetailActionsFeatureProvider : public QObject, + public UkuiQuick::WidgetFeatureProvider +{ +public: + explicit FileSystemDetailActionsFeatureProvider(const QString &featureKey, + FileSystemDetailActionsFeature *feature, + QObject *parent = nullptr); + + bool hasFeature(const QString &key) const override; + QObject *feature(const QString &key) override; + QStringList availableFeatures() const override; + +private: + QString m_featureKey; + QPointer m_feature; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_PROVIDER_H diff --git a/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.cpp b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.cpp new file mode 100644 index 0000000000000000000000000000000000000000..072b5e57f7bf9e3160cce6dfd102f3f512a59d97 --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.cpp @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "file-system-detail-actions-feature.h" + +#include +#include + +#include + +namespace UkuiSearch { + +FileSystemDetailActionsFeature::FileSystemDetailActionsFeature(QObject *parent) + : QObject(parent) + , m_actionsModel(this) +{ +} + +QAbstractItemModel *FileSystemDetailActionsFeature::actionsModel() +{ + return &m_actionsModel; +} + +FileSystemMenuRuntime &FileSystemDetailActionsFeature::runtime() +{ + return m_runtime; +} + +void FileSystemDetailActionsFeature::rebuildForResult(const ResultData &result) +{ + rebuildForResult(result.actionKey(), int(result.resourceType())); +} + +void FileSystemDetailActionsFeature::rebuildForResult(const QString &path, int resourceType) +{ + const QFileInfo fileInfo(path); + const auto typedResourceType = static_cast(resourceType); + const bool validResourceType = typedResourceType == ResourceType::File + || typedResourceType == ResourceType::Directory; + + if (path.isEmpty() || !fileInfo.isAbsolute() || !validResourceType) { + clearState(); + return; + } + + SingleFileSystemMenuContext context; + context.targetLocalPath = path; + context.targetUri = QUrl::fromLocalFile(path).toString(); + context.containerUri = QUrl::fromLocalFile(fileInfo.absolutePath()).toString(); + context.resourceType = typedResourceType; + + rebuildForContext(context); +} + +void FileSystemDetailActionsFeature::rebuildForContext(const SingleFileSystemMenuContext &context) +{ + const QFileInfo fileInfo(context.targetLocalPath); + if (context.targetLocalPath.isEmpty() || !fileInfo.isAbsolute()) { + clearState(); + return; + } + + QList filteredActions; + const auto menuItems = m_runtime.filesystemMenuItemsFor(context); + for (const auto &item : menuItems) { + if (item.item().kind() != ResultMenuItemDefinition::Kind::Action + || !supportsDetailRole(item.role())) { + continue; + } + + DetailActionSpec action; + action.id = item.item().id(); + action.text = item.item().text(); + action.iconName = item.item().iconName(); + action.enabled = item.item().enabled(); + action.visible = item.item().visible(); + action.role = item.role(); + filteredActions.append(action); + } + + std::sort(filteredActions.begin(), filteredActions.end(), [](const DetailActionSpec &left, + const DetailActionSpec &right) { + return roleSortOrder(left.role) < roleSortOrder(right.role); + }); + + m_currentContext = context; + m_hasActiveContext = true; + m_currentActionIds.clear(); + for (const auto &action : filteredActions) { + m_currentActionIds.insert(action.id); + } + m_actionsModel.setActions(filteredActions); +} + +bool FileSystemDetailActionsFeature::trigger(const QString &actionId) +{ + if (!m_hasActiveContext || actionId.isEmpty() || !m_currentActionIds.contains(actionId)) { + return false; + } + + for (const auto &action : m_actionsModel.actions()) { + if (action.id != actionId) { + continue; + } + + if (!action.enabled || !action.visible) { + return false; + } + + return m_runtime.executeAction(actionId, m_currentContext); + } + + return m_runtime.executeAction(actionId, m_currentContext); +} + +bool FileSystemDetailActionsFeature::supportsDetailRole(FileSystemMenuItemDefinition::Role role) +{ + switch (role) { + case FileSystemMenuItemDefinition::Role::SendToAiAssistant: + case FileSystemMenuItemDefinition::Role::Open: + case FileSystemMenuItemDefinition::Role::Reveal: + case FileSystemMenuItemDefinition::Role::CopyPath: + return true; + default: + return false; + } +} + +int FileSystemDetailActionsFeature::roleSortOrder(FileSystemMenuItemDefinition::Role role) +{ + switch (role) { + case FileSystemMenuItemDefinition::Role::SendToAiAssistant: + return 0; + case FileSystemMenuItemDefinition::Role::Open: + return 1; + case FileSystemMenuItemDefinition::Role::Reveal: + return 2; + case FileSystemMenuItemDefinition::Role::CopyPath: + return 3; + default: + return 100; + } +} + +void FileSystemDetailActionsFeature::clearState() +{ + m_currentContext = {}; + m_currentActionIds.clear(); + m_hasActiveContext = false; + m_actionsModel.setActions({}); +} + +} // namespace UkuiSearch diff --git a/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.h b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.h new file mode 100644 index 0000000000000000000000000000000000000000..46c64bc753825d7864c9076b174596c86defc76a --- /dev/null +++ b/ukui-search-qml/widgets/common/plugin/file-system-detail-actions-feature.h @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_H +#define UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_H + +#include +#include + +#include "detail-action-list-model.h" +#include "filesystemmenu/file-system-menu-runtime.h" +#include "plugininterface/search-contract-types.h" + +namespace UkuiSearch { + +class FileSystemDetailActionsFeature : public QObject +{ + Q_OBJECT + Q_PROPERTY(QAbstractItemModel *actionsModel READ actionsModel CONSTANT) + +public: + explicit FileSystemDetailActionsFeature(QObject *parent = nullptr); + + [[nodiscard]] QAbstractItemModel *actionsModel(); + [[nodiscard]] FileSystemMenuRuntime &runtime(); + + Q_INVOKABLE void rebuildForResult(const ResultData &result); + Q_INVOKABLE void rebuildForResult(const QString &path, int resourceType); + Q_INVOKABLE bool trigger(const QString &actionId); + +private: + [[nodiscard]] static bool supportsDetailRole(FileSystemMenuItemDefinition::Role role); + [[nodiscard]] static int roleSortOrder(FileSystemMenuItemDefinition::Role role); + void rebuildForContext(const SingleFileSystemMenuContext &context); + void clearState(); + +private: + DetailActionListModel m_actionsModel; + FileSystemMenuRuntime m_runtime; + SingleFileSystemMenuContext m_currentContext; + QSet m_currentActionIds; + bool m_hasActiveContext = false; +}; + +} // namespace UkuiSearch + +#endif // UKUI_SEARCH_FILE_SYSTEM_DETAIL_ACTIONS_FEATURE_H diff --git a/ukui-search-qml/widgets/org.ukui.search.directorySearch/CMakeLists.txt b/ukui-search-qml/widgets/org.ukui.search.directorySearch/CMakeLists.txt index ff88d579ea1f810c192a5dff07116811b1b212e2..b19922261c85918da4f1649f4fdc0e84f15650f9 100644 --- a/ukui-search-qml/widgets/org.ukui.search.directorySearch/CMakeLists.txt +++ b/ukui-search-qml/widgets/org.ukui.search.directorySearch/CMakeLists.txt @@ -36,6 +36,7 @@ target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/libsearch ${CMAKE_SOURCE_DIR}/libsearch/dirwatcher ${CMAKE_SOURCE_DIR}/libsearch/plugininterface + ${CMAKE_SOURCE_DIR}/ukui-search-qml/widgets/common/plugin ${${GSETTINGS_PKG}_INCLUDE_DIRS} ) @@ -47,6 +48,7 @@ target_link_libraries(${PROJECT_NAME} Qt${QT_VERSION_MAJOR}::Widgets PkgConfig::${GSETTINGS_PKG} ukui-search + ukui-search-widgets-common ukui-quick::core ukui-quick::framework ) diff --git a/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.cpp b/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.cpp index 3f2860b661e2e5b3ddeb60b6014e28ac46843a6b..33608843e6f683d04eb4ba74d38847915fa4b377 100644 --- a/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.cpp +++ b/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.cpp @@ -33,15 +33,14 @@ #include "pathsearch/path-ranker.h" #include "result-metadata-option-utils.h" #include "private/search-plugin-top-n-publisher_p.h" +#include "file-system-detail-actions-feature.h" +#include "file-system-detail-actions-feature-provider.h" namespace { // The visible top-N is still controlled by request.maxResults; this only bounds // provider-side streaming and candidate buffering when the request omits a smaller limit. constexpr int kMaxProviderResults = 9999; -const QString kOpenActionId = QStringLiteral("open"); -const QString kRevealActionId = QStringLiteral("reveal"); -const QString kCopyPathActionId = QStringLiteral("copyPath"); QVariantList directorySortOptions() { @@ -119,7 +118,25 @@ UkuiSearch::ResultData toResultData(const UkuiSearch::PathCandidate &candidate) bool hasLocalDirectoryActionKey(const UkuiSearch::ResultData &result) { - return QFileInfo(result.actionKey()).isAbsolute(); + return result.resourceType() == UkuiSearch::ResourceType::Directory + && QFileInfo(result.actionKey()).isAbsolute(); +} + +UkuiSearch::SingleFileSystemMenuContext buildDirectoryMenuContext(const UkuiSearch::ResultData &result) +{ + if (!hasLocalDirectoryActionKey(result)) { + return {}; + } + + UkuiSearch::SingleFileSystemMenuContext context; + // 对目录结果来说,target 是目录本身,而 container 则是它的直接父目录。 + // 这样 bridge 给 peony 插件传递的“当前目录 + 选中项”语义才与文件管理器保持一致。 + context.targetLocalPath = result.actionKey(); + context.targetUri = QUrl::fromLocalFile(result.actionKey()).toString(); + context.containerUri + = QUrl::fromLocalFile(QFileInfo(result.actionKey()).absolutePath()).toString(); + context.resourceType = UkuiSearch::ResourceType::Directory; + return context; } bool samePublishedCandidate(const UkuiSearch::PathCandidate &left, @@ -171,6 +188,12 @@ UkuiSearch::SearchPluginTopNPublisherPolicy makeTopNP DirectorySearchPlugin::DirectorySearchPlugin() { + m_detailActionsFeature = new UkuiSearch::FileSystemDetailActionsFeature(this); + m_detailActionsFeatureProvider = new UkuiSearch::FileSystemDetailActionsFeatureProvider( + QStringLiteral("org.ukui.search.directorySearch.detailActions"), + m_detailActionsFeature, + this); + refreshBuiltinMenuProviderCallbacks(); m_pool = new QThreadPool(this); m_pool->setMaxThreadCount(1); } @@ -183,6 +206,11 @@ DirectorySearchPlugin::~DirectorySearchPlugin() } } +void DirectorySearchPlugin::closeActiveContextMenuSession() +{ + m_contextMenuRuntime.closeSession(); +} + size_t DirectorySearchPlugin::startSearch(const QString &keyword, UkuiSearch::SearchResultQueue searchResult) { @@ -293,50 +321,27 @@ UkuiSearch::ResourceType::Type DirectorySearchPlugin::resourceType() const return UkuiSearch::ResourceType::Directory; } -UkuiSearch::ResultMenuItemDefinitions DirectorySearchPlugin::contextMenuItems(const UkuiSearch::ResultData &result) const +QList DirectorySearchPlugin::featureProviders() { - if (!hasLocalDirectoryActionKey(result)) { - return {}; - } + return m_detailActionsFeatureProvider ? QList {m_detailActionsFeatureProvider} + : QList {}; +} - return { - UkuiSearch::ResultMenuItemDefinition::action(kOpenActionId, - tr("Open"), - QStringLiteral("document-open-symbolic")), - UkuiSearch::ResultMenuItemDefinition::action(kRevealActionId, - tr("Open path"), - QStringLiteral("folder-open-symbolic")), - UkuiSearch::ResultMenuItemDefinition::action(kCopyPathActionId, - tr("Copy path"), - QStringLiteral("edit-copy-symbolic")) - }; +UkuiSearch::ResultMenuItemDefinitions DirectorySearchPlugin::contextMenuItems(const UkuiSearch::ResultData &result) const +{ + return m_contextMenuRuntime.menuItemsFor(buildMenuContext(result)); } QString DirectorySearchPlugin::defaultActionId(const UkuiSearch::ResultData &result) const { - return hasLocalDirectoryActionKey(result) ? kOpenActionId : QString(); + return hasLocalDirectoryActionKey(result) + ? UkuiSearch::BuiltinFileSystemMenuProvider::openActionId() + : QString(); } bool DirectorySearchPlugin::executeAction(const QString &actionId, const UkuiSearch::ResultData &result) { - if (!hasLocalDirectoryActionKey(result)) { - return false; - } - - if (actionId == kOpenActionId) { - openAction(0, result.actionKey(), int(result.resourceType())); - return true; - } - if (actionId == kRevealActionId) { - openAction(1, result.actionKey(), int(result.resourceType())); - return true; - } - if (actionId == kCopyPathActionId) { - openAction(2, result.actionKey(), int(result.resourceType())); - return true; - } - - return false; + return m_contextMenuRuntime.executeAction(actionId, buildMenuContext(result)); } QVariantList DirectorySearchPlugin::availableSortOptions() const @@ -362,11 +367,58 @@ QString DirectorySearchPlugin::defaultFilterKey() const void DirectorySearchPlugin::setHooks(const DirectorySearchPluginHooks &hooks) { m_hooks = hooks; + refreshBuiltinMenuProviderCallbacks(); +} + +void DirectorySearchPlugin::refreshBuiltinMenuProviderCallbacks() const +{ + const auto applyRuntimeHooks = [this](UkuiSearch::FileSystemMenuRuntime &runtime) { + runtime.builtinCallbacks() = UkuiSearch::BuiltinFileSystemMenuProvider().callbacks; + + if (m_hooks.openDirectory) { + runtime.builtinCallbacks().open = [hook = m_hooks.openDirectory](const QString &path) { + hook(path); + return true; + }; + } + if (m_hooks.revealPath) { + runtime.builtinCallbacks().reveal = [hook = m_hooks.revealPath](const QString &path) { + hook(path); + return true; + }; + } + if (m_hooks.copyPath) { + runtime.builtinCallbacks().copyPath = [hook = m_hooks.copyPath](const QString &path) { + hook(path); + return true; + }; + } + if (m_hooks.copyUris) { + runtime.builtinCallbacks().copyUris = m_hooks.copyUris; + } + if (m_hooks.showItemProperties) { + runtime.builtinCallbacks().showItemProperties = m_hooks.showItemProperties; + } + runtime.bridgeHooks().menuItems = m_hooks.menuBridgeItems; + runtime.bridgeHooks().fileSystemMenuItems = m_hooks.detailMenuBridgeItems; + runtime.bridgeHooks().executeAction = m_hooks.menuBridgeExecuteAction; + }; + + applyRuntimeHooks(m_contextMenuRuntime); + if (m_detailActionsFeature) { + applyRuntimeHooks(m_detailActionsFeature->runtime()); + } +} + +UkuiSearch::SingleFileSystemMenuContext DirectorySearchPlugin::buildMenuContext( + const UkuiSearch::ResultData &result) const +{ + return buildDirectoryMenuContext(result); } void DirectorySearchPlugin::publishEvent(size_t searchId, - const UkuiSearch::SearchResultQueue &searchResult, - UkuiSearch::SearchResultEvent event) + const UkuiSearch::SearchResultQueue &searchResult, + UkuiSearch::SearchResultEvent event) { if (!searchResult || isSearchObsolete(searchId)) { return; diff --git a/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.h b/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.h index 37bcd49980fd7c778d913697dbc557486f82a747..7e3485371bb6be276d76ac2f6c9e99a43ac9ba94 100644 --- a/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.h +++ b/ukui-search-qml/widgets/org.ukui.search.directorySearch/plugin/directory-search-plugin.h @@ -25,13 +25,23 @@ #include #include "data-queue.h" +#include "context-menu-session-lifecycle-interface.h" #include "directorysearch/directory-search-request.h" +#include "filesystemmenu/file-system-menu-runtime.h" #include "pathsearch/path-candidate.h" #include "pathsearch/path-search-request.h" #include "result-metadata-provider-interface.h" #include "search-execution-interface.h" #include +namespace UkuiSearch { +class FileSystemDetailActionsFeature; +class FileSystemDetailActionsFeatureProvider; +} + +// 目录搜索插件的可替换 hook 集合。 +// 菜单相关入口单独暴露出来,是为了让目录搜索复用统一文件系统菜单模型的同时, +// 仍然可以按测试或宿主需求覆盖默认动作实现。 struct DirectorySearchPluginHooks { std::function(const QString &, int)> search; std::function buildRequest; @@ -44,11 +54,19 @@ struct DirectorySearchPluginHooks { std::function openDirectory; std::function revealPath; std::function copyPath; + std::function copyUris; + std::function showItemProperties; + std::function menuBridgeItems; + std::function + detailMenuBridgeItems; + std::function + menuBridgeExecuteAction; }; class DirectorySearchPlugin : public QObject, public UkuiQuick::WidgetInterface, public UkuiSearch::SearchExecutionInterface, + public UkuiSearch::ContextMenuSessionLifecycleInterface, public UkuiSearch::ResultMetadataProviderInterface { Q_OBJECT @@ -58,11 +76,13 @@ class DirectorySearchPlugin : public QObject, public: DirectorySearchPlugin(); ~DirectorySearchPlugin() override; + void closeActiveContextMenuSession() override; size_t startSearch(const QString &keyword, UkuiSearch::SearchResultQueue searchResult) override; void stopSearch() override; void openAction(int actionKey, QString key, int type) override; [[nodiscard]] UkuiSearch::ResourceType::Type resourceType() const override; + QList featureProviders() override; [[nodiscard]] UkuiSearch::ResultMenuItemDefinitions contextMenuItems(const UkuiSearch::ResultData &result) const override; [[nodiscard]] QString defaultActionId(const UkuiSearch::ResultData &result) const override; bool executeAction(const QString &actionId, const UkuiSearch::ResultData &result) override; @@ -74,6 +94,12 @@ public: void setHooks(const DirectorySearchPluginHooks &hooks); private: + // 与文件搜索插件相同:hooks 变化时刷新一次 provider 状态, + // 正常右键菜单请求直接复用现成 provider,不在热路径里重复创建对象。 + // 该方法保持 const,是因为只会刷新 mutable provider 的内部回调缓存, + // 不会改变插件实例对外暴露的对象拓扑和菜单协议。 + void refreshBuiltinMenuProviderCallbacks() const; + [[nodiscard]] UkuiSearch::SingleFileSystemMenuContext buildMenuContext(const UkuiSearch::ResultData &result) const; void publishEvent(size_t searchId, const UkuiSearch::SearchResultQueue &searchResult, UkuiSearch::SearchResultEvent event); @@ -82,6 +108,9 @@ private: size_t m_searchId = 0; // plugin-thread only; worker threads use captured ids and m_activeSearchId. std::atomic_size_t m_activeSearchId = 0; DirectorySearchPluginHooks m_hooks; + mutable UkuiSearch::FileSystemMenuRuntime m_contextMenuRuntime; + UkuiSearch::FileSystemDetailActionsFeature *m_detailActionsFeature = nullptr; + UkuiSearch::FileSystemDetailActionsFeatureProvider *m_detailActionsFeatureProvider = nullptr; QThreadPool *m_pool = nullptr; }; diff --git a/ukui-search-qml/widgets/org.ukui.search.fileSearch/CMakeLists.txt b/ukui-search-qml/widgets/org.ukui.search.fileSearch/CMakeLists.txt index 70d55886cdab28a806a9dcf7cf2a55cca8a5e520..297ed085b8465ff3bf7cdb864e390b429835517e 100644 --- a/ukui-search-qml/widgets/org.ukui.search.fileSearch/CMakeLists.txt +++ b/ukui-search-qml/widgets/org.ukui.search.fileSearch/CMakeLists.txt @@ -57,6 +57,7 @@ target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/libsearch ${CMAKE_SOURCE_DIR}/libsearch/dirwatcher ${CMAKE_SOURCE_DIR}/libsearch/plugininterface + ${CMAKE_SOURCE_DIR}/ukui-search-qml/widgets/common/plugin ) target_link_libraries(${PROJECT_NAME} @@ -67,6 +68,7 @@ target_link_libraries(${PROJECT_NAME} Qt${QT_VERSION_MAJOR}::Widgets chinese-segmentation ukui-search + ukui-search-widgets-common ukui-quick::core ukui-quick::framework xapian diff --git a/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.cpp b/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.cpp index 546cf22f39631f6eb8d1f09876afdbb50e9972b8..d759e7932515cd5c08fd22429fdb5d8084a180f1 100644 --- a/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.cpp +++ b/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.cpp @@ -43,15 +43,14 @@ #include "result-metadata-option-utils.h" #include "thumbnail-reply.h" #include "thumbnail-service.h" +#include "file-system-detail-actions-feature.h" +#include "file-system-detail-actions-feature-provider.h" namespace { // File search intentionally uses the same 9999 cap for provider requests and // the published plugin result window, matching the expanded UI result limit. constexpr int kMaxProviderResults = 9999; -const QString kOpenActionId = QStringLiteral("open"); -const QString kRevealActionId = QStringLiteral("reveal"); -const QString kCopyPathActionId = QStringLiteral("copyPath"); const QSize kThumbnailTargetSize(512, 512); const QSize kListThumbnailSize(48, 48); @@ -176,7 +175,24 @@ UkuiSearch::ResultData makeRemovalResult(const QString &path) bool hasLocalFileActionKey(const UkuiSearch::ResultData &result) { return result.resourceType() == UkuiSearch::ResourceType::File - && QFileInfo(result.actionKey()).isAbsolute(); + && QFileInfo(result.actionKey()).isAbsolute(); +} + +UkuiSearch::SingleFileSystemMenuContext buildFileMenuContext(const UkuiSearch::ResultData &result) +{ + if (!hasLocalFileActionKey(result)) { + return {}; + } + + UkuiSearch::SingleFileSystemMenuContext context; + // 搜索结果层里的 actionKey 已经是本地绝对路径,这里一次性补齐路径与 URI 两种表示, + // 供内建 provider 和 peony bridge 共享同一份上下文。 + context.targetLocalPath = result.actionKey(); + context.targetUri = QUrl::fromLocalFile(result.actionKey()).toString(); + context.containerUri + = QUrl::fromLocalFile(QFileInfo(result.actionKey()).absolutePath()).toString(); + context.resourceType = UkuiSearch::ResourceType::File; + return context; } QIcon buildThumbnailIcon(const QImage &image) @@ -211,6 +227,10 @@ UkuiSearch::SearchPluginTopNPublisherPolicy makeT FileSearchPlugin::FileSearchPlugin() { + m_detailActionsFeature = new UkuiSearch::FileSystemDetailActionsFeature(this); + m_detailActionsFeatureProvider = new UkuiSearch::FileSystemDetailActionsFeatureProvider( + QStringLiteral("org.ukui.search.fileSearch.detailActions"), m_detailActionsFeature, this); + refreshBuiltinMenuProviderCallbacks(); m_pool = new QThreadPool(this); m_pool->setMaxThreadCount(1); } @@ -223,6 +243,11 @@ FileSearchPlugin::~FileSearchPlugin() } } +void FileSearchPlugin::closeActiveContextMenuSession() +{ + m_contextMenuRuntime.closeSession(); +} + QVariantList FileSearchPlugin::availableSortOptions() const { return fileSortOptions(); @@ -313,8 +338,7 @@ size_t FileSearchPlugin::startSearch( const auto shouldCancel = [this, searchId] { return isSearchObsolete(searchId); }; - UkuiSearch::SearchPluginTopNPublisher publisher( - makeTopNPublisherPolicy()); + UkuiSearch::SearchPluginTopNPublisher publisher(makeTopNPublisherPolicy()); service.streamSearch(request, planInput, @@ -360,56 +384,65 @@ UkuiSearch::ResourceType::Type FileSearchPlugin::resourceType() const return UkuiSearch::ResourceType::File; } -UkuiSearch::ResultMenuItemDefinitions FileSearchPlugin::contextMenuItems(const UkuiSearch::ResultData &result) const +QList FileSearchPlugin::featureProviders() { - if (!hasLocalFileActionKey(result)) { - return {}; - } + return m_detailActionsFeatureProvider ? QList {m_detailActionsFeatureProvider} + : QList {}; +} - return { - UkuiSearch::ResultMenuItemDefinition::action(kOpenActionId, - tr("Open"), - QStringLiteral("document-open-symbolic")), - UkuiSearch::ResultMenuItemDefinition::action(kRevealActionId, - tr("Open path"), - QStringLiteral("folder-open-symbolic")), - UkuiSearch::ResultMenuItemDefinition::action(kCopyPathActionId, - tr("Copy path"), - QStringLiteral("edit-copy-symbolic")) - }; +UkuiSearch::ResultMenuItemDefinitions FileSearchPlugin::contextMenuItems(const UkuiSearch::ResultData &result) const +{ + return m_contextMenuRuntime.menuItemsFor(buildMenuContext(result)); } QString FileSearchPlugin::defaultActionId(const UkuiSearch::ResultData &result) const { - return hasLocalFileActionKey(result) ? kOpenActionId : QString(); + return hasLocalFileActionKey(result) ? UkuiSearch::BuiltinFileSystemMenuProvider::openActionId() + : QString(); } bool FileSearchPlugin::executeAction(const QString &actionId, const UkuiSearch::ResultData &result) { - if (!hasLocalFileActionKey(result)) { - return false; - } - - auto path = result.actionKey(); - if (actionId == kOpenActionId) { - UkuiSearch::FileUtils::openFile(path, false); - return true; - } - if (actionId == kRevealActionId) { - UkuiSearch::FileUtils::openFile(path, true); - return true; - } - if (actionId == kCopyPathActionId) { - UkuiSearch::FileUtils::copyPath(path); - return true; - } - - return false; + return m_contextMenuRuntime.executeAction(actionId, buildMenuContext(result)); } void FileSearchPlugin::setProviderHooks(const UkuiSearch::FileSearchPluginHooks &hooks) { m_providerHooks = hooks; + refreshBuiltinMenuProviderCallbacks(); +} + +void FileSearchPlugin::refreshBuiltinMenuProviderCallbacks() const +{ + const auto applyRuntimeHooks = [this](UkuiSearch::FileSystemMenuRuntime &runtime) { + runtime.builtinCallbacks() = UkuiSearch::BuiltinFileSystemMenuProvider().callbacks; + if (m_providerHooks.openFile) { + runtime.builtinCallbacks().open = m_providerHooks.openFile; + } + if (m_providerHooks.revealPath) { + runtime.builtinCallbacks().reveal = m_providerHooks.revealPath; + } + if (m_providerHooks.copyUris) { + runtime.builtinCallbacks().copyUris = m_providerHooks.copyUris; + } + if (m_providerHooks.showItemProperties) { + runtime.builtinCallbacks().showItemProperties = m_providerHooks.showItemProperties; + } + runtime.bridgeHooks().menuItems = m_providerHooks.menuBridgeItems; + runtime.bridgeHooks().fileSystemMenuItems = m_providerHooks.detailMenuBridgeItems; + runtime.bridgeHooks().executeAction = m_providerHooks.menuBridgeExecuteAction; + }; + + applyRuntimeHooks(m_contextMenuRuntime); + if (m_detailActionsFeature) { + applyRuntimeHooks(m_detailActionsFeature->runtime()); + } +} + +UkuiSearch::SingleFileSystemMenuContext FileSearchPlugin::buildMenuContext( + const UkuiSearch::ResultData &result) const +{ + return buildFileMenuContext(result); } UkuiSearch::FileSearchPlanInput FileSearchPlugin::resolvePlanInput(const UkuiSearch::FileSearchPluginHooks &hooks) const diff --git a/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.h b/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.h index cccfebdf46f7c5b05d9a89b12027f3b384533bb5..92862879e6d680c4afccdfcf89626884fdc5465d 100644 --- a/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.h +++ b/ukui-search-qml/widgets/org.ukui.search.fileSearch/plugin/file-search-plugin.h @@ -29,8 +29,10 @@ #include "file-candidate-meta.h" #include "filesearch/file-search-request.h" +#include "context-menu-session-lifecycle-interface.h" #include "result-metadata-provider-interface.h" #include "result-visibility-hint-interface.h" +#include "filesystemmenu/file-system-menu-runtime.h" #include "search-execution-interface.h" #include "thumbnail-request.h" #include @@ -39,12 +41,27 @@ class FileSearchEngineTest; namespace UkuiSearch { class ThumbnailReply; +class FileSystemDetailActionsFeature; +class FileSystemDetailActionsFeatureProvider; +// 供测试和宿主定制注入的 hook 集合。 +// 菜单相关部分只暴露“基础动作覆盖点”和“bridge 菜单替换点”: +// 1. open/reveal/copyUris/showItemProperties 对应内建动作; +// 2. menuBridgeItems 用于直接替换 peony bridge 的菜单输出,便于做确定性测试。 struct FileSearchPluginHooks { std::function resolvePlanInput; std::function(const QString &, int, const std::function &)> nameIndexSearch; std::function(const QString &, int, const std::function &)> nameDirectSearch; std::function(const QString &, int)> contentIndexSearch; + std::function openFile; + std::function revealPath; + std::function copyUris; + std::function showItemProperties; + std::function menuBridgeItems; + std::function + detailMenuBridgeItems; + std::function + menuBridgeExecuteAction; }; } // namespace UkuiSearch @@ -52,6 +69,7 @@ struct FileSearchPluginHooks { class FileSearchPlugin : public QObject, public UkuiQuick::WidgetInterface, public UkuiSearch::SearchExecutionInterface, + public UkuiSearch::ContextMenuSessionLifecycleInterface, public UkuiSearch::ResultMetadataProviderInterface, public UkuiSearch::ResultVisibilityHintInterface { @@ -61,10 +79,12 @@ class FileSearchPlugin : public QObject, public: FileSearchPlugin(); ~FileSearchPlugin() override; + void closeActiveContextMenuSession() override; size_t startSearch(const QString &keyword, UkuiSearch::SearchResultQueue searchResult) override; void stopSearch() override; UkuiSearch::ResourceType::Type resourceType() const override; + QList featureProviders() override; [[nodiscard]] UkuiSearch::ResultMenuItemDefinitions contextMenuItems(const UkuiSearch::ResultData &result) const override; [[nodiscard]] QString defaultActionId(const UkuiSearch::ResultData &result) const override; bool executeAction(const QString &actionId, const UkuiSearch::ResultData &result) override; @@ -88,6 +108,14 @@ private: }; void setProviderHooks(const UkuiSearch::FileSearchPluginHooks &hooks); + // hooks 发生变化时,原地重建内建 provider 的默认回调,再覆盖注入实现。 + // 这样既能保留默认行为合并逻辑,又能保持 m_menuComposer 持有的 provider 地址稳定。 + // 该方法声明为 const,是因为它不会改变插件对外可观察的菜单语义; + // 真正被刷新的只是 mutable provider 内部的回调表缓存。 + void refreshBuiltinMenuProviderCallbacks() const; + // 把搜索结果规范化成菜单层消费的文件系统上下文。 + // 非本地绝对路径文件会返回无效上下文,后续菜单链路会自然短路。 + [[nodiscard]] UkuiSearch::SingleFileSystemMenuContext buildMenuContext(const UkuiSearch::ResultData &result) const; [[nodiscard]] UkuiSearch::FileSearchPlanInput resolvePlanInput(const UkuiSearch::FileSearchPluginHooks &hooks) const; void publishEvent(size_t searchId, const UkuiSearch::SearchResultQueue &searchResult, @@ -108,6 +136,9 @@ private: size_t m_searchId = 0; // plugin-thread only; worker threads use captured ids and m_activeSearchId. std::atomic_size_t m_activeSearchId = 0; UkuiSearch::FileSearchPluginHooks m_providerHooks; + mutable UkuiSearch::FileSystemMenuRuntime m_contextMenuRuntime; + UkuiSearch::FileSystemDetailActionsFeature *m_detailActionsFeature = nullptr; + UkuiSearch::FileSystemDetailActionsFeatureProvider *m_detailActionsFeatureProvider = nullptr; QThreadPool *m_pool = nullptr; UkuiSearch::SearchResultQueue m_searchResultQueue; mutable QMutex m_resultsMutex;