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