diff --git a/resources.qrc b/resources.qrc
index bc09408ae841a..1e411df911a56 100644
--- a/resources.qrc
+++ b/resources.qrc
@@ -72,8 +72,11 @@
src/gui/wizard/qml/ServerPage.qml
src/gui/wizard/qml/SyncOptionsPage.qml
src/gui/wizard/qml/WizardButton.qml
+ src/gui/wizard/qml/WizardChipButton.qml
src/gui/wizard/qml/WizardComboBox.qml
src/gui/wizard/qml/WizardDialogFrame.qml
+ src/gui/wizard/qml/WizardMenu.qml
+ src/gui/wizard/qml/WizardMenuItem.qml
src/gui/wizard/qml/WizardTextField.qml
src/gui/macOS/ui/FileProviderFileDelegate.qml
src/gui/integration/FileActionsWindow.qml
diff --git a/src/gui/iconutils.cpp b/src/gui/iconutils.cpp
index f153b13ebe53c..07ff138f184a7 100644
--- a/src/gui/iconutils.cpp
+++ b/src/gui/iconutils.cpp
@@ -164,8 +164,8 @@ QImage drawSvgWithCustomFillColor(const QString &sourceSvgPath,
return {};
}
- const auto reqSize = (requestedSize.isValid() && requestedSize.height() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize();
- if (!reqSize.isValid() || !reqSize.height() || !reqSize.height()) {
+ const auto reqSize = (requestedSize.isValid() && requestedSize.width() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize();
+ if (!reqSize.isValid() || !reqSize.width() || !reqSize.height()) {
return {};
}
diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp
index 17fd2b6bfaf76..443fb9f507d20 100644
--- a/src/gui/owncloudgui.cpp
+++ b/src/gui/owncloudgui.cpp
@@ -39,6 +39,7 @@
#include "tray/trayactivationpolicy.h"
#include "tray/trayaccountappsmodel.h"
#include "search/unifiedsearchresultslistmodel.h"
+#include "search/unifiedsearchpeoplemodel.h"
#include "integration/fileactionsmodel.h"
#include "governance/applygovernancelabel.h"
#include "governance/deletegovernancelabel.h"
@@ -159,6 +160,7 @@ ownCloudGui::ownCloudGui(Application *parent)
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileDetails");
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel");
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel");
+ qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel");
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel");
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel");
qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActionsModel");
diff --git a/src/gui/search/CMakeLists.txt b/src/gui/search/CMakeLists.txt
index d4dbcddc13dbd..0320e76d51ffa 100644
--- a/src/gui/search/CMakeLists.txt
+++ b/src/gui/search/CMakeLists.txt
@@ -9,6 +9,8 @@ target_sources(nextcloudGuiSearch
unifiedsearchresult.cpp
unifiedsearchresultslistmodel.h
unifiedsearchresultslistmodel.cpp
+ unifiedsearchpeoplemodel.h
+ unifiedsearchpeoplemodel.cpp
)
ecm_add_qml_module(nextcloudGuiSearch
@@ -16,7 +18,12 @@ ecm_add_qml_module(nextcloudGuiSearch
GENERATE_PLUGIN_SOURCE
QML_FILES
SearchWindow.qml
+ UnifiedSearchCustomDateRangeDialog.qml
+ UnifiedSearchDetailHeader.qml
+ UnifiedSearchFilterBar.qml
UnifiedSearchInputContainer.qml
+ UnifiedSearchPeoplePopup.qml
+ UnifiedSearchResultDelegate.qml
UnifiedSearchResultFetchMoreTrigger.qml
UnifiedSearchResultItem.qml
UnifiedSearchResultItemSkeleton.qml
diff --git a/src/gui/search/SearchWindow.qml b/src/gui/search/SearchWindow.qml
index c953b391081b6..782bc8289ff09 100644
--- a/src/gui/search/SearchWindow.qml
+++ b/src/gui/search/SearchWindow.qml
@@ -10,19 +10,19 @@ import QtQuick.Layouts
import Style
import com.nextcloud.desktopclient
import "qrc:/qml/src/gui"
+import "qrc:/qml/src/gui/tray"
+import "qrc:/qml/src/gui/wizard/qml"
WizardStyledWindow {
id: root
property var account: null
property var searchModel: null
- readonly property string headline: qsTr("Search")
- readonly property int searchState: searchModel
- ? searchModel.searchState
- : UnifiedSearchResultsListModel.Placeholder
- readonly property bool isSearchInProgress: searchModel !== null && searchModel.isSearchInProgress
- readonly property bool canEditSearch: searchModel !== null && searchModel.canEditSearch
- readonly property bool isAccountConnected: searchModel !== null && searchModel.isAccountConnected
+ readonly property bool aggregateView: searchModel && searchModel.viewMode === UnifiedSearchResultsListModel.Aggregate
+ readonly property bool filtersVisible: aggregateView && searchModel && searchModel.providersReady
+ readonly property int searchState: searchModel ? searchModel.searchState : UnifiedSearchResultsListModel.Placeholder
+ readonly property bool peoplePopupOpened: peoplePopupLoader.status === Loader.Ready && peoplePopupLoader.item.opened
+ readonly property bool customRangeDialogOpened: customRangeDialogLoader.status === Loader.Ready && customRangeDialogLoader.item.opened
title: ""
width: Style.searchWindowWidth
@@ -31,57 +31,132 @@ WizardStyledWindow {
minimumHeight: Style.wizardStandaloneWindowMinimumHeight
function focusSearchInput() {
- if (visible && searchInput.enabled) {
- searchInput.forceActiveFocus()
+ if (visible && searchInput.enabled) searchInput.forceActiveFocus()
+ }
+
+ function openPeoplePopup() {
+ if (peoplePopupLoader.status === Loader.Ready) {
+ peoplePopupLoader.item.open()
+ return
+ }
+ peoplePopupLoader.active = true
+ }
+
+ function openCustomRangeDialog() {
+ if (customRangeDialogLoader.status === Loader.Ready) {
+ customRangeDialogLoader.item.open()
+ return
}
+ customRangeDialogLoader.active = true
}
Shortcut {
sequences: [StandardKey.Cancel]
+ enabled: !filterBar.opened && !root.peoplePopupOpened && !root.customRangeDialogOpened
onActivated: root.close()
}
- Component.onCompleted: Qt.callLater(focusSearchInput)
- onVisibleChanged: {
- if (visible) {
- Qt.callLater(focusSearchInput)
+ UnifiedSearchPeopleModel {
+ id: peopleSuggestionsModel
+ accountState: root.searchModel ? root.searchModel.accountState : null
+ }
+
+ Connections {
+ target: root.searchModel
+ function onSelectedRowChanged() {
+ if (root.searchModel && root.searchModel.selectedRow >= 0
+ && root.searchModel.selectedRow < resultsList.count) {
+ resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Contain)
+ }
+ }
+ function onViewModeChanged() {
+ Qt.callLater(function() {
+ if (root.searchModel && root.searchModel.selectedRow >= 0
+ && root.searchModel.selectedRow < resultsList.count) {
+ resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Beginning)
+ }
+ })
+ }
+ function onAccessibilityStatusChanged() {
+ if (root.searchModel.accessibilityStatus.length > 0)
+ Accessible.announce(root.searchModel.accessibilityStatus, Accessible.Polite)
}
}
+ Component.onCompleted: Qt.callLater(focusSearchInput)
+ onVisibleChanged: if (visible) Qt.callLater(focusSearchInput)
+
ColumnLayout {
anchors.fill: parent
- anchors.leftMargin: Style.wizardWindowMargin
- anchors.rightMargin: Style.wizardWindowMargin
+ anchors.margins: Style.wizardWindowMargin
anchors.topMargin: Style.wizardWindowTopMargin
- anchors.bottomMargin: Style.wizardWindowMargin
- spacing: Style.wizardSectionSpacing
+ spacing: Style.smallSpacing
WindowAccountHeader {
Layout.fillWidth: true
- title: root.headline
+ title: qsTr("Search")
user: root.account
}
UnifiedSearchInputContainer {
id: searchInput
-
Layout.fillWidth: true
Layout.preferredHeight: Style.unifiedSearchInputContainerHeight
enabled: root.searchModel !== null
- readOnly: !root.canEditSearch
+ readOnly: !root.searchModel || !root.searchModel.canEditSearch
text: root.searchModel ? root.searchModel.searchTerm : ""
- placeholderText: root.account !== null && !root.isAccountConnected
+ isSearchInProgress: root.searchModel
+ && (root.searchModel.isSearchInProgress
+ || root.searchModel.waitingForSearchTermEditEnd
+ || root.searchModel.isFetchMoreInProgress)
+ placeholderText: root.searchModel && !root.searchModel.isAccountConnected
? qsTr("Search is available when this account is connected")
: qsTr("Search files, messages, events …")
- isSearchInProgress: root.isSearchInProgress
- onTextEdited: {
- if (root.searchModel) {
- root.searchModel.searchTerm = searchInput.text
- }
- }
- onClearText: {
- if (root.searchModel) {
- root.searchModel.searchTerm = ""
+ onTextEdited: if (root.searchModel) root.searchModel.searchTerm = text
+ onClearText: if (root.searchModel) root.searchModel.searchTerm = ""
+ onMoveSelection: direction => root.searchModel.moveSelection(direction)
+ onActivateSelection: root.searchModel.activateSelected()
+ }
+
+ UnifiedSearchDetailHeader {
+ Layout.fillWidth: true
+ Layout.preferredHeight: implicitHeight
+ visible: root.searchModel && !root.aggregateView
+ searchModel: root.searchModel
+ onNavigateBack: root.focusSearchInput()
+ }
+
+ UnifiedSearchFilterBar {
+ id: filterBar
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ visible: root.filtersVisible
+ searchModel: root.searchModel
+ onCustomDateRangeRequested: root.openCustomRangeDialog()
+ onPeopleRequested: root.openPeoplePopup()
+ }
+
+ Flow {
+ objectName: "activeFilterFlow"
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ visible: root.filtersVisible && root.searchModel && root.searchModel.activeFilters.length > 0
+ spacing: Style.smallSpacing
+ Repeater {
+ model: root.searchModel ? root.searchModel.activeFilters : []
+ delegate: WizardChipButton {
+ id: chipButton
+ objectName: "activeFilterChip"
+ required property var modelData
+ text: modelData.label
+ textSuffix: "×"
+ iconBeforeText: true
+ iconSource: modelData.icon ? "image://tray-image-provider/" + modelData.icon : ""
+ tintIcon: true
+ iconTintColor: Style.wizardPrimaryText
+ Accessible.name: qsTr("Remove %1 filter").arg(modelData.label)
+ onClicked: root.searchModel.removeFilter(modelData.type, modelData.id)
}
}
}
@@ -96,12 +171,17 @@ WizardStyledWindow {
Layout.fillWidth: true
Layout.fillHeight: true
- ErrorBox {
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: parent.top
+ ColumnLayout {
+ anchors.centerIn: parent
+ width: Math.min(parent.width, Style.wizardDialogMaximumWidth)
visible: root.searchState === UnifiedSearchResultsListModel.SearchError
- text: root.searchModel ? root.searchModel.errorString : ""
+ ErrorBox { Layout.fillWidth: true; text: root.searchModel ? root.searchModel.errorString : "" }
+ WizardButton {
+ Layout.alignment: Qt.AlignHCenter
+ primary: true
+ text: qsTr("Retry")
+ onClicked: root.searchModel.retry()
+ }
}
UnifiedSearchPlaceholderView {
@@ -115,63 +195,162 @@ WizardStyledWindow {
text: root.searchModel ? root.searchModel.searchTerm : ""
}
- Loader {
- anchors.fill: parent
- anchors.margins: Style.smallSpacing
- active: root.searchState === UnifiedSearchResultsListModel.Skeleton
- asynchronous: true
-
- sourceComponent: UnifiedSearchResultItemSkeletonContainer {
- anchors.fill: parent
- spacing: searchResultsListView.spacing
- animationRectangleWidth: root.width
- }
- }
-
ScrollView {
- id: searchResultsScrollView
-
anchors.fill: parent
contentWidth: availableWidth
visible: root.searchState === UnifiedSearchResultsListModel.Results
-
+ || root.searchState === UnifiedSearchResultsListModel.Skeleton
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ListView {
- id: searchResultsListView
+ id: resultsList
- spacing: Style.smallSpacing
+ objectName: "searchResultsList"
clip: true
- keyNavigationEnabled: true
reuseItems: true
+ spacing: Style.extraSmallSpacing
model: root.searchModel
-
+ currentIndex: root.searchModel ? root.searchModel.selectedRow : -1
Accessible.role: Accessible.List
- Accessible.name: qsTr("Search results list")
-
- delegate: UnifiedSearchResultListItem {
- width: searchResultsListView.width
- isSearchInProgress: root.isSearchInProgress
- currentFetchMoreInProgressProviderId: root.searchModel
- ? root.searchModel.currentFetchMoreInProgressProviderId
- : ""
- fetchMoreTriggerClicked: root.searchModel
- ? root.searchModel.fetchMoreTriggerClicked
- : function() {}
- resultClicked: root.searchModel
- ? root.searchModel.resultClicked
- : function() {}
- ListView.onPooled: isPooled = true
- ListView.onReused: isPooled = false
+ Accessible.name: qsTr("Search results")
+
+ delegate: Item {
+ id: delegateData
+
+ required property string providerName
+ required property string providerId
+ required property string providerIcon
+ required property string resultTitle
+ required property string subline
+ required property url resourceUrlRole
+ required property string darkIcons
+ required property string lightIcons
+ required property bool darkIconsIsThumbnail
+ required property bool lightIconsIsThumbnail
+ required property string darkImagePlaceholder
+ required property string lightImagePlaceholder
+ required property bool isRounded
+ required property int type
+ required property bool isSelected
+ required property bool isPartialMatch
+ required property bool hasOverflow
+ required property bool isLoading
+
+ readonly property alias loadedItem: resultDelegate.loadedItem
+
+ width: resultsList.width
+ height: resultDelegate.implicitHeight
+
+ UnifiedSearchResultDelegate {
+ id: resultDelegate
+
+ anchors.fill: parent
+ searchModel: root.searchModel
+ providerName: delegateData.providerName
+ providerId: delegateData.providerId
+ providerIcon: delegateData.providerIcon
+ resultTitle: delegateData.resultTitle
+ subline: delegateData.subline
+ resourceUrlRole: delegateData.resourceUrlRole
+ darkIcons: delegateData.darkIcons
+ lightIcons: delegateData.lightIcons
+ darkIconsIsThumbnail: delegateData.darkIconsIsThumbnail
+ lightIconsIsThumbnail: delegateData.lightIconsIsThumbnail
+ darkImagePlaceholder: delegateData.darkImagePlaceholder
+ lightImagePlaceholder: delegateData.lightImagePlaceholder
+ isRounded: delegateData.isRounded
+ resultType: delegateData.type
+ isSelected: delegateData.isSelected
+ isPartialMatch: delegateData.isPartialMatch
+ hasOverflow: delegateData.hasOverflow
+ isLoading: delegateData.isLoading
+ }
}
- section.property: "providerName"
- section.criteria: ViewSection.FullString
- section.delegate: UnifiedSearchResultSectionItem {
- width: searchResultsListView.width
+ footer: Column {
+ objectName: "searchResultsLoadingFooter"
+ width: resultsList.width
+ height: visible ? implicitHeight : 0
+ spacing: Style.smallSpacing
+ visible: root.searchModel && root.searchModel.isSearchInProgress
+ Accessible.ignored: true
+ Repeater {
+ model: Style.unifiedSearchLoadingPlaceholderCount
+ Rectangle {
+ required property int index
+ width: resultsList.width * (Style.unifiedSearchLoadingPlaceholderInitialWidthRatio
+ + index * Style.unifiedSearchLoadingPlaceholderWidthStep)
+ height: Style.unifiedSearchProviderHeaderHeight
+ radius: Style.mediumRoundedButtonRadius
+ color: palette.alternateBase
+ opacity: Style.unifiedSearchLoadingPlaceholderOpacity
+ }
+ }
}
}
}
}
+
+ RowLayout {
+ objectName: "partialFailureFooter"
+ Layout.fillWidth: true
+ visible: root.aggregateView && root.searchModel && root.searchModel.hasPartialFailure
+ EnforcedPlainTextLabel { Layout.fillWidth: true; text: qsTr("Some sources unavailable"); color: palette.placeholderText }
+ WizardButton { text: qsTr("Retry"); onClicked: root.searchModel.retryFailedProviders() }
+ }
+
+ WizardButton {
+ Layout.fillWidth: true
+ visible: root.searchModel && root.searchModel.showConnectedServicesAction
+ text: root.searchModel && root.searchModel.externalProvidersEnabled
+ ? qsTr("Less from connected services") : qsTr("More from connected services")
+ onClicked: root.searchModel.setExternalProvidersEnabled(!root.searchModel.externalProvidersEnabled)
+ }
}
+
+ Component {
+ id: peoplePopupComponent
+
+ UnifiedSearchPeoplePopup {
+ searchModel: root.searchModel
+ peopleModel: peopleSuggestionsModel
+ windowWidth: root.width
+ onClosed: peoplePopupLoader.active = false
+ onPersonSelected: root.focusSearchInput()
+ }
+ }
+
+ Loader {
+ id: peoplePopupLoader
+
+ active: false
+ sourceComponent: peoplePopupComponent
+ onLoaded: {
+ if (status === Loader.Ready) {
+ item.open()
+ }
+ }
+ }
+
+ Component {
+ id: customRangeDialogComponent
+
+ UnifiedSearchCustomDateRangeDialog {
+ searchModel: root.searchModel
+ onClosed: customRangeDialogLoader.active = false
+ }
+ }
+
+ Loader {
+ id: customRangeDialogLoader
+
+ active: false
+ sourceComponent: customRangeDialogComponent
+ onLoaded: {
+ if (status === Loader.Ready) {
+ item.open()
+ }
+ }
+ }
+
}
diff --git a/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml
new file mode 100644
index 0000000000000..ffaf568fbd6fc
--- /dev/null
+++ b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+import Style
+import "qrc:/qml/src/gui/tray"
+import "qrc:/qml/src/gui/wizard/qml"
+
+Dialog {
+ id: root
+
+ required property var searchModel
+
+ property bool validationError: false
+
+ anchors.centerIn: parent
+ title: qsTr("Custom date range")
+ modal: true
+ onOpened: validationError = false
+
+ footer: RowLayout {
+ spacing: Style.wizardFooterSpacing
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ WizardButton {
+ text: qsTr("Cancel")
+ onClicked: root.close()
+ }
+
+ WizardButton {
+ primary: true
+ text: qsTr("Apply")
+ onClicked: {
+ root.validationError = !root.searchModel.setCustomDateRange(customSince.text, customUntil.text)
+ if (!root.validationError) {
+ root.close()
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ EnforcedPlainTextLabel {
+ text: qsTr("Start date (YYYY-MM-DD)")
+ }
+
+ TextField {
+ id: customSince
+
+ Layout.fillWidth: true
+ placeholderText: qsTr("YYYY-MM-DD")
+ }
+
+ EnforcedPlainTextLabel {
+ text: qsTr("End date (YYYY-MM-DD)")
+ }
+
+ TextField {
+ id: customUntil
+
+ Layout.fillWidth: true
+ placeholderText: qsTr("YYYY-MM-DD")
+ }
+
+ EnforcedPlainTextLabel {
+ visible: root.validationError
+ text: qsTr("Enter valid dates with the start date before the end date.")
+ color: palette.accent
+ }
+ }
+}
diff --git a/src/gui/search/UnifiedSearchDetailHeader.qml b/src/gui/search/UnifiedSearchDetailHeader.qml
new file mode 100644
index 0000000000000..4bb2596bf407a
--- /dev/null
+++ b/src/gui/search/UnifiedSearchDetailHeader.qml
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls.Basic
+
+import Style
+import "qrc:/qml/src/gui/tray"
+
+Item {
+ id: root
+
+ required property var searchModel
+
+ signal navigateBack()
+
+ objectName: "searchDetailHeader"
+ implicitHeight: Style.unifiedSearchDetailHeaderHeight
+
+ ToolButton {
+ id: backButton
+
+ objectName: "searchDetailBackButton"
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: qsTr("Back")
+ icon.source: "image://svgimage-custom-color/"
+ + (root.LayoutMirroring.enabled ? "arrow-right.svg/" : "arrow-left.svg/")
+ + Style.wizardPrimaryText
+ icon.width: Style.smallIconSize
+ icon.height: Style.smallIconSize
+ display: AbstractButton.TextBesideIcon
+ Accessible.name: qsTr("Back to all search results")
+ onClicked: {
+ root.searchModel.closeProviderDetail()
+ root.navigateBack()
+ }
+ }
+
+ EnforcedPlainTextLabel {
+ objectName: "searchDetailProviderTitle"
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: backButton.width + Style.smallSpacing
+ anchors.rightMargin: backButton.width + Style.smallSpacing
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.searchModel ? root.searchModel.detailProviderName : ""
+ font.bold: true
+ font.pixelSize: Style.wizardHeaderTitleFontPixelSize
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignHCenter
+ }
+}
diff --git a/src/gui/search/UnifiedSearchFilterBar.qml b/src/gui/search/UnifiedSearchFilterBar.qml
new file mode 100644
index 0000000000000..cb9072766df20
--- /dev/null
+++ b/src/gui/search/UnifiedSearchFilterBar.qml
@@ -0,0 +1,167 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls.Basic
+
+import Style
+import "qrc:/qml/src/gui/wizard/qml"
+
+Flow {
+ id: root
+
+ required property var searchModel
+
+ readonly property bool opened: typeMenu.opened || dateMenu.opened
+
+ signal customDateRangeRequested()
+ signal peopleRequested()
+
+ objectName: "categoryFilterFlow"
+ spacing: Style.smallSpacing
+
+ function hasActiveFilter(type) {
+ if (!searchModel) {
+ return false
+ }
+ const filters = searchModel.activeFilters
+ for (let index = 0; index < filters.length; ++index) {
+ if (filters[index].type === type) {
+ return true
+ }
+ }
+ return false
+ }
+
+ WizardButton {
+ id: typeFilterButton
+
+ objectName: "typeFilterButton"
+ width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth,
+ (root.width - 2 * root.spacing) / 3)
+ text: qsTr("Type")
+ trailingIconSource: "image://svgimage-custom-color/caret-down.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ iconBeforeText: true
+ iconSource: "image://svgimage-custom-color/folder.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ primary: root.hasActiveFilter("provider")
+ Accessible.name: qsTr("Filter by type")
+ onClicked: typeMenu.toggle()
+
+ WizardMenu {
+ id: typeMenu
+
+ objectName: "typeFilterMenu"
+ anchorItem: typeFilterButton
+ width: anchorItem.width * Style.unifiedSearchFilterMenuWidthFactor
+
+ Binding on height {
+ when: root.searchModel
+ && root.searchModel.providers.length
+ > Style.unifiedSearchProviderMenuMaximumVisibleRows
+ value: Style.unifiedSearchProviderMenuMaximumVisibleRows
+ * Style.standardPrimaryButtonHeight
+ + typeMenu.topPadding + typeMenu.bottomPadding
+ }
+
+ Repeater {
+ model: root.searchModel ? root.searchModel.providers : []
+
+ delegate: WizardMenuItem {
+ id: providerMenuItem
+
+ required property var modelData
+
+ text: (providerMenuItem.modelData.selected ? "✓ " : "") + providerMenuItem.modelData.name
+ icon.source: providerMenuItem.modelData.icon
+ ? "image://tray-image-provider/" + providerMenuItem.modelData.icon
+ : ""
+ tintIcon: true
+ iconTintColor: Style.wizardPrimaryText
+ onTriggered: root.searchModel.toggleProviderFilter(providerMenuItem.modelData.id)
+ }
+ }
+ }
+ }
+
+ WizardButton {
+ id: dateFilterButton
+
+ objectName: "dateFilterButton"
+ width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth,
+ (root.width - 2 * root.spacing) / 3)
+ text: qsTr("Date")
+ trailingIconSource: "image://svgimage-custom-color/caret-down.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ iconBeforeText: true
+ iconSource: "image://svgimage-custom-color/calendar.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ primary: root.hasActiveFilter("date")
+ enabled: root.searchModel && root.searchModel.dateFilterAvailable
+ Accessible.name: qsTr("Filter by date")
+ Accessible.description: enabled ? "" : qsTr("No search source supports date filtering")
+ onClicked: dateMenu.toggle()
+
+ WizardMenu {
+ id: dateMenu
+
+ objectName: "dateFilterMenu"
+ anchorItem: dateFilterButton
+
+ WizardMenuItem {
+ objectName: "dateTodayMenuItem"
+ text: qsTr("Today")
+ onTriggered: root.searchModel.setDatePreset("today")
+ }
+ WizardMenuItem {
+ text: qsTr("Last 7 days")
+ onTriggered: root.searchModel.setDatePreset("last7days")
+ }
+ WizardMenuItem {
+ text: qsTr("Last 30 days")
+ onTriggered: root.searchModel.setDatePreset("last30days")
+ }
+ WizardMenuItem {
+ text: qsTr("This year")
+ onTriggered: root.searchModel.setDatePreset("thisyear")
+ }
+ WizardMenuItem {
+ text: qsTr("Last year")
+ onTriggered: root.searchModel.setDatePreset("lastyear")
+ }
+ MenuSeparator {}
+ WizardMenuItem {
+ text: qsTr("Custom range …")
+ onTriggered: root.customDateRangeRequested()
+ }
+ WizardMenuItem {
+ text: qsTr("Clear date")
+ onTriggered: root.searchModel.clearDateFilter()
+ }
+ }
+ }
+
+ WizardButton {
+ id: peopleButton
+
+ objectName: "peopleFilterButton"
+ width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth,
+ (root.width - 2 * root.spacing) / 3)
+ text: qsTr("People")
+ trailingIconSource: "image://svgimage-custom-color/caret-down.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ iconBeforeText: true
+ iconSource: "image://svgimage-custom-color/account-group.svg/"
+ + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText)
+ primary: root.hasActiveFilter("person")
+ enabled: root.searchModel && root.searchModel.peopleFilterAvailable
+ Accessible.name: qsTr("Filter by person")
+ Accessible.description: enabled ? "" : qsTr("No search source supports people filtering")
+ onClicked: root.peopleRequested()
+ }
+}
diff --git a/src/gui/search/UnifiedSearchInputContainer.qml b/src/gui/search/UnifiedSearchInputContainer.qml
index 6d66558f7b323..ecdba98208352 100644
--- a/src/gui/search/UnifiedSearchInputContainer.qml
+++ b/src/gui/search/UnifiedSearchInputContainer.qml
@@ -1,98 +1,90 @@
/*
- * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-2.0-or-later
*/
-import QtQml
import QtQuick
import QtQuick.Controls
-import Qt5Compat.GraphicalEffects
import Style
-
import com.nextcloud.desktopclient
-import "qrc:/qml/src/gui/common"
import "qrc:/qml/src/gui/tray"
-NCContextMenuTextField {
+TextField {
id: root
signal clearText()
+ signal moveSelection(int direction)
+ signal activateSelection()
property bool isSearchInProgress: false
-
- readonly property color textFieldIconsColor: palette.placeholderText
-
- readonly property int iconInset: Style.smallSpacing
-
- readonly property real leadingControlWidth: root.isSearchInProgress ? busyIndicator.width : searchIconImage.width
- readonly property real trailingControlWidth: clearTextButton.visible ? clearTextButton.width : 0
-
- topPadding: topInset
- bottomPadding: bottomInset
- leftPadding: iconInset + leadingControlWidth + Style.smallSpacing
- rightPadding: iconInset + trailingControlWidth + Style.smallSpacing
+ readonly property color iconColor: palette.placeholderText
+ readonly property int iconSize: 24
+ readonly property int controlSize: Math.max(40, height - 4)
+
+ leftPadding: 8 + controlSize
+ rightPadding: 8
+ + (root.text.length > 0 ? controlSize : 0)
+ + (root.isSearchInProgress ? 8 + iconSize : 0)
verticalAlignment: Qt.AlignVCenter
-
placeholderText: qsTr("Search files, messages, events …")
- Image {
- id: searchIconImage
+ background: Rectangle {
+ radius: 8
+ color: Style.wizardFieldBackground
+ border.width: 1
+ border.color: Style.wizardFieldBorder
+ }
- anchors {
- left: root.left
- leftMargin: iconInset
- top: root.top
- topMargin: Style.extraSmallSpacing
- bottom: root.bottom
- bottomMargin: Style.extraSmallSpacing
- }
+ Keys.onPressed: event => {
+ if (inputMethodComposing) return
+ if (event.key === Qt.Key_Down) moveSelection(UnifiedSearchResultsListModel.Next)
+ else if (event.key === Qt.Key_Up) moveSelection(UnifiedSearchResultsListModel.Previous)
+ else if (event.key === Qt.Key_Home) moveSelection(UnifiedSearchResultsListModel.First)
+ else if (event.key === Qt.Key_End) moveSelection(UnifiedSearchResultsListModel.Last)
+ else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) activateSelection()
+ else return
+ event.accepted = true
+ }
- fillMode: Image.PreserveAspectFit
- smooth: true
- antialiasing: true
- mipmap: true
- source: "image://svgimage-custom-color/search.svg" + "/" + root.textFieldIconsColor
- visible: !root.isSearchInProgress
+ Image {
+ objectName: "searchLeadingIcon"
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.iconSize
+ height: root.iconSize
+ sourceSize.width: width
+ sourceSize.height: height
+ source: "image://svgimage-custom-color/search.svg/" + root.iconColor
+ Accessible.ignored: true
}
NCBusyIndicator {
- id: busyIndicator
-
- anchors {
- top: root.top
- topMargin: Style.extraSmallSpacing
- bottom: root.bottom
- bottomMargin: Style.extraSmallSpacing
- left: root.left
- leftMargin: iconInset
- }
-
- width: height
- color: root.textFieldIconsColor
+ objectName: "searchProgressIndicator"
+ anchors.right: clearSearchButton.visible ? clearSearchButton.left : parent.right
+ anchors.rightMargin: clearSearchButton.visible ? 0 : 8
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.iconSize
+ height: root.iconSize
+ color: root.iconColor
visible: root.isSearchInProgress
running: visible
+ Accessible.ignored: true
}
- Image {
- id: clearTextButton
-
- anchors {
- top: root.top
- topMargin: Style.extraSmallSpacing
- bottom: root.bottom
- bottomMargin: Style.extraSmallSpacing
- right: root.right
- rightMargin: iconInset
- }
-
- fillMode: Image.PreserveAspectFit
- visible: root.text
- source: "image://svgimage-custom-color/clear.svg" + "/" + root.textFieldIconsColor
-
- MouseArea {
- id: clearTextButtonMouseArea
- anchors.fill: parent
- onClicked: root.clearText()
- }
+ ToolButton {
+ id: clearSearchButton
+
+ objectName: "clearSearchButton"
+ anchors.right: parent.right
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.controlSize
+ height: root.controlSize
+ icon.source: "image://svgimage-custom-color/clear.svg/" + root.iconColor
+ visible: root.text.length > 0
+ Accessible.name: qsTr("Clear search")
+ Accessible.description: qsTr("Keeps the active filters")
+ onClicked: root.clearText()
}
}
diff --git a/src/gui/search/UnifiedSearchPeoplePopup.qml b/src/gui/search/UnifiedSearchPeoplePopup.qml
new file mode 100644
index 0000000000000..f8ff9cfe20f0e
--- /dev/null
+++ b/src/gui/search/UnifiedSearchPeoplePopup.qml
@@ -0,0 +1,119 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+import Style
+import "qrc:/qml/src/gui/tray"
+import "qrc:/qml/src/gui/wizard/qml"
+
+Popup {
+ id: root
+
+ required property var searchModel
+ required property var peopleModel
+ required property real windowWidth
+
+ signal personSelected()
+
+ parent: Overlay.overlay
+ width: Math.min(windowWidth - Style.unifiedSearchPeoplePopupHorizontalMargin,
+ Style.wizardDialogMaximumWidth)
+ height: Style.unifiedSearchPeoplePopupHeight
+ x: (windowWidth - width) / 2
+ y: Style.unifiedSearchPeoplePopupTopMargin
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ onOpened: peopleSearch.forceActiveFocus()
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Style.smallSpacing
+
+ TextField {
+ id: peopleSearch
+
+ Layout.fillWidth: true
+ placeholderText: qsTr("Search people")
+ onTextEdited: root.peopleModel.searchTerm = text
+ }
+
+ EnforcedPlainTextLabel {
+ visible: root.peopleModel.errorString.length > 0
+ text: root.peopleModel.errorString
+ wrapMode: Text.Wrap
+ }
+
+ WizardButton {
+ visible: root.peopleModel.errorString.length > 0
+ text: qsTr("Retry")
+ onClicked: root.peopleModel.retry()
+ }
+
+ ListView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ model: root.peopleModel
+
+ delegate: ItemDelegate {
+ id: personDelegate
+
+ required property string userId
+ required property string displayName
+ required property string avatarUrl
+
+ width: ListView.view.width
+ height: Style.unifiedSearchProviderHeaderHeight
+ text: displayName
+ hoverEnabled: true
+ Accessible.description: userId
+
+ background: Rectangle {
+ color: personDelegate.hovered || personDelegate.down
+ ? Style.listItemHoverBackground
+ : "transparent"
+ radius: Style.mediumRoundedButtonRadius
+ }
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ contentItem: RowLayout {
+ Image {
+ Layout.preferredWidth: Style.accountAvatarSize
+ Layout.preferredHeight: Style.accountAvatarSize
+ sourceSize.width: Style.accountAvatarSize
+ sourceSize.height: Style.accountAvatarSize
+ asynchronous: true
+ source: personDelegate.avatarUrl.length > 0
+ ? "image://tray-image-provider/" + personDelegate.avatarUrl
+ : ""
+ Accessible.ignored: true
+ }
+
+ EnforcedPlainTextLabel {
+ Layout.fillWidth: true
+ text: personDelegate.displayName
+ elide: Text.ElideRight
+ }
+ }
+
+ onClicked: {
+ root.searchModel.setPersonFilter(personDelegate.userId,
+ personDelegate.displayName,
+ personDelegate.avatarUrl)
+ root.close()
+ root.personSelected()
+ }
+ }
+ }
+ }
+}
diff --git a/src/gui/search/UnifiedSearchResultDelegate.qml b/src/gui/search/UnifiedSearchResultDelegate.qml
new file mode 100644
index 0000000000000..af549099446a7
--- /dev/null
+++ b/src/gui/search/UnifiedSearchResultDelegate.qml
@@ -0,0 +1,248 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+import Style
+import com.nextcloud.desktopclient
+import "qrc:/qml/src/gui/tray"
+
+Item {
+ id: root
+
+ required property var searchModel
+ required property string providerName
+ required property string providerId
+ required property string providerIcon
+ required property string resultTitle
+ required property string subline
+ required property url resourceUrlRole
+ required property string darkIcons
+ required property string lightIcons
+ required property bool darkIconsIsThumbnail
+ required property bool lightIconsIsThumbnail
+ required property string darkImagePlaceholder
+ required property string lightImagePlaceholder
+ required property bool isRounded
+ required property int resultType
+ required property bool isSelected
+ required property bool isPartialMatch
+ required property bool hasOverflow
+ required property bool isLoading
+
+ readonly property alias loadedItem: rowContent.item
+
+ implicitHeight: {
+ if (resultType === UnifiedSearchResultsListModel.ProviderHeader) {
+ return Style.unifiedSearchProviderHeaderHeight
+ }
+ if (resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) {
+ return Style.unifiedSearchPartialMatchesHeaderHeight
+ }
+ if (resultType === UnifiedSearchResultsListModel.FetchMoreTrigger
+ || resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) {
+ return Style.unifiedSearchPagingRowHeight
+ }
+ return Style.unifiedSearchItemHeight
+ }
+ height: implicitHeight
+
+ Loader {
+ id: rowContent
+
+ anchors.fill: parent
+ sourceComponent: {
+ if (root.resultType === UnifiedSearchResultsListModel.ProviderHeader) {
+ return providerHeader
+ }
+ if (root.resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) {
+ return partialHeader
+ }
+ if (root.resultType === UnifiedSearchResultsListModel.FetchMoreTrigger
+ || root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) {
+ return pagingRow
+ }
+ return resultRow
+ }
+ }
+
+ Component {
+ id: providerHeader
+
+ Button {
+ id: providerHeaderButton
+
+ objectName: "providerHeaderRow"
+ width: root.width
+ height: Style.unifiedSearchProviderHeaderHeight
+ flat: true
+ text: root.hasOverflow ? qsTr("More from %1 →").arg(root.providerName) : root.providerName
+ font.bold: false
+ font.pixelSize: Style.unifiedSearchResultTitleFontSize
+ leftPadding: 0
+ rightPadding: 0
+ hoverEnabled: root.hasOverflow
+ activeFocusOnTab: root.hasOverflow
+ Accessible.role: root.hasOverflow ? Accessible.Button : Accessible.StaticText
+ Accessible.name: text
+
+ HoverHandler {
+ enabled: root.hasOverflow
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ background: Rectangle {
+ color: root.hasOverflow && providerHeaderButton.hovered
+ ? Style.listItemHoverBackground
+ : "transparent"
+ radius: Style.mediumRoundedButtonRadius
+ }
+
+ contentItem: EnforcedPlainTextLabel {
+ text: providerHeaderButton.text
+ color: Style.wizardPrimaryText
+ font: providerHeaderButton.font
+ elide: Text.ElideRight
+ horizontalAlignment: Text.AlignLeft
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ onPressed: {
+ if (root.hasOverflow) {
+ root.searchModel.openProviderDetail(root.providerId)
+ }
+ }
+ }
+ }
+
+ Component {
+ id: partialHeader
+
+ EnforcedPlainTextLabel {
+ objectName: "partialMatchesHeaderRow"
+ width: root.width
+ height: Style.unifiedSearchPartialMatchesHeaderHeight
+ verticalAlignment: Text.AlignVCenter
+ text: qsTr("Partial matches")
+ color: Style.wizardSecondaryText
+ font.bold: true
+ }
+ }
+
+ Component {
+ id: resultRow
+
+ ItemDelegate {
+ id: resultDelegateButton
+
+ objectName: "searchResultRow"
+ width: root.width
+ height: Style.unifiedSearchItemHeight
+ leftPadding: 0
+ rightPadding: 0
+ topPadding: 0
+ bottomPadding: 0
+ activeFocusOnTab: false
+ opacity: root.isPartialMatch ? Style.unifiedSearchPartialMatchOpacity : 1.0
+ hoverEnabled: true
+ Accessible.role: Accessible.ListItem
+ Accessible.name: root.resultTitle
+ Accessible.description: root.subline
+ Accessible.selected: root.isSelected
+
+ background: Rectangle {
+ color: root.isSelected
+ ? Style.wizardSecondaryButtonPressed
+ : (resultDelegateButton.hovered ? Style.listItemHoverBackground : "transparent")
+ radius: Style.mediumRoundedButtonRadius
+ }
+
+ contentItem: UnifiedSearchResultItem {
+ accessibilityEnabled: false
+ title: root.resultTitle
+ subline: root.subline
+ icons: Style.darkMode ? root.darkIcons : root.lightIcons
+ iconsIsThumbnail: Style.darkMode ? root.darkIconsIsThumbnail : root.lightIconsIsThumbnail
+ iconPlaceholder: Style.darkMode ? root.darkImagePlaceholder : root.lightImagePlaceholder
+ isRounded: root.isRounded && iconsIsThumbnail
+ }
+
+ onClicked: root.searchModel.resultClicked(root.providerId, root.resourceUrlRole)
+ }
+ }
+
+ Component {
+ id: pagingRow
+
+ ItemDelegate {
+ id: pagingDelegate
+
+ objectName: "searchPagingRow"
+ width: root.width
+ enabled: !root.isLoading
+ hoverEnabled: true
+ leftPadding: Style.unifiedSearchResultIconLeftMargin
+ rightPadding: Style.unifiedSearchResultTextRightMargin
+ topPadding: 0
+ bottomPadding: 0
+ text: root.isLoading
+ ? qsTr("Loading more results …")
+ : (root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger
+ ? qsTr("Retry loading more results") : qsTr("Load more results"))
+ icon.source: "image://svgimage-custom-color/more.svg/" + Style.wizardPrimaryText
+ icon.width: Style.smallIconSize
+ icon.height: Style.smallIconSize
+ Accessible.role: Accessible.Button
+ Accessible.name: text
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ background: Rectangle {
+ color: pagingDelegate.hovered ? Style.listItemHoverBackground : "transparent"
+ radius: Style.mediumRoundedButtonRadius
+ }
+
+ contentItem: RowLayout {
+ spacing: Style.smallSpacing
+
+ BusyIndicator {
+ Layout.preferredWidth: Style.smallIconSize
+ Layout.preferredHeight: Style.smallIconSize
+ running: root.isLoading
+ visible: running
+ }
+
+ Image {
+ Layout.preferredWidth: Style.smallIconSize
+ Layout.preferredHeight: Style.smallIconSize
+ sourceSize.width: Style.smallIconSize
+ sourceSize.height: Style.smallIconSize
+ source: pagingDelegate.icon.source
+ visible: !root.isLoading
+ Accessible.ignored: true
+ }
+
+ EnforcedPlainTextLabel {
+ objectName: "searchPagingLabel"
+ Layout.fillWidth: true
+ text: pagingDelegate.text
+ color: Style.wizardPrimaryText
+ font.bold: false
+ font.pixelSize: Style.unifiedSearchResultTitleFontSize
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+
+ onPressed: root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger
+ ? root.searchModel.retryLoadMore(root.providerId)
+ : root.searchModel.loadMore(root.providerId)
+ }
+ }
+}
diff --git a/src/gui/search/UnifiedSearchResultItem.qml b/src/gui/search/UnifiedSearchResultItem.qml
index fae127653b466..a3ebafcf58098 100644
--- a/src/gui/search/UnifiedSearchResultItem.qml
+++ b/src/gui/search/UnifiedSearchResultItem.qml
@@ -15,6 +15,8 @@ import "qrc:/qml/src/gui/tray"
RowLayout {
id: unifiedSearchResultItemDetails
+ objectName: "searchResultContent"
+
property string title: ""
property string subline: ""
property string icons: ""
@@ -22,6 +24,7 @@ RowLayout {
property bool iconsIsThumbnail: false
property bool isRounded: false
+ property bool accessibilityEnabled: true
property int iconWidth: iconsIsThumbnail && icons !== "" ? Style.unifiedSearchResultIconWidth : Style.unifiedSearchResultSmallIconWidth
property int titleFontSize: Style.unifiedSearchResultTitleFontSize
@@ -32,8 +35,9 @@ RowLayout {
Accessible.role: Accessible.ListItem
- Accessible.name: resultTitle
- Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
+ Accessible.name: title
+ Accessible.description: subline
+ Accessible.ignored: !accessibilityEnabled
spacing: Style.trayHorizontalMargin
@@ -86,16 +90,35 @@ RowLayout {
}
}
- ListItemLineAndSubline {
+ ColumnLayout {
id: unifiedSearchResultTextContainer
- spacing: Style.standardSpacing
+ objectName: "searchResultTextContainer"
+ spacing: Style.unifiedSearchResultTextSpacing
Layout.fillWidth: true
Layout.rightMargin: Style.trayHorizontalMargin
- lineText: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ")
- sublineText: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ")
+ EnforcedPlainTextLabel {
+ objectName: "searchResultTitle"
+ Layout.fillWidth: true
+ text: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ")
+ textFormat: Text.PlainText
+ color: unifiedSearchResultItemDetails.titleColor
+ elide: Text.ElideRight
+ font.pixelSize: unifiedSearchResultItemDetails.titleFontSize
+ }
+
+ EnforcedPlainTextLabel {
+ objectName: "searchResultSubline"
+ Layout.fillWidth: true
+ text: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ")
+ textFormat: Text.PlainText
+ color: unifiedSearchResultItemDetails.sublineColor
+ visible: text.length > 0
+ elide: Text.ElideRight
+ font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize
+ }
}
}
diff --git a/src/gui/search/UnifiedSearchResultNothingFound.qml b/src/gui/search/UnifiedSearchResultNothingFound.qml
index fe61aa1149127..46d9c80fdce89 100644
--- a/src/gui/search/UnifiedSearchResultNothingFound.qml
+++ b/src/gui/search/UnifiedSearchResultNothingFound.qml
@@ -10,42 +10,55 @@ import QtQuick.Layouts
import Style
import "qrc:/qml/src/gui/tray"
-ColumnLayout {
+Item {
id: unifiedSearchResultNothingFoundContainer
+ objectName: "nothingFoundView"
+
required property string text
- spacing: Style.standardSpacing
- anchors.leftMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin
- anchors.rightMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin
+ ColumnLayout {
+ id: content
- Image {
- id: unifiedSearchResultsNoResultsLabelIcon
- source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}`
- sourceSize.width: Style.trayWindowHeaderHeight / 2
- sourceSize.height: Style.trayWindowHeaderHeight / 2
- Layout.alignment: Qt.AlignHCenter
- }
+ objectName: "nothingFoundContent"
+ anchors.centerIn: parent
+ width: parent.width - 2 * Style.unifiedSearchResultNothingFoundHorizontalMargin
+ spacing: Style.standardSpacing
- EnforcedPlainTextLabel {
- id: unifiedSearchResultsNoResultsLabel
- text: qsTr("No results for")
- font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize
- wrapMode: Text.Wrap
- Layout.fillWidth: true
- Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
- horizontalAlignment: Text.AlignHCenter
- }
+ Image {
+ id: unifiedSearchResultsNoResultsLabelIcon
+
+ objectName: "nothingFoundIcon"
+ source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}`
+ sourceSize.width: Style.trayWindowHeaderHeight / 2
+ sourceSize.height: Style.trayWindowHeaderHeight / 2
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ EnforcedPlainTextLabel {
+ id: unifiedSearchResultsNoResultsLabel
+
+ objectName: "nothingFoundMessage"
+ text: qsTr("No results for")
+ font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ EnforcedPlainTextLabel {
+ id: unifiedSearchResultsNoResultsLabelDetails
- EnforcedPlainTextLabel {
- id: unifiedSearchResultsNoResultsLabelDetails
- text: unifiedSearchResultNothingFoundContainer.text
- font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize
- wrapMode: Text.Wrap
- maximumLineCount: 2
- elide: Text.ElideRight
- Layout.fillWidth: true
- Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
- horizontalAlignment: Text.AlignHCenter
+ objectName: "nothingFoundQuery"
+ text: unifiedSearchResultNothingFoundContainer.text
+ font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize
+ wrapMode: Text.Wrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
+ horizontalAlignment: Text.AlignHCenter
+ }
}
}
diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp
new file mode 100644
index 0000000000000..4a04cc1a87d02
--- /dev/null
+++ b/src/gui/search/unifiedsearchpeoplemodel.cpp
@@ -0,0 +1,279 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "unifiedsearchpeoplemodel.h"
+
+#include "account.h"
+#include "accountstate.h"
+#include "networkjobs.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+constexpr auto maximumPeopleResults = 50;
+
+QString avatarUrl(const OCC::AccountPtr &account, const QString &userId)
+{
+ const auto encodedUserId = QString::fromUtf8(QUrl::toPercentEncoding(userId));
+ const auto avatarPath = QStringLiteral("index.php/avatar/%1/64").arg(encodedUserId);
+ return account->url().resolved(QUrl(avatarPath)).toString();
+}
+}
+
+namespace OCC
+{
+UnifiedSearchPeopleModel::UnifiedSearchPeopleModel(QObject *parent, int debounceInterval)
+ : QAbstractListModel(parent)
+{
+ _debounceTimer.setSingleShot(true);
+ _debounceTimer.setInterval(debounceInterval);
+ connect(&_debounceTimer, &QTimer::timeout, this, &UnifiedSearchPeopleModel::startSearch);
+}
+
+UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel()
+{
+ cancel();
+}
+
+QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const
+{
+ Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid));
+ const auto &person = _people.at(index.row());
+ switch (role) {
+ case UserIdRole:
+ return person.id;
+ case DisplayNameRole:
+ return person.displayName;
+ case AvatarUrlRole:
+ return person.avatarUrl;
+ }
+ return {};
+}
+
+int UnifiedSearchPeopleModel::rowCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : _people.size();
+}
+
+QHash UnifiedSearchPeopleModel::roleNames() const
+{
+ static const auto roles = QHash{
+ {UserIdRole, "userId"},
+ {DisplayNameRole, "displayName"},
+ {AvatarUrlRole, "avatarUrl"},
+ };
+ return roles;
+}
+
+AccountState *UnifiedSearchPeopleModel::accountState() const
+{
+ return _accountState.data();
+}
+
+QString UnifiedSearchPeopleModel::searchTerm() const
+{
+ return _searchTerm;
+}
+
+bool UnifiedSearchPeopleModel::busy() const
+{
+ return _busy;
+}
+
+QString UnifiedSearchPeopleModel::errorString() const
+{
+ return _errorString;
+}
+
+void UnifiedSearchPeopleModel::setAccountState(AccountState *accountState)
+{
+ if (_accountState == accountState) {
+ return;
+ }
+ cancel();
+ clearPeople();
+ if (_accountState) {
+ disconnect(_accountState, nullptr, this, nullptr);
+ }
+ _accountState = accountState;
+ if (_accountState) {
+ connect(_accountState, &AccountState::isConnectedChanged, this, [this] {
+ if (!_accountState->isConnected()) {
+ cancel();
+ clearPeople();
+ setErrorString(tr("People search is unavailable."));
+ } else {
+ setErrorString({});
+ _debounceTimer.start(0);
+ }
+ });
+ connect(_accountState, &QObject::destroyed, this, [this] {
+ cancel();
+ clearPeople();
+ setErrorString(tr("People search is unavailable."));
+ Q_EMIT accountStateChanged();
+ });
+ }
+ Q_EMIT accountStateChanged();
+ if (_accountState && _accountState->isConnected()) {
+ setErrorString({});
+ _debounceTimer.start(0);
+ } else {
+ setErrorString(tr("People search is unavailable."));
+ }
+}
+
+void UnifiedSearchPeopleModel::setSearchTerm(const QString &searchTerm)
+{
+ if (_searchTerm == searchTerm) {
+ return;
+ }
+ _searchTerm = searchTerm;
+ Q_EMIT searchTermChanged();
+ cancel();
+ clearPeople();
+ setErrorString({});
+ _debounceTimer.start();
+}
+
+void UnifiedSearchPeopleModel::retry()
+{
+ startSearch();
+}
+
+void UnifiedSearchPeopleModel::startSearch()
+{
+ cancel();
+ if (!_accountState || !_accountState->account() || !_accountState->isConnected()) {
+ setErrorString(tr("People search is unavailable."));
+ return;
+ }
+ const auto account = _accountState->account();
+ if (_searchTerm.trimmed().isEmpty()) {
+ auto people = QVector{};
+ const auto selfId = account->davUser();
+ if (!selfId.isEmpty()) {
+ const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName();
+ people.push_back({selfId, selfName, avatarUrl(account, selfId)});
+ }
+ replacePeople(std::move(people));
+ setErrorString({});
+ return;
+ }
+
+ const auto generation = ++_generation;
+ const auto job = new JsonApiJob(account, QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/sharees"));
+ auto query = QUrlQuery{};
+ query.addQueryItem(QStringLiteral("search"), _searchTerm);
+ query.addQueryItem(QStringLiteral("shareType"), QStringLiteral("0"));
+ query.addQueryItem(QStringLiteral("lookup"), QStringLiteral("false"));
+ query.addQueryItem(QStringLiteral("page"), QStringLiteral("1"));
+ query.addQueryItem(QStringLiteral("perPage"), QString::number(maximumPeopleResults));
+ job->addQueryParams(query);
+ _job = job;
+ setBusy(true);
+ connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job, account](const QJsonDocument &reply, int statusCode) {
+ if (generation != _generation || _job != job) {
+ return;
+ }
+ _job.clear();
+ setBusy(false);
+ if (statusCode != 200) {
+ clearPeople();
+ setErrorString(tr("Could not load people. Try again."));
+ return;
+ }
+ auto people = QVector{};
+ auto seen = QSet{};
+ const auto ocs = reply.object().value(QStringLiteral("ocs")).toObject();
+ const auto data = ocs.value(QStringLiteral("data")).toObject();
+ const auto appendUsers = [&people, &seen, &account](const QJsonArray &users) {
+ for (const auto &value : users) {
+ if (people.size() >= maximumPeopleResults) {
+ break;
+ }
+ const auto object = value.toObject();
+ const auto id = object.value(QStringLiteral("value")).toObject().value(QStringLiteral("shareWith")).toString();
+ if (id.isEmpty() || seen.contains(id)) {
+ continue;
+ }
+ seen.insert(id);
+ people.push_back({id, object.value(QStringLiteral("label")).toString(id), avatarUrl(account, id)});
+ }
+ };
+ appendUsers(data.value(QStringLiteral("exact")).toObject().value(QStringLiteral("users")).toArray());
+ appendUsers(data.value(QStringLiteral("users")).toArray());
+ const auto selfId = account->davUser();
+ const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName();
+ const auto selfMatchesSearch =
+ _searchTerm.isEmpty() || selfId.contains(_searchTerm, Qt::CaseInsensitive) || selfName.contains(_searchTerm, Qt::CaseInsensitive);
+ if (!selfId.isEmpty() && !seen.contains(selfId) && selfMatchesSearch) {
+ people.prepend({selfId, selfName, avatarUrl(account, selfId)});
+ if (people.size() > maximumPeopleResults) {
+ people.removeLast();
+ }
+ }
+ replacePeople(std::move(people));
+ setErrorString({});
+ });
+ job->start();
+}
+
+void UnifiedSearchPeopleModel::cancel()
+{
+ _debounceTimer.stop();
+ ++_generation;
+ if (_job) {
+ disconnect(_job, nullptr, this, nullptr);
+ const auto job = qobject_cast(_job.data());
+ const auto reply = job ? job->reply() : nullptr;
+ if (reply && reply->isRunning()) {
+ reply->abort();
+ }
+ _job.clear();
+ }
+ setBusy(false);
+}
+
+void UnifiedSearchPeopleModel::clearPeople()
+{
+ if (_people.isEmpty()) {
+ return;
+ }
+ beginResetModel();
+ _people.clear();
+ endResetModel();
+}
+
+void UnifiedSearchPeopleModel::replacePeople(QVector people)
+{
+ beginResetModel();
+ _people = std::move(people);
+ endResetModel();
+}
+
+void UnifiedSearchPeopleModel::setBusy(bool busy)
+{
+ if (_busy == busy) {
+ return;
+ }
+ _busy = busy;
+ Q_EMIT busyChanged();
+}
+
+void UnifiedSearchPeopleModel::setErrorString(const QString &errorString)
+{
+ if (_errorString == errorString) {
+ return;
+ }
+ _errorString = errorString;
+ Q_EMIT errorStringChanged();
+}
+}
diff --git a/src/gui/search/unifiedsearchpeoplemodel.h b/src/gui/search/unifiedsearchpeoplemodel.h
new file mode 100644
index 0000000000000..b81174d8f4188
--- /dev/null
+++ b/src/gui/search/unifiedsearchpeoplemodel.h
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#pragma once
+
+#include "accountstate.h"
+
+#include
+#include
+#include
+
+namespace OCC {
+
+/** @brief Account-authenticated user suggestions for Unified Search. */
+class UnifiedSearchPeopleModel : public QAbstractListModel
+{
+ Q_OBJECT
+ Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged)
+ Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged)
+ Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
+ Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged)
+
+public:
+ enum Role { UserIdRole = Qt::UserRole + 1, DisplayNameRole, AvatarUrlRole };
+
+ explicit UnifiedSearchPeopleModel(QObject *parent = nullptr, int debounceInterval = 300);
+ ~UnifiedSearchPeopleModel() override;
+ [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
+ [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override;
+ [[nodiscard]] QHash roleNames() const override;
+ [[nodiscard]] AccountState *accountState() const;
+ [[nodiscard]] QString searchTerm() const;
+ [[nodiscard]] bool busy() const;
+ [[nodiscard]] QString errorString() const;
+
+public Q_SLOTS:
+ void setAccountState(AccountState *accountState);
+ void setSearchTerm(const QString &searchTerm);
+ void retry();
+
+Q_SIGNALS:
+ void accountStateChanged();
+ void searchTermChanged();
+ void busyChanged();
+ void errorStringChanged();
+
+private:
+ struct Person { QString id; QString displayName; QString avatarUrl; };
+ void startSearch();
+ void cancel();
+ void clearPeople();
+ void replacePeople(QVector people);
+ void setBusy(bool busy);
+ void setErrorString(const QString &errorString);
+
+ QPointer _accountState;
+ QVector _people;
+ QString _searchTerm;
+ QString _errorString;
+ QTimer _debounceTimer;
+ QPointer _job;
+ quint64 _generation = 0;
+ bool _busy = false;
+};
+}
diff --git a/src/gui/search/unifiedsearchresult.cpp b/src/gui/search/unifiedsearchresult.cpp
index 928266fbc7328..a4fe0ace870e7 100644
--- a/src/gui/search/unifiedsearchresult.cpp
+++ b/src/gui/search/unifiedsearchresult.cpp
@@ -18,9 +18,21 @@ QString UnifiedSearchResult::typeAsString(UnifiedSearchResult::Type type)
result = QStringLiteral("Default");
break;
+ case ProviderHeader:
+ result = QStringLiteral("ProviderHeader");
+ break;
+
+ case PartialMatchesHeader:
+ result = QStringLiteral("PartialMatchesHeader");
+ break;
+
case FetchMoreTrigger:
result = QStringLiteral("FetchMoreTrigger");
break;
+
+ case RetryFetchMoreTrigger:
+ result = QStringLiteral("RetryFetchMoreTrigger");
+ break;
}
return result;
}
diff --git a/src/gui/search/unifiedsearchresult.h b/src/gui/search/unifiedsearchresult.h
index b291b8c6cb676..f7d17247214d4 100644
--- a/src/gui/search/unifiedsearchresult.h
+++ b/src/gui/search/unifiedsearchresult.h
@@ -21,7 +21,10 @@ struct UnifiedSearchResult
{
enum Type : quint8 {
Default,
+ ProviderHeader,
+ PartialMatchesHeader,
FetchMoreTrigger,
+ RetryFetchMoreTrigger,
};
static QString typeAsString(UnifiedSearchResult::Type type);
@@ -30,7 +33,14 @@ struct UnifiedSearchResult
QString _subline;
QString _providerId;
QString _providerName;
+ QString _providerIcon;
+ QString _stableKey;
bool _isRounded = false;
+ bool _isSelected = false;
+ bool _isSelectable = false;
+ bool _isPartialMatch = false;
+ bool _hasOverflow = false;
+ bool _isLoading = false;
qint32 _order = std::numeric_limits::max();
QUrl _resourceUrl;
QString _darkIcons;
diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp
index 3f59109955d83..69d3f88e9fc7a 100644
--- a/src/gui/search/unifiedsearchresultslistmodel.cpp
+++ b/src/gui/search/unifiedsearchresultslistmodel.cpp
@@ -7,18 +7,28 @@
#include "account.h"
#include "accountstate.h"
-#include "guiutility.h"
#include "folderman.h"
+#include "guiutility.h"
#include "networkjobs.h"
-#include
-
-#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
using namespace Qt::StringLiterals;
namespace {
+constexpr qsizetype aggregateResultsPerProvider = 3;
+constexpr auto requestResultsPerProvider = 10;
+
QString imagePlaceholderUrlForProviderId(const QString &providerId, const bool darkMode)
{
const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/");
@@ -64,737 +74,1500 @@ QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix, const boo
QString iconUrlForDefaultIconName(const QString &defaultIconName, const bool darkMode)
{
const QUrl urlForIcon{defaultIconName};
-
if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) {
return defaultIconName;
}
const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/");
-
if (defaultIconName.startsWith(QStringLiteral("icon-"))) {
const auto parts = defaultIconName.split(u'-');
-
if (parts.size() > 1) {
- const QString blackOrWhiteIconFilePath = colorIconPath + parts[1] + QStringLiteral(".svg");
-
- if (QFile::exists(blackOrWhiteIconFilePath)) {
- return blackOrWhiteIconFilePath;
+ const auto themedIcon = colorIconPath + parts[1] + QStringLiteral(".svg");
+ if (QFile::exists(themedIcon)) {
+ return themedIcon;
}
-
- const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg");
-
- if (QFile::exists(iconFilePath)) {
- return iconFilePath;
+ const auto icon = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg");
+ if (QFile::exists(icon)) {
+ return icon;
}
}
-
- const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName, darkMode);
-
- if (!iconNameFromIconPrefix.isEmpty()) {
- return iconNameFromIconPrefix;
- }
+ return localIconPathFromIconPrefix(defaultIconName, darkMode);
}
return colorIconPath % QStringLiteral("change.svg");
}
-QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl)
+QString absoluteServerResource(const QString &resource, const QUrl &serverUrl)
{
- auto serverUrlCopy = serverUrl;
- auto thumbnailUrlCopy = thumbnailUrl;
-
- if (thumbnailUrlCopy.startsWith(u'/') || thumbnailUrlCopy.startsWith(u'\\')) {
- // relative image resource URL, just needs some concatenation with current server URL
- // some icons may contain parameters after (?)
- const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(u'?')
- ? thumbnailUrlCopy.split(u'?', Qt::SkipEmptyParts)
- : QStringList{thumbnailUrlCopy};
- Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty());
- serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]);
- thumbnailUrlCopy = serverUrlCopy.toString();
- if (thumbnailUrlCopySplitted.size() > 1) {
- thumbnailUrlCopy += u'?' + thumbnailUrlCopySplitted[1];
- }
+ if (resource.isEmpty()) {
+ return {};
}
- return thumbnailUrlCopy;
-}
-
-QString generateUrlForIcon(const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode)
-{
- auto serverUrlCopy = serverUrl;
-
- auto fallbackIconCopy = fallbackIcon;
+ const auto url = QUrl(resource);
+ if (!url.isRelative() && !url.scheme().isEmpty()) {
+ return resource;
+ }
- if (fallbackIconCopy.startsWith(u'/') || fallbackIconCopy.startsWith(u'\\')) {
- // relative image resource URL, just needs some concatenation with current server URL
- // some icons may contain parameters after (?)
- const QStringList fallbackIconPathSplitted =
- fallbackIconCopy.contains(u'?') ? fallbackIconCopy.split(u'?') : QStringList{fallbackIconCopy};
- Q_ASSERT(!fallbackIconPathSplitted.isEmpty());
- serverUrlCopy.setPath(fallbackIconPathSplitted[0]);
- fallbackIconCopy = serverUrlCopy.toString();
- if (fallbackIconPathSplitted.size() > 1) {
- fallbackIconCopy += u'?' + fallbackIconPathSplitted[1];
- }
- } else if (!fallbackIconCopy.isEmpty()) {
- // could be one of names for standard icons (e.g. icon-mail)
- const auto defaultIconUrl = iconUrlForDefaultIconName(fallbackIconCopy, darkMode);
- if (!defaultIconUrl.isEmpty()) {
- fallbackIconCopy = defaultIconUrl;
+ auto absoluteUrl = serverUrl;
+ if (resource.startsWith(u'/') || resource.startsWith(u'\\')) {
+ const auto queryPosition = resource.indexOf(u'?');
+ absoluteUrl.setPath(queryPosition < 0 ? resource : resource.left(queryPosition));
+ if (queryPosition >= 0) {
+ absoluteUrl.setQuery(resource.mid(queryPosition + 1));
}
+ } else {
+ absoluteUrl = serverUrl.resolved(QUrl(resource));
}
-
- return fallbackIconCopy;
+ return absoluteUrl.toString();
}
-// Return image URL and whether it is a thumbnail or not
-std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode)
+QString normalizedIcon(const QString &icon, const QUrl &serverUrl, const bool darkMode)
{
- if (thumbnailUrl.isEmpty() && fallbackIcon.isEmpty()) {
+ if (icon.isEmpty()) {
return {};
}
-
- if (serverUrl.isEmpty()) {
- const QStringList listImages = {thumbnailUrl, fallbackIcon};
- return {listImages.join(u';'), false};
+ if (icon.startsWith(QStringLiteral(":/"))) {
+ return icon;
}
+ if (icon.startsWith(u'/') || icon.startsWith(u'\\')) {
+ return absoluteServerResource(icon, serverUrl);
+ }
+ return iconUrlForDefaultIconName(icon, darkMode);
+}
- const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl);
- const auto urlForFallbackIcon = generateUrlForIcon(fallbackIcon, serverUrl, darkMode);
-
- qDebug() << "SEARCH" << urlForThumbnail << urlForFallbackIcon;
+std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl,
+ const QString &fallbackIcon,
+ const QUrl &serverUrl,
+ const bool darkMode)
+{
+ const auto thumbnail = absoluteServerResource(thumbnailUrl, serverUrl);
+ const auto icon = normalizedIcon(fallbackIcon, serverUrl, darkMode);
+ auto icons = QStringList{};
+ if (!thumbnail.isEmpty()) {
+ icons.push_back(thumbnail);
+ }
+ if (!icon.isEmpty()) {
+ icons.push_back(icon);
+ }
+ return {icons.join(u';'), !thumbnail.isEmpty()};
+}
- if (urlForThumbnail.isEmpty() && !urlForFallbackIcon.isEmpty()) {
- return {urlForFallbackIcon, false};
+QString navigationAppIconForResult(const OCC::AccountState *accountState,
+ const QString &providerId,
+ const QString &resultTitle,
+ const bool darkMode)
+{
+ if (!accountState || providerId != QStringLiteral("settings_apps")) {
+ return {};
}
- if (!urlForThumbnail.isEmpty() && urlForFallbackIcon.isEmpty()) {
- return {urlForThumbnail, true};
+ const auto apps = accountState->appList();
+ const auto appIt = std::find_if(apps.cbegin(), apps.cend(), [&resultTitle](const auto *app) {
+ return app && app->name().compare(resultTitle, Qt::CaseInsensitive) == 0;
+ });
+ if (appIt == apps.cend() || (*appIt)->iconUrl().isEmpty()) {
+ return {};
}
- const QStringList listImages{urlForThumbnail, urlForFallbackIcon};
- return {listImages.join(u';'), true};
+ return (*appIt)->iconUrl().toString()
+ % (darkMode ? QStringLiteral("/white") : QStringLiteral("/black"));
}
-
-constexpr int searchTermEditingFinishedSearchStartDelay = 800;
-
-// server-side bug of returning the cursor > 0 and isPaginated == 'true', using '5' as it is done on Android client's end now
-constexpr int minimumEntresNumberToShowLoadMore = 5;
}
+
namespace OCC {
Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg)
-UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent)
+UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, int debounceInterval, int revealInterval, QObject *parent)
: QAbstractListModel(parent)
, _accountState(accountState)
{
+ _debounceTimer.setSingleShot(true);
+ _debounceTimer.setInterval(debounceInterval);
+ _revealTimer.setSingleShot(true);
+ _revealTimer.setInterval(revealInterval);
+
+ connect(&_debounceTimer, &QTimer::timeout, this, [this] {
+ if (_providersReady) {
+ startSearch();
+ } else {
+ _pendingSearch = true;
+ if (!_providersLoading) {
+ discoverProviders();
+ }
+ }
+ setWaitingForSearchTermEditEnd(false);
+ });
+ connect(&_revealTimer, &QTimer::timeout, this, &UnifiedSearchResultsListModel::closeRevealWindow);
+
connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::searchStateChanged);
- connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::searchStateChanged);
+ connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged);
connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::searchStateChanged);
+ connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged);
connect(this, &UnifiedSearchResultsListModel::errorStringChanged, this, &UnifiedSearchResultsListModel::searchStateChanged);
connect(this, &UnifiedSearchResultsListModel::waitingForSearchTermEditEndChanged, this, &UnifiedSearchResultsListModel::searchStateChanged);
- connect(this, &QAbstractListModel::rowsInserted, this, &UnifiedSearchResultsListModel::searchStateChanged);
- connect(this, &QAbstractListModel::rowsRemoved, this, &UnifiedSearchResultsListModel::searchStateChanged);
connect(this, &QAbstractListModel::modelReset, this, &UnifiedSearchResultsListModel::searchStateChanged);
-
connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged);
+ connect(this, &UnifiedSearchResultsListModel::providersChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged);
+ connect(this, &UnifiedSearchResultsListModel::viewModeChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged);
+
+ _lastKnownConnected = isAccountConnected();
if (_accountState) {
- connect(_accountState, &AccountState::isConnectedChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged);
+ connect(_accountState, &AccountState::isConnectedChanged, this, [this] {
+ const auto connected = isAccountConnected();
+ Q_EMIT canEditSearchChanged();
+ if (_lastKnownConnected && !connected) {
+ const auto wasInProgress = isSearchInProgress();
+ ++_queryGeneration;
+ abortSearchJobs();
+ abortProviderDiscovery();
+ _debounceTimer.stop();
+ setWaitingForSearchTermEditEnd(false);
+ resetProviderRuntime();
+ setProvidersReady(false);
+ rebuildProjection();
+ setErrorString(tr("Search is unavailable while this account is offline."));
+ updateProgressSignals(wasInProgress);
+ } else if (!_lastKnownConnected && connected) {
+ setErrorString({});
+ _pendingSearch = hasSearchTerm();
+ discoverProviders();
+ }
+ _lastKnownConnected = connected;
+ });
+ }
+
+ if (isAccountConnected()) {
+ QTimer::singleShot(0, this, &UnifiedSearchResultsListModel::discoverProviders);
+ } else {
+ setErrorString(tr("Search is unavailable while this account is offline."));
}
}
+UnifiedSearchResultsListModel::~UnifiedSearchResultsListModel()
+{
+ abortSearchJobs();
+ abortProviderDiscovery();
+}
+
QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const
{
Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid));
-
+ const auto &result = _results.at(index.row());
switch (role) {
case ProviderNameRole:
- return _results.at(index.row())._providerName;
+ return result._providerName;
case ProviderIdRole:
- return _results.at(index.row())._providerId;
+ return result._providerId;
+ case ProviderIconRole:
+ return result._providerIcon;
case DarkImagePlaceholderRole:
- return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, true);
+ return imagePlaceholderUrlForProviderId(result._providerId, true);
case LightImagePlaceholderRole:
- return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, false);
+ return imagePlaceholderUrlForProviderId(result._providerId, false);
case DarkIconsRole:
- return _results.at(index.row())._darkIcons;
+ return result._darkIcons;
case LightIconsRole:
- return _results.at(index.row())._lightIcons;
+ return result._lightIcons;
case DarkIconsIsThumbnailRole:
- return _results.at(index.row())._darkIconsIsThumbnail;
+ return result._darkIconsIsThumbnail;
case LightIconsIsThumbnailRole:
- return _results.at(index.row())._lightIconsIsThumbnail;
+ return result._lightIconsIsThumbnail;
case TitleRole:
- return _results.at(index.row())._title;
+ return result._title;
case SublineRole:
- return _results.at(index.row())._subline;
+ return result._subline;
case ResourceUrlRole:
- return _results.at(index.row())._resourceUrl;
+ return result._resourceUrl;
case RoundedRole:
- return _results.at(index.row())._isRounded;
+ return result._isRounded;
case TypeRole:
- return _results.at(index.row())._type;
+ return static_cast(result._type);
case TypeAsStringRole:
- return UnifiedSearchResult::typeAsString(_results.at(index.row())._type);
+ return UnifiedSearchResult::typeAsString(result._type);
+ case StableKeyRole:
+ return result._stableKey;
+ case SelectedRole:
+ return result._isSelected;
+ case SelectableRole:
+ return result._isSelectable;
+ case PartialMatchRole:
+ return result._isPartialMatch;
+ case HasOverflowRole:
+ return result._hasOverflow;
+ case LoadingRole:
+ return result._isLoading;
}
-
return {};
}
int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const
{
- if (parent.isValid()) {
- return 0;
- }
-
- return _results.size();
+ return parent.isValid() ? 0 : _results.size();
}
QHash UnifiedSearchResultsListModel::roleNames() const
{
- auto roles = QAbstractListModel::roleNames();
- roles[ProviderNameRole] = "providerName";
- roles[ProviderIdRole] = "providerId";
- roles[DarkIconsRole] = "darkIcons";
- roles[LightIconsRole] = "lightIcons";
- roles[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail";
- roles[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail";
- roles[DarkImagePlaceholderRole] = "darkImagePlaceholder";
- roles[LightImagePlaceholderRole] = "lightImagePlaceholder";
- roles[TitleRole] = "resultTitle";
- roles[SublineRole] = "subline";
- roles[ResourceUrlRole] = "resourceUrlRole";
- roles[TypeRole] = "type";
- roles[TypeAsStringRole] = "typeAsString";
- roles[RoundedRole] = "isRounded";
+ static const auto roles = [this] {
+ auto result = QAbstractListModel::roleNames();
+ result[ProviderNameRole] = "providerName";
+ result[ProviderIdRole] = "providerId";
+ result[ProviderIconRole] = "providerIcon";
+ result[DarkIconsRole] = "darkIcons";
+ result[LightIconsRole] = "lightIcons";
+ result[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail";
+ result[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail";
+ result[DarkImagePlaceholderRole] = "darkImagePlaceholder";
+ result[LightImagePlaceholderRole] = "lightImagePlaceholder";
+ result[TitleRole] = "resultTitle";
+ result[SublineRole] = "subline";
+ result[ResourceUrlRole] = "resourceUrlRole";
+ result[TypeRole] = "type";
+ result[TypeAsStringRole] = "typeAsString";
+ result[RoundedRole] = "isRounded";
+ result[StableKeyRole] = "stableKey";
+ result[SelectedRole] = "isSelected";
+ result[SelectableRole] = "isSelectable";
+ result[PartialMatchRole] = "isPartialMatch";
+ result[HasOverflowRole] = "hasOverflow";
+ result[LoadingRole] = "isLoading";
+ return result;
+ }();
return roles;
}
-QString UnifiedSearchResultsListModel::searchTerm() const
+bool UnifiedSearchResultsListModel::isSearchInProgress() const
{
- return _searchTerm;
+ return _inFlightSearchRequests > 0 || (_providersLoading && hasSearchTerm());
}
-QString UnifiedSearchResultsListModel::errorString() const
+QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const { return _currentFetchMoreInProgressProviderId; }
+QString UnifiedSearchResultsListModel::searchTerm() const { return _searchTerm; }
+QString UnifiedSearchResultsListModel::errorString() const { return _errorString; }
+bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const { return _waitingForSearchTermEditEnd; }
+bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const { return !_currentFetchMoreInProgressProviderId.isEmpty(); }
+bool UnifiedSearchResultsListModel::hasSearchTerm() const { return !_searchTerm.trimmed().isEmpty(); }
+bool UnifiedSearchResultsListModel::hasSearchError() const { return !_errorString.isEmpty(); }
+bool UnifiedSearchResultsListModel::canEditSearch() const { return _accountState && _accountState->account() && isAccountConnected(); }
+bool UnifiedSearchResultsListModel::isAccountConnected() const { return _accountState && _accountState->isConnected(); }
+
+UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const
{
- return _errorString;
+ if (!_results.isEmpty()) {
+ return SearchState::Results;
+ }
+ if (hasSearchError() && !isSearchInProgress()) {
+ return SearchState::SearchError;
+ }
+ if (!hasSearchTerm()) {
+ return SearchState::Placeholder;
+ }
+ if (_waitingForSearchTermEditEnd || isSearchInProgress()) {
+ return SearchState::Skeleton;
+ }
+ return SearchState::NothingFound;
}
-QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const
+QVariantList UnifiedSearchResultsListModel::providers() const
{
- return _currentFetchMoreInProgressProviderId;
+ QVariantList result;
+ for (const auto &providerId : _providerOrder) {
+ const auto providerIt = _providers.constFind(providerId);
+ if (providerIt == _providers.cend()) {
+ continue;
+ }
+ const auto &provider = providerIt.value();
+ result.push_back(QVariantMap{
+ {QStringLiteral("id"), provider.id},
+ {QStringLiteral("name"), provider.name},
+ {QStringLiteral("icon"), providerIcon(provider, false)},
+ {QStringLiteral("selected"), _selectedProviderIds.contains(provider.id)},
+ {QStringLiteral("external"), provider.isExternalProvider},
+ });
+ }
+ return result;
}
-bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const
+QVariantList UnifiedSearchResultsListModel::activeFilters() const
{
- return _waitingForSearchTermEditEnd;
+ QVariantList result;
+ for (const auto &providerId : _selectedProviderIds) {
+ const auto providerIt = _providers.constFind(providerId);
+ if (providerIt == _providers.cend()) {
+ continue;
+ }
+ const auto &provider = providerIt.value();
+ result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("provider")},
+ {QStringLiteral("id"), providerId},
+ {QStringLiteral("label"), provider.name},
+ {QStringLiteral("icon"), providerIcon(provider, false)}});
+ }
+ if (_since.isValid() && _until.isValid()) {
+ result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("date")},
+ {QStringLiteral("id"), QStringLiteral("date")},
+ {QStringLiteral("label"), _dateLabel}});
+ }
+ if (!_personId.isEmpty()) {
+ result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("person")},
+ {QStringLiteral("id"), _personId},
+ {QStringLiteral("label"), _personName},
+ {QStringLiteral("icon"), _personAvatarUrl}});
+ }
+ return result;
}
+bool UnifiedSearchResultsListModel::providersReady() const { return _providersReady; }
+
+bool UnifiedSearchResultsListModel::dateFilterAvailable() const
+{
+ return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) {
+ return provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until"));
+ });
+}
+
+bool UnifiedSearchResultsListModel::peopleFilterAvailable() const
+{
+ return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) {
+ return provider.filters.contains(QStringLiteral("person"));
+ });
+}
+
+bool UnifiedSearchResultsListModel::hasExternalProviders() const
+{
+ return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { return provider.isExternalProvider; });
+}
+
+bool UnifiedSearchResultsListModel::externalProvidersEnabled() const { return _externalProvidersEnabled; }
+
+bool UnifiedSearchResultsListModel::showConnectedServicesAction() const
+{
+ return _viewMode == ViewMode::Aggregate && hasSearchTerm() && hasExternalProviders() && !isSearchInProgress();
+}
+
+bool UnifiedSearchResultsListModel::hasPartialFailure() const { return _hasPartialFailure; }
+UnifiedSearchResultsListModel::ViewMode UnifiedSearchResultsListModel::viewMode() const { return _viewMode; }
+
+QString UnifiedSearchResultsListModel::detailProviderName() const
+{
+ const auto providerIt = _providers.constFind(_detailProviderId);
+ return providerIt == _providers.cend() ? QString() : providerIt->name;
+}
+
+int UnifiedSearchResultsListModel::selectedRow() const { return _selectedRow; }
+QString UnifiedSearchResultsListModel::accessibilityStatus() const { return _accessibilityStatus; }
+AccountState *UnifiedSearchResultsListModel::accountState() const { return _accountState; }
+
void UnifiedSearchResultsListModel::setSearchTerm(const QString &term)
{
- if (term == _searchTerm) {
+ if (_searchTerm == term) {
return;
}
-
_searchTerm = term;
Q_EMIT searchTermChanged();
+ scheduleSearch();
+}
- if (!_errorString.isEmpty()) {
- _errorString.clear();
- Q_EMIT errorStringChanged();
+void UnifiedSearchResultsListModel::discoverProviders()
+{
+ if (!_accountState || !_accountState->account()) {
+ setErrorString(tr("Failed to fetch search providers."));
+ return;
+ }
+ if (!isAccountConnected()) {
+ setErrorString(tr("Search is unavailable while this account is offline."));
+ return;
}
- disconnectAndClearSearchJobs();
+ const auto wasInProgress = isSearchInProgress();
+ abortProviderDiscovery();
+ _providersLoading = true;
+ setProvidersReady(false);
+ const auto generation = ++_providerGeneration;
+
+ auto *const job = new JsonApiJob(_accountState->account(), QStringLiteral("ocs/v2.php/search/providers"));
+ _providerDiscoveryJob = job;
+ connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job](const QJsonDocument &json, const int statusCode) {
+ providerDiscoveryFinished(json, statusCode, generation, job);
+ });
+ connect(job, &QObject::destroyed, this, [this, job] {
+ if (_providerDiscoveryJob == job) {
+ _providerDiscoveryJob.clear();
+ }
+ });
+ job->start();
+ updateProgressSignals(wasInProgress);
+}
- clearCurrentFetchMoreInProgressProviderId();
+void UnifiedSearchResultsListModel::providerDiscoveryFinished(const QJsonDocument &json,
+ int statusCode,
+ quint64 generation,
+ QObject *jobObject)
+{
+ if (generation != _providerGeneration || jobObject != _providerDiscoveryJob) {
+ return;
+ }
+ const auto wasInProgress = isSearchInProgress();
+ _providerDiscoveryJob.clear();
+ _providersLoading = false;
+ _providers.clear();
+ _providerOrder.clear();
- disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
- &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
+ if (statusCode != 200) {
+ if (!_selectedProviderIds.isEmpty()) {
+ _selectedProviderIds.clear();
+ Q_EMIT activeFiltersChanged();
+ }
+ setErrorString(tr("Failed to fetch search providers."));
+ Q_EMIT providersChanged();
+ updateProgressSignals(wasInProgress);
+ return;
+ }
- if (_unifiedSearchTextEditingFinishedTimer.isActive()) {
- _unifiedSearchTextEditingFinishedTimer.stop();
- _waitingForSearchTermEditEnd = false;
- Q_EMIT waitingForSearchTermEditEndChanged();
+ const auto providerList = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray();
+ QVector appOrder;
+ QHash> providersByApp;
+ auto discoveryIndex = 0;
+ for (const auto &providerValue : providerList) {
+ const auto providerObject = providerValue.toObject();
+ UnifiedSearchProvider provider;
+ provider.id = providerObject.value("id"_L1).toString();
+ provider.name = providerObject.value("name"_L1).toString();
+ if (provider.id.isEmpty() || provider.name.isEmpty()) {
+ continue;
+ }
+ provider.appId = providerObject.value("appId"_L1).toString(provider.id);
+ provider.icon = providerObject.value("icon"_L1).toString();
+ provider.order = providerObject.contains("order"_L1)
+ ? providerObject.value("order"_L1).toInt(std::numeric_limits::max())
+ : std::numeric_limits::max();
+ provider.discoveryIndex = discoveryIndex++;
+ const auto externalValue = providerObject.value("isExternalProvider"_L1);
+ provider.isExternalProvider = externalValue.isBool()
+ ? externalValue.toBool()
+ : externalValue.toString().compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0;
+ const auto filtersValue = providerObject.value("filters"_L1);
+ if (filtersValue.isUndefined() || filtersValue.isNull()) {
+ provider.filters.insert(QStringLiteral("term"));
+ } else if (filtersValue.isArray()) {
+ for (const auto &filter : filtersValue.toArray()) {
+ provider.filters.insert(filter.toString());
+ }
+ if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term"));
+ } else {
+ const auto filters = filtersValue.toObject();
+ for (auto it = filters.constBegin(); it != filters.constEnd(); ++it) {
+ provider.filters.insert(it.key());
+ }
+ if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term"));
+ }
+ _providers.insert(provider.id, provider);
+ if (!providersByApp.contains(provider.appId)) {
+ appOrder.push_back(provider.appId);
+ }
+ providersByApp[provider.appId].push_back(provider.id);
+ }
+ const auto providerLess = [this](const QString &leftId, const QString &rightId) {
+ const auto &left = _providers[leftId];
+ const auto &right = _providers[rightId];
+ return std::tie(left.order, left.discoveryIndex) < std::tie(right.order, right.discoveryIndex);
+ };
+ for (auto it = providersByApp.begin(); it != providersByApp.end(); ++it) {
+ std::stable_sort(it->begin(), it->end(), providerLess);
+ }
+ std::stable_sort(appOrder.begin(), appOrder.end(), [&providersByApp, &providerLess](const QString &leftApp, const QString &rightApp) {
+ return providerLess(providersByApp[leftApp].constFirst(), providersByApp[rightApp].constFirst());
+ });
+ for (const auto &appId : appOrder) {
+ _providerOrder.append(providersByApp.value(appId));
}
- if (!_searchTerm.isEmpty()) {
- _unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay);
- connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
- &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
- _unifiedSearchTextEditingFinishedTimer.start();
- _waitingForSearchTermEditEnd = true;
- Q_EMIT waitingForSearchTermEditEndChanged();
+ const auto previousSelectedProviderIds = _selectedProviderIds;
+ _selectedProviderIds.removeIf([this](const auto &providerId) {
+ return !_providers.contains(providerId);
+ });
+ if (_selectedProviderIds != previousSelectedProviderIds) {
+ Q_EMIT activeFiltersChanged();
}
- if (!_results.isEmpty()) {
- beginResetModel();
- _results.clear();
- endResetModel();
+ setProvidersReady(!_providers.isEmpty());
+ setErrorString(_providersReady ? QString() : tr("No search providers are available."));
+ Q_EMIT providersChanged();
+ updateProgressSignals(wasInProgress);
+
+ if (_pendingSearch && hasSearchTerm() && _providersReady) {
+ _pendingSearch = false;
+ startSearch();
}
}
-bool UnifiedSearchResultsListModel::isSearchInProgress() const
+void UnifiedSearchResultsListModel::scheduleSearch()
{
- return !_searchJobConnections.isEmpty();
+ const auto wasInProgress = isSearchInProgress();
+ ++_queryGeneration;
+ abortSearchJobs();
+ _revealTimer.stop();
+ _debounceTimer.stop();
+ _pendingSearch = false;
+ _revealWindowClosed = false;
+ resetProviderRuntime();
+ setErrorString({});
+ if (_viewMode != ViewMode::Aggregate) {
+ _viewMode = ViewMode::Aggregate;
+ _detailProviderId.clear();
+ Q_EMIT viewModeChanged();
+ }
+ _aggregateSelectedStableKey.clear();
+ rebuildProjection();
+
+ if (hasSearchTerm()) {
+ setWaitingForSearchTermEditEnd(true);
+ _debounceTimer.start();
+ } else {
+ setWaitingForSearchTermEditEnd(false);
+ setAccessibilityStatus(tr("Search cleared"));
+ }
+ updateProgressSignals(wasInProgress);
}
-bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const
+void UnifiedSearchResultsListModel::startSearch()
{
- return !_currentFetchMoreInProgressProviderId.isEmpty();
+ if (!_accountState || !_accountState->account() || !isAccountConnected() || !hasSearchTerm() || !_providersReady) {
+ return;
+ }
+ const auto wasInProgress = isSearchInProgress();
+ abortSearchJobs();
+ _revealOrder.clear();
+ _revealWindowClosed = false;
+ resetProviderRuntime();
+ setErrorString({});
+
+ auto requestCount = 0;
+ for (const auto &providerId : _providerOrder) {
+ auto &provider = _providers[providerId];
+ if (!providerIsApplicable(provider)) {
+ continue;
+ }
+ provider.partialMatch = !providerSupportsAllContentFilters(provider);
+ provider.status = ProviderStatus::Loading;
+ ++requestCount;
+ }
+ if (requestCount == 0) {
+ rebuildProjection();
+ updateAccessibilityStatus();
+ updateProgressSignals(wasInProgress);
+ return;
+ }
+
+ _revealTimer.start();
+ for (const auto &providerId : _providerOrder) {
+ if (_providers[providerId].status == ProviderStatus::Loading) {
+ startSearchForProvider(providerId);
+ }
+ }
+ setAccessibilityStatus(tr("Searching"));
+ updateProgressSignals(wasInProgress);
}
-bool UnifiedSearchResultsListModel::hasSearchTerm() const
+void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, bool pagination)
{
- return !_searchTerm.isEmpty();
+ if (!_accountState || !_accountState->account() || !_providers.contains(providerId)) {
+ return;
+ }
+ auto &provider = _providers[providerId];
+ const auto generation = _queryGeneration;
+ auto *const job = new JsonApiJob(_accountState->account(),
+ QStringLiteral("ocs/v2.php/search/providers/%1/search").arg(QString::fromUtf8(QUrl::toPercentEncoding(providerId))));
+ job->addQueryParams(queryForProvider(provider, pagination));
+ connect(job, &JsonApiJob::jsonReceived, this,
+ [this, providerId, pagination, generation, job](const QJsonDocument &json, const int statusCode) {
+ providerSearchFinished(json, statusCode, providerId, pagination, generation, job);
+ });
+ trackSearchJob(job);
+ if (pagination) {
+ provider.paging = true;
+ provider.loadMoreFailed = false;
+ _currentFetchMoreInProgressProviderId = providerId;
+ Q_EMIT currentFetchMoreInProgressProviderIdChanged();
+ if (!updateDetailPagingState(provider)) {
+ rebuildProjection();
+ }
+ }
+ job->start();
}
-bool UnifiedSearchResultsListModel::hasSearchError() const
+void UnifiedSearchResultsListModel::providerSearchFinished(const QJsonDocument &json,
+ int statusCode,
+ const QString &providerId,
+ bool pagination,
+ quint64 generation,
+ QObject *jobObject)
{
- return !_errorString.isEmpty();
+ const auto wasInProgress = isSearchInProgress();
+ untrackSearchJob(jobObject);
+ if (generation != _queryGeneration || !_providers.contains(providerId)) {
+ updateProgressSignals(wasInProgress);
+ return;
+ }
+
+ auto &provider = _providers[providerId];
+ const auto previousEntryCount = provider.entries.size();
+ if (pagination) {
+ provider.paging = false;
+ if (_currentFetchMoreInProgressProviderId == providerId) {
+ _currentFetchMoreInProgressProviderId.clear();
+ Q_EMIT currentFetchMoreInProgressProviderIdChanged();
+ }
+ }
+
+ if (statusCode != 200) {
+ if (pagination) {
+ provider.loadMoreFailed = true;
+ } else {
+ provider.status = ProviderStatus::Failed;
+ }
+ } else {
+ const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject();
+ auto parsedEntries = parseEntries(data.value("entries"_L1).toArray(), provider,
+ pagination ? provider.entries.size() : 0);
+ const auto pageHasMore = !parsedEntries.isEmpty()
+ && data.value("isPaginated"_L1).toBool(false)
+ && !data.value("cursor"_L1).isNull()
+ && !data.value("cursor"_L1).isUndefined();
+ if (pagination) {
+ provider.entries.append(std::move(parsedEntries));
+ provider.hasMore = pageHasMore;
+ } else {
+ provider.entries = std::move(parsedEntries);
+ provider.status = ProviderStatus::Blocked;
+ provider.hasMore = pageHasMore;
+ }
+ provider.cursor = data.value("cursor"_L1);
+ provider.loadMoreFailed = false;
+ }
+
+ if (!pagination) {
+ promoteReadyProviders();
+ updateAggregateState();
+ } else if (!updateDetailProjectionAfterPagination(provider, previousEntryCount)) {
+ rebuildProjection();
+ }
+ updateAccessibilityStatus();
+ updateProgressSignals(wasInProgress);
}
-bool UnifiedSearchResultsListModel::canEditSearch() const
+QVector UnifiedSearchResultsListModel::parseEntries(const QJsonArray &entries,
+ const UnifiedSearchProvider &provider,
+ int firstEntryIndex) const
{
- return isAccountConnected() && !isFetchMoreInProgress();
+ QVector parsedEntries;
+ const auto account = _accountState ? _accountState->account() : AccountPtr();
+ const auto accountUrl = account ? account->url() : QUrl();
+ auto entryIndex = firstEntryIndex;
+ for (const auto &entryValue : entries) {
+ if (parsedEntries.size() >= requestResultsPerProvider) {
+ break;
+ }
+ const auto entry = entryValue.toObject();
+ if (entry.isEmpty()) {
+ continue;
+ }
+ UnifiedSearchResult result;
+ result._providerId = provider.id;
+ result._providerName = provider.name;
+ result._order = provider.order;
+ result._isRounded = entry.value("rounded"_L1).toBool(false);
+ result._title = entry.value("title"_L1).toString();
+ result._subline = entry.value("subline"_L1).toString();
+ result._resourceUrl = openableResourceUrl(QUrl(entry.value("resourceUrl"_L1).toString()), accountUrl);
+ const auto entryIcon = entry.value("icon"_L1).toString();
+ const auto darkNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, true);
+ const auto lightNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, false);
+ const auto darkFallbackIcon = !darkNavigationAppIcon.isEmpty()
+ ? darkNavigationAppIcon
+ : (entryIcon.isEmpty() ? providerIcon(provider, true) : entryIcon);
+ const auto lightFallbackIcon = !lightNavigationAppIcon.isEmpty()
+ ? lightNavigationAppIcon
+ : (entryIcon.isEmpty() ? providerIcon(provider, false) : entryIcon);
+ const auto darkIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), darkFallbackIcon, accountUrl, true);
+ const auto lightIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), lightFallbackIcon, accountUrl, false);
+ result._darkIcons = darkIcons.first;
+ result._lightIcons = lightIcons.first;
+ result._darkIconsIsThumbnail = darkIcons.second;
+ result._lightIconsIsThumbnail = lightIcons.second;
+ result._providerIcon = providerIcon(provider, false);
+ result._isSelectable = true;
+ result._stableKey = stableKeyForResult(provider.id, result, entryIndex++);
+ parsedEntries.push_back(result);
+ }
+ return parsedEntries;
}
-bool UnifiedSearchResultsListModel::isAccountConnected() const
+void UnifiedSearchResultsListModel::promoteReadyProviders()
{
- return _accountState && _accountState->isConnected();
+ auto unresolvedPredecessor = false;
+ auto changed = false;
+ for (const auto &providerId : _providerOrder) {
+ auto &provider = _providers[providerId];
+ if (!providerIsApplicable(provider)) {
+ continue;
+ }
+ if (provider.status == ProviderStatus::Loading) {
+ unresolvedPredecessor = true;
+ } else if (provider.status == ProviderStatus::Blocked) {
+ if (_revealWindowClosed || !unresolvedPredecessor) {
+ provider.status = ProviderStatus::Loaded;
+ if (!_revealOrder.contains(providerId)) {
+ _revealOrder.push_back(providerId);
+ }
+ changed = true;
+ } else {
+ unresolvedPredecessor = true;
+ }
+ }
+ }
+ if (changed) {
+ rebuildProjection();
+ }
}
-UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const
+void UnifiedSearchResultsListModel::closeRevealWindow()
{
- if (rowCount() > 0) {
- return SearchState::Results;
+ _revealWindowClosed = true;
+ promoteReadyProviders();
+}
+
+void UnifiedSearchResultsListModel::rebuildProjection()
+{
+ if (_selectedRow >= 0 && _selectedRow < _results.size() && _results[_selectedRow]._isSelectable) {
+ _selectedStableKey = _results[_selectedRow]._stableKey;
}
- if (hasSearchError()) {
- return (!isSearchInProgress() && !isFetchMoreInProgress()) ? SearchState::SearchError : SearchState::None;
+ QVector projection;
+ if (_viewMode == ViewMode::ProviderDetail && _providers.contains(_detailProviderId)) {
+ const auto &provider = _providers[_detailProviderId];
+ projection = provider.entries;
+ if (provider.loadMoreFailed || provider.hasMore) {
+ projection.push_back(pagingRowForProvider(provider));
+ }
+ } else {
+ QSet filteredResourceUrls;
+ for (const auto &providerId : _revealOrder) {
+ const auto &provider = _providers[providerId];
+ if (provider.partialMatch) {
+ continue;
+ }
+ for (const auto &entry : provider.entries) {
+ if (!entry._resourceUrl.isEmpty()) {
+ filteredResourceUrls.insert(entry._resourceUrl.toString());
+ }
+ }
+ appendProviderProjection(provider, false, {}, projection);
+ }
+
+ auto partialHeaderAdded = false;
+ for (const auto &providerId : _revealOrder) {
+ const auto &provider = _providers[providerId];
+ if (!provider.partialMatch || provider.entries.isEmpty()) {
+ continue;
+ }
+ const auto hasVisibleEntry = std::any_of(provider.entries.cbegin(), provider.entries.cend(),
+ [&filteredResourceUrls](const auto &entry) {
+ return entry._resourceUrl.isEmpty() || !filteredResourceUrls.contains(entry._resourceUrl.toString());
+ });
+ if (!hasVisibleEntry) {
+ continue;
+ }
+ if (!partialHeaderAdded) {
+ UnifiedSearchResult header;
+ header._type = UnifiedSearchResult::Type::PartialMatchesHeader;
+ header._title = tr("Partial matches");
+ header._stableKey = QStringLiteral("partial-matches");
+ projection.push_back(header);
+ partialHeaderAdded = true;
+ }
+ appendProviderProjection(provider, true, filteredResourceUrls, projection);
+ }
}
- if (!hasSearchTerm()) {
- return SearchState::Placeholder;
+ auto selectedRow = -1;
+ for (auto row = 0; row < projection.size(); ++row) {
+ if (!projection[row]._isSelectable) {
+ continue;
+ }
+ if (selectedRow < 0) {
+ selectedRow = row;
+ }
+ if (!_selectedStableKey.isEmpty() && projection[row]._stableKey == _selectedStableKey) {
+ selectedRow = row;
+ break;
+ }
+ }
+ for (auto row = 0; row < projection.size(); ++row) {
+ projection[row]._isSelected = row == selectedRow;
}
- if (!isSearchInProgress() && !waitingForSearchTermEditEnd()) {
- return SearchState::NothingFound;
+ beginResetModel();
+ _results = std::move(projection);
+ endResetModel();
+ setSelectedRow(selectedRow);
+ if (selectedRow >= 0) {
+ _selectedStableKey = _results[selectedRow]._stableKey;
+ } else {
+ _selectedStableKey.clear();
}
+}
- return SearchState::Skeleton;
+UnifiedSearchResult UnifiedSearchResultsListModel::pagingRowForProvider(const UnifiedSearchProvider &provider) const
+{
+ UnifiedSearchResult pagingRow;
+ pagingRow._providerId = provider.id;
+ pagingRow._providerName = provider.name;
+ pagingRow._type = provider.loadMoreFailed
+ ? UnifiedSearchResult::Type::RetryFetchMoreTrigger
+ : UnifiedSearchResult::Type::FetchMoreTrigger;
+ pagingRow._isLoading = provider.paging;
+ pagingRow._stableKey = QStringLiteral("paging:%1").arg(provider.id);
+ return pagingRow;
}
-void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const
+bool UnifiedSearchResultsListModel::updateDetailPagingState(const UnifiedSearchProvider &provider)
{
- const QUrlQuery urlQuery{resourceUrl};
- const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::ComponentFormattingOption::FullyDecoded);
- const auto fileName =
- urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::ComponentFormattingOption::FullyDecoded);
+ if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id || _results.isEmpty()) {
+ return false;
+ }
+ const auto row = _results.size() - 1;
+ auto &pagingRow = _results[row];
+ if (pagingRow._providerId != provider.id
+ || (pagingRow._type != UnifiedSearchResult::Type::FetchMoreTrigger
+ && pagingRow._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) {
+ return false;
+ }
+ pagingRow = pagingRowForProvider(provider);
+ Q_EMIT dataChanged(index(row), index(row), {TypeRole, TypeAsStringRole, LoadingRole});
+ return true;
+}
- if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) {
- if (!_accountState || !_accountState->account()) {
- return;
- }
+bool UnifiedSearchResultsListModel::updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider,
+ const int previousEntryCount)
+{
+ if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id
+ || previousEntryCount < 0 || provider.entries.size() < previousEntryCount
+ || _results.size() != previousEntryCount + 1) {
+ return false;
+ }
+ const auto pagingRowIndex = _results.size() - 1;
+ if (_results[pagingRowIndex]._providerId != provider.id
+ || (_results[pagingRowIndex]._type != UnifiedSearchResult::Type::FetchMoreTrigger
+ && _results[pagingRowIndex]._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) {
+ return false;
+ }
+
+ if (provider.loadMoreFailed) {
+ _results[pagingRowIndex] = pagingRowForProvider(provider);
+ Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex));
+ return true;
+ }
- const QString relativePath = dir + u'/' + fileName;
- const auto localFiles =
- FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account());
+ const auto appendedCount = provider.entries.size() - previousEntryCount;
+ if (appendedCount == 0) {
+ beginRemoveRows({}, pagingRowIndex, pagingRowIndex);
+ _results.removeLast();
+ endRemoveRows();
+ return true;
+ }
- if (!localFiles.isEmpty()) {
- qCInfo(lcUnifiedSearch) << "Opening file:" << localFiles.constFirst();
- QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst()));
- return;
- }
+ _results[pagingRowIndex] = provider.entries[previousEntryCount];
+ Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex));
+
+ QVector tail;
+ tail.reserve(appendedCount);
+ for (auto entryIndex = previousEntryCount + 1; entryIndex < provider.entries.size(); ++entryIndex) {
+ tail.push_back(provider.entries[entryIndex]);
}
- Utility::openBrowser(resourceUrl);
+ if (provider.hasMore) {
+ tail.push_back(pagingRowForProvider(provider));
+ }
+ if (!tail.isEmpty()) {
+ const auto firstRow = _results.size();
+ const auto lastRow = firstRow + tail.size() - 1;
+ beginInsertRows({}, firstRow, lastRow);
+ _results.append(tail);
+ endInsertRows();
+ }
+ return true;
}
-void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId)
+void UnifiedSearchResultsListModel::appendProviderProjection(const UnifiedSearchProvider &provider,
+ bool partial,
+ const QSet &filteredResourceUrls,
+ QVector &projection) const
{
- if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) {
+ QVector visibleEntries;
+ visibleEntries.reserve(aggregateResultsPerProvider + 1);
+ for (const auto &entry : provider.entries) {
+ if (partial && !entry._resourceUrl.isEmpty() && filteredResourceUrls.contains(entry._resourceUrl.toString())) {
+ continue;
+ }
+ auto visibleEntry = entry;
+ visibleEntry._isPartialMatch = partial;
+ visibleEntries.push_back(visibleEntry);
+ if (visibleEntries.size() > aggregateResultsPerProvider) {
+ break;
+ }
+ }
+ if (visibleEntries.isEmpty()) {
return;
}
- const auto providerInfo = _providers.value(providerId, {});
+ UnifiedSearchResult header;
+ header._providerId = provider.id;
+ header._providerName = provider.name;
+ header._providerIcon = visibleEntries.constFirst()._providerIcon;
+ header._title = provider.name;
+ header._hasOverflow = visibleEntries.size() > aggregateResultsPerProvider || provider.hasMore;
+ header._isPartialMatch = partial;
+ header._type = UnifiedSearchResult::Type::ProviderHeader;
+ header._stableKey = QStringLiteral("header:%1:%2").arg(partial ? QStringLiteral("partial") : QStringLiteral("full"), provider.id);
+ projection.push_back(header);
+
+ const auto visibleCount = std::min(aggregateResultsPerProvider, visibleEntries.size());
+ for (auto index = 0; index < visibleCount; ++index) {
+ projection.push_back(visibleEntries[index]);
+ }
+}
- if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) {
- // Load more items
- _currentFetchMoreInProgressProviderId = providerId;
+void UnifiedSearchResultsListModel::resetProviderRuntime()
+{
+ for (auto it = _providers.begin(); it != _providers.end(); ++it) {
+ it->status = ProviderStatus::Idle;
+ it->entries.clear();
+ it->cursor = {};
+ it->hasMore = false;
+ it->loadMoreFailed = false;
+ it->paging = false;
+ it->partialMatch = false;
+ }
+ _revealOrder.clear();
+ if (_hasPartialFailure) {
+ _hasPartialFailure = false;
+ Q_EMIT hasPartialFailureChanged();
+ }
+ if (!_currentFetchMoreInProgressProviderId.isEmpty()) {
+ _currentFetchMoreInProgressProviderId.clear();
Q_EMIT currentFetchMoreInProgressProviderIdChanged();
- startSearchForProvider(providerId, providerInfo._cursor);
}
}
-void UnifiedSearchResultsListModel::slotSearchTermEditingFinished()
+void UnifiedSearchResultsListModel::abortSearchJobs()
{
- _waitingForSearchTermEditEnd = false;
- Q_EMIT waitingForSearchTermEditEndChanged();
-
- disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
- &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
+ const auto hadJobs = !_activeSearchJobs.isEmpty() || _inFlightSearchRequests > 0;
+ for (const auto &jobObject : std::as_const(_activeSearchJobs)) {
+ if (!jobObject) {
+ continue;
+ }
+ disconnect(jobObject, nullptr, this, nullptr);
+ if (const auto job = qobject_cast(jobObject.data()); job && job->reply() && job->reply()->isRunning()) {
+ job->reply()->abort();
+ }
+ }
+ _activeSearchJobs.clear();
+ _inFlightSearchRequests = 0;
+ if (hadJobs) {
+ Q_EMIT isSearchInProgressChanged();
+ }
+}
- if (!_accountState || !_accountState->account()) {
- qCCritical(lcUnifiedSearch) << QStringLiteral("Account state is invalid. Could not start search!");
+void UnifiedSearchResultsListModel::abortProviderDiscovery()
+{
+ if (!_providerDiscoveryJob) {
+ _providersLoading = false;
return;
}
-
- if (_providers.isEmpty()) {
- auto job = new JsonApiJob(_accountState->account(), QLatin1String("ocs/v2.php/search/providers"));
- QObject::connect(job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotFetchProvidersFinished);
- job->start();
- } else {
- startSearch();
+ disconnect(_providerDiscoveryJob, nullptr, this, nullptr);
+ if (const auto job = qobject_cast(_providerDiscoveryJob.data()); job && job->reply() && job->reply()->isRunning()) {
+ job->reply()->abort();
}
+ _providerDiscoveryJob.clear();
+ _providersLoading = false;
}
-void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode)
+void UnifiedSearchResultsListModel::trackSearchJob(QObject *jobObject)
{
- const auto job = qobject_cast(sender());
+ const auto wasInProgress = isSearchInProgress();
+ _activeSearchJobs.push_back(jobObject);
+ ++_inFlightSearchRequests;
+ connect(jobObject, &QObject::destroyed, this, [this, jobObject] { untrackSearchJob(jobObject); });
+ updateProgressSignals(wasInProgress);
+}
- if (!job) {
- qCCritical(lcUnifiedSearch) << QStringLiteral("Failed to fetch providers.").arg(_searchTerm);
- _errorString += tr("Failed to fetch providers.") + u'\n';
- Q_EMIT errorStringChanged();
+void UnifiedSearchResultsListModel::untrackSearchJob(QObject *jobObject)
+{
+ const auto it = std::find_if(_activeSearchJobs.begin(), _activeSearchJobs.end(), [jobObject](const auto &candidate) {
+ return candidate == jobObject;
+ });
+ if (it == _activeSearchJobs.end()) {
return;
}
+ _activeSearchJobs.erase(it);
+ _inFlightSearchRequests = std::max(0, _inFlightSearchRequests - 1);
+}
- if (statusCode != 200) {
- qCCritical(lcUnifiedSearch) << QStringLiteral("%1: Failed to fetch search providers for '%2'. Error: %3")
- .arg(statusCode)
- .arg(_searchTerm)
- .arg(job->errorString());
- _errorString +=
- tr("Failed to fetch search providers for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString())
- + u'\n';
- Q_EMIT errorStringChanged();
- return;
+void UnifiedSearchResultsListModel::updateProgressSignals(bool wasInProgress)
+{
+ if (wasInProgress != isSearchInProgress()) {
+ Q_EMIT isSearchInProgressChanged();
}
- const auto providerList =
- json.object().value("ocs"_L1).toObject().value("data"_L1).toVariant().toList();
+}
- for (const auto &provider : providerList) {
- const auto providerMap = provider.toMap();
- const auto id = providerMap["id"_L1].toString();
- const auto name = providerMap["name"_L1].toString();
- if (!name.isEmpty() && id != "talk-message-current"_L1) {
- UnifiedSearchProvider newProvider;
- newProvider._name = name;
- newProvider._id = id;
- newProvider._order = providerMap["order"_L1].toInt();
- _providers.insert(newProvider._id, newProvider);
+void UnifiedSearchResultsListModel::updateAggregateState()
+{
+ auto applicableCount = 0;
+ auto failedCount = 0;
+ auto settledCount = 0;
+ for (const auto &providerId : _providerOrder) {
+ const auto &provider = _providers[providerId];
+ if (!providerIsApplicable(provider)) {
+ continue;
+ }
+ ++applicableCount;
+ if (provider.status == ProviderStatus::Failed) {
+ ++failedCount;
+ ++settledCount;
+ } else if (provider.status == ProviderStatus::Loaded || provider.status == ProviderStatus::Blocked) {
+ ++settledCount;
}
}
- if (!_providers.empty()) {
- startSearch();
+ const auto partialFailure = failedCount > 0 && failedCount < applicableCount;
+ if (_hasPartialFailure != partialFailure) {
+ _hasPartialFailure = partialFailure;
+ Q_EMIT hasPartialFailureChanged();
+ }
+ if (applicableCount > 0 && failedCount == applicableCount) {
+ setErrorString(tr("Search failed for all available sources. Please try again."));
+ } else if (settledCount == applicableCount) {
+ setErrorString({});
+ _revealTimer.stop();
+ promoteReadyProviders();
}
+ Q_EMIT showConnectedServicesActionChanged();
}
-void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode)
+void UnifiedSearchResultsListModel::updateAccessibilityStatus()
{
- Q_ASSERT(_accountState && _accountState->account());
-
- const auto job = qobject_cast(sender());
-
- if (!job) {
- qCCritical(lcUnifiedSearch) << QStringLiteral("Search has failed for '%2'.").arg(_searchTerm);
- _errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + u'\n';
- Q_EMIT errorStringChanged();
- return;
- }
-
- const auto providerId = job->property("providerId").toString();
-
- if (providerId.isEmpty()) {
+ if (isSearchInProgress()) {
+ setAccessibilityStatus(tr("Searching"));
return;
}
-
- if (!_searchJobConnections.isEmpty()) {
- _searchJobConnections.remove(providerId);
-
- if (_searchJobConnections.isEmpty()) {
- Q_EMIT isSearchInProgressChanged();
+ auto resultCount = 0;
+ for (const auto &result : _results) {
+ if (result._type == UnifiedSearchResult::Type::Default) {
+ ++resultCount;
}
}
+ if (_viewMode == ViewMode::ProviderDetail) {
+ setAccessibilityStatus(tr("%1 results in %2").arg(resultCount).arg(detailProviderName()));
+ } else if (resultCount == 0) {
+ setAccessibilityStatus(tr("No matching results"));
+ } else if (_hasPartialFailure) {
+ setAccessibilityStatus(tr("%1 results. Some sources are unavailable.").arg(resultCount));
+ } else {
+ setAccessibilityStatus(tr("%1 results").arg(resultCount));
+ }
+}
- if (providerId == _currentFetchMoreInProgressProviderId) {
- clearCurrentFetchMoreInProgressProviderId();
+void UnifiedSearchResultsListModel::restartForFilterChange()
+{
+ Q_EMIT activeFiltersChanged();
+ Q_EMIT providersChanged();
+ if (_viewMode != ViewMode::Aggregate) {
+ _viewMode = ViewMode::Aggregate;
+ _detailProviderId.clear();
+ Q_EMIT viewModeChanged();
+ }
+ if (hasSearchTerm()) {
+ scheduleSearch();
}
+}
- if (statusCode != 200) {
- qCCritical(lcUnifiedSearch) << QStringLiteral("%1: Search has failed for '%2'. Error: %3")
- .arg(statusCode)
- .arg(_searchTerm)
- .arg(job->errorString());
- _errorString +=
- tr("Search has failed for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + u'\n';
- Q_EMIT errorStringChanged();
+void UnifiedSearchResultsListModel::setErrorString(const QString &error)
+{
+ if (_errorString == error) {
return;
}
+ _errorString = error;
+ Q_EMIT errorStringChanged();
+}
- const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject();
- if (!data.isEmpty()) {
- parseResultsForProvider(data, providerId, job->property("appendResults").toBool());
+void UnifiedSearchResultsListModel::setProvidersReady(const bool ready)
+{
+ if (_providersReady == ready) {
+ return;
}
+ _providersReady = ready;
+ Q_EMIT providersReadyChanged();
}
-void UnifiedSearchResultsListModel::startSearch()
+void UnifiedSearchResultsListModel::setWaitingForSearchTermEditEnd(bool waiting)
{
- Q_ASSERT(_accountState && _accountState->account());
-
- disconnectAndClearSearchJobs();
+ if (_waitingForSearchTermEditEnd == waiting) {
+ return;
+ }
+ const auto wasInProgress = isSearchInProgress();
+ _waitingForSearchTermEditEnd = waiting;
+ Q_EMIT waitingForSearchTermEditEndChanged();
+ updateProgressSignals(wasInProgress);
+}
- if (!_accountState || !_accountState->account()) {
+void UnifiedSearchResultsListModel::setSelectedRow(int row)
+{
+ if (_selectedRow == row) {
return;
}
+ _selectedRow = row;
+ Q_EMIT selectedRowChanged();
+}
- if (!_results.isEmpty()) {
- beginResetModel();
- _results.clear();
- endResetModel();
+void UnifiedSearchResultsListModel::setAccessibilityStatus(const QString &status)
+{
+ if (_accessibilityStatus == status) {
+ return;
}
+ _accessibilityStatus = status;
+ Q_EMIT accessibilityStatusChanged();
+}
- for (const auto &provider : std::as_const(_providers)) {
- startSearchForProvider(provider._id);
+bool UnifiedSearchResultsListModel::providerIsApplicable(const UnifiedSearchProvider &provider) const
+{
+ if (!_selectedProviderIds.isEmpty()) {
+ return _selectedProviderIds.contains(provider.id);
}
+ return !provider.isExternalProvider || _externalProvidersEnabled;
}
-void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor)
+bool UnifiedSearchResultsListModel::providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const
{
- Q_ASSERT(_accountState && _accountState->account());
+ const auto supportsDate = !_since.isValid() || (provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until")));
+ const auto supportsPerson = _personId.isEmpty() || provider.filters.contains(QStringLiteral("person"));
+ return supportsDate && supportsPerson;
+}
- if (!_accountState || !_accountState->account()) {
- return;
+QString UnifiedSearchResultsListModel::providerIcon(const UnifiedSearchProvider &provider, const bool darkMode) const
+{
+ if (_accountState) {
+ if (const auto app = _accountState->findApp(provider.appId); app && !app->iconUrl().isEmpty()) {
+ return app->iconUrl().toString();
+ }
}
- auto job = new JsonApiJob(_accountState->account(),
- QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId));
+ const auto account = _accountState ? _accountState->account() : AccountPtr();
+ return normalizedIcon(provider.icon, account ? account->url() : QUrl(), darkMode);
+}
- QUrlQuery params;
- params.addQueryItem(QStringLiteral("term"), _searchTerm);
- if (cursor > 0) {
- params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor));
- job->setProperty("appendResults", true);
+QUrlQuery UnifiedSearchResultsListModel::queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const
+{
+ QUrlQuery query;
+ query.addQueryItem(QStringLiteral("term"), _searchTerm);
+ query.addQueryItem(QStringLiteral("limit"), QString::number(requestResultsPerProvider));
+ if (_since.isValid() && provider.filters.contains(QStringLiteral("since"))) {
+ query.addQueryItem(QStringLiteral("since"), _since.toUTC().toString(Qt::ISODateWithMs));
}
- job->setProperty("providerId", providerId);
- job->addQueryParams(params);
- const auto wasSearchInProgress = isSearchInProgress();
- _searchJobConnections.insert(providerId,
- QObject::connect(
- job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotSearchForProviderFinished));
- if (isSearchInProgress() && !wasSearchInProgress) {
- Q_EMIT isSearchInProgressChanged();
+ if (_until.isValid() && provider.filters.contains(QStringLiteral("until"))) {
+ query.addQueryItem(QStringLiteral("until"), _until.toUTC().toString(Qt::ISODateWithMs));
}
- job->start();
+ if (!_personId.isEmpty() && provider.filters.contains(QStringLiteral("person"))) {
+ query.addQueryItem(QStringLiteral("person"), _personId);
+ }
+ if (pagination && !provider.cursor.isNull() && !provider.cursor.isUndefined()) {
+ query.addQueryItem(QStringLiteral("cursor"), provider.cursor.toVariant().toString());
+ }
+ return query;
}
-void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore)
+QString UnifiedSearchResultsListModel::stableKeyForResult(const QString &providerId,
+ const UnifiedSearchResult &result,
+ int entryIndex)
{
- const auto cursor = data.value("cursor"_L1).toInt();
- const auto entries = data.value("entries"_L1).toVariant().toList();
+ return QStringLiteral("result:%1:%2:%3").arg(providerId, result._resourceUrl.toString(), QString::number(entryIndex));
+}
- auto &provider = _providers[providerId];
+QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl)
+{
+ return resourceUrl.isRelative() ? accountUrl.resolved(resourceUrl) : resourceUrl;
+}
- if (provider._id.isEmpty() && fetchedMore) {
- _providers.remove(providerId);
- return;
+void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const
+{
+ const QUrlQuery urlQuery{resourceUrl};
+ const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::FullyDecoded);
+ const auto fileName = urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::FullyDecoded);
+ if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) {
+ if (!_accountState || !_accountState->account()) {
+ return;
+ }
+ const auto relativePath = dir + u'/' + fileName;
+ const auto localFiles = FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account());
+ if (!localFiles.isEmpty()) {
+ QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst()));
+ return;
+ }
}
+ Utility::openBrowser(resourceUrl);
+}
- if (entries.isEmpty()) {
- // we may have received false pagination information from the server, such as, we expect more
- // results available via pagination, but, there are no more left, so, we need to stop paginating for
- // this provider
- provider._isPaginated = false;
-
- if (fetchedMore) {
- removeFetchMoreTrigger(provider._id);
- }
+void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) { loadMore(providerId); }
+void UnifiedSearchResultsListModel::toggleProviderFilter(const QString &providerId)
+{
+ if (!_providers.contains(providerId)) {
return;
}
-
- provider._isPaginated = data.value("isPaginated"_L1).toBool();
- provider._cursor = cursor;
-
- if (provider._pageSize == -1) {
- provider._pageSize = cursor;
+ if (_selectedProviderIds.contains(providerId)) {
+ _selectedProviderIds.removeAll(providerId);
+ } else {
+ _selectedProviderIds.push_back(providerId);
}
+ restartForFilterChange();
+}
- if ((provider._pageSize != -1 && entries.size() < provider._pageSize)
- || entries.size() < minimumEntresNumberToShowLoadMore) {
- // for some providers we are still getting a non-null cursor and isPaginated true even thought
- // there are no more results to paginate
- provider._isPaginated = false;
+void UnifiedSearchResultsListModel::clearTypeFilters()
+{
+ if (_selectedProviderIds.isEmpty()) {
+ return;
}
+ _selectedProviderIds.clear();
+ restartForFilterChange();
+}
- QVector newEntries;
-
- for (const auto &entry : entries) {
- const auto entryMap = entry.toMap();
- if (entryMap.isEmpty()) {
- continue;
- }
- UnifiedSearchResult result;
- result._providerId = provider._id;
- result._order = provider._order;
- result._providerName = provider._name;
- result._isRounded = entryMap.value("rounded"_L1).toBool();
- result._title = entryMap.value("title"_L1).toString();
- result._subline = entryMap.value("subline"_L1).toString();
-
- const auto resourceUrl = entryMap.value("resourceUrl"_L1).toUrl();
- const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl();
-
- result._resourceUrl = openableResourceUrl(resourceUrl, accountUrl);
- const auto darkIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(),
- entryMap.value("icon"_L1).toString(), accountUrl, true);
- const auto lightIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(),
- entryMap.value("icon"_L1).toString(), accountUrl, false);
- result._darkIcons = darkIconsData.first;
- result._lightIcons = lightIconsData.first;
- result._darkIconsIsThumbnail = darkIconsData.second;
- result._lightIconsIsThumbnail = lightIconsData.second;
-
- newEntries.push_back(result);
- }
-
- if (fetchedMore) {
- appendResultsToProvider(newEntries, provider);
+void UnifiedSearchResultsListModel::setDatePreset(const QString &preset)
+{
+ const auto today = QDate::currentDate();
+ auto first = today;
+ auto last = today;
+ if (preset == QStringLiteral("today")) {
+ _dateLabel = tr("Today");
+ } else if (preset == QStringLiteral("7days") || preset == QStringLiteral("last7days")) {
+ first = today.addDays(-6);
+ _dateLabel = tr("Last 7 days");
+ } else if (preset == QStringLiteral("30days") || preset == QStringLiteral("last30days")) {
+ first = today.addDays(-29);
+ _dateLabel = tr("Last 30 days");
+ } else if (preset == QStringLiteral("thisyear")) {
+ first = QDate(today.year(), 1, 1);
+ last = QDate(today.year(), 12, 31);
+ _dateLabel = tr("This year");
+ } else if (preset == QStringLiteral("lastyear")) {
+ first = QDate(today.year() - 1, 1, 1);
+ last = QDate(today.year() - 1, 12, 31);
+ _dateLabel = tr("Last year");
} else {
- appendResults(newEntries, provider);
+ return;
}
+ const auto zone = QTimeZone::systemTimeZone();
+ _since = QDateTime(first, QTime(0, 0), zone);
+ _until = QDateTime(last, QTime(23, 59, 59, 999), zone);
+ restartForFilterChange();
}
-QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl)
+bool UnifiedSearchResultsListModel::setCustomDateRange(const QString &sinceDate, const QString &untilDate)
{
- if (!resourceUrl.isRelative()) {
- return resourceUrl;
+ const auto first = QDate::fromString(sinceDate, Qt::ISODate);
+ const auto last = QDate::fromString(untilDate, Qt::ISODate);
+ if (!first.isValid() || !last.isValid() || last < first) {
+ return false;
}
-
- QUrl finalResourceUrl(accountUrl);
- finalResourceUrl.setPath(resourceUrl.toString());
- return finalResourceUrl;
+ const auto zone = QTimeZone::systemTimeZone();
+ _since = QDateTime(first, QTime(0, 0), zone);
+ _until = QDateTime(last, QTime(23, 59, 59, 999), zone);
+ _dateLabel = tr("%1 – %2").arg(QLocale().toString(first, QLocale::ShortFormat), QLocale().toString(last, QLocale::ShortFormat));
+ restartForFilterChange();
+ return true;
}
-void UnifiedSearchResultsListModel::appendResults(QVector results, const UnifiedSearchProvider &provider)
+void UnifiedSearchResultsListModel::clearDateFilter()
{
- if (provider._cursor > 0 && provider._isPaginated) {
- UnifiedSearchResult fetchMoreTrigger;
- fetchMoreTrigger._providerId = provider._id;
- fetchMoreTrigger._providerName = provider._name;
- fetchMoreTrigger._order = provider._order;
- fetchMoreTrigger._type = UnifiedSearchResult::Type::FetchMoreTrigger;
- results.push_back(fetchMoreTrigger);
+ if (!_since.isValid() && !_until.isValid()) {
+ return;
}
+ _since = {};
+ _until = {};
+ _dateLabel.clear();
+ restartForFilterChange();
+}
-
- if (_results.isEmpty()) {
- beginInsertRows({}, 0, results.size() - 1);
- _results = results;
- endInsertRows();
+void UnifiedSearchResultsListModel::setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl)
+{
+ if (userId.isEmpty()) {
return;
}
+ _personId = userId;
+ _personName = displayName;
+ _personAvatarUrl = avatarUrl;
+ restartForFilterChange();
+}
- // insertion is done with sorting (first -> by order, then -> by name)
- const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results),
- [&provider](const UnifiedSearchResult ¤t) {
- // insert before other results of higher order when possible
- if (current._order > provider._order) {
- return true;
- } else {
- if (current._order == provider._order) {
- // insert before results of higher QString value when possible
- return current._providerName > provider._name;
- }
+void UnifiedSearchResultsListModel::clearPersonFilter()
+{
+ if (_personId.isEmpty()) {
+ return;
+ }
+ _personId.clear();
+ _personName.clear();
+ _personAvatarUrl.clear();
+ restartForFilterChange();
+}
- return false;
- }
- });
+void UnifiedSearchResultsListModel::removeFilter(const QString &type, const QString &id)
+{
+ if (type == QStringLiteral("provider")) {
+ if (_selectedProviderIds.removeAll(id) > 0) {
+ restartForFilterChange();
+ }
+ } else if (type == QStringLiteral("date")) {
+ clearDateFilter();
+ } else if (type == QStringLiteral("person")) {
+ clearPersonFilter();
+ }
+}
- const auto first = static_cast(std::distance(std::begin(_results), itToInsertTo));
- const auto last = first + results.size() - 1;
+void UnifiedSearchResultsListModel::setExternalProvidersEnabled(bool enabled)
+{
+ if (_externalProvidersEnabled == enabled) {
+ return;
+ }
+ _externalProvidersEnabled = enabled;
+ Q_EMIT externalProvidersEnabledChanged();
+ if (hasSearchTerm()) {
+ scheduleSearch();
+ }
+}
- beginInsertRows({}, first, last);
- std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo));
- endInsertRows();
+void UnifiedSearchResultsListModel::openProviderDetail(const QString &providerId)
+{
+ const auto providerIt = _providers.constFind(providerId);
+ if (providerIt == _providers.cend()
+ || (providerIt->entries.size() <= aggregateResultsPerProvider && !providerIt->hasMore)) {
+ return;
+ }
+ _aggregateSelectedStableKey = _selectedStableKey;
+ _detailProviderId = providerId;
+ _viewMode = ViewMode::ProviderDetail;
+ _selectedStableKey.clear();
+ Q_EMIT viewModeChanged();
+ rebuildProjection();
+ updateAccessibilityStatus();
}
-void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider)
+void UnifiedSearchResultsListModel::closeProviderDetail()
{
- if (results.isEmpty()) {
+ if (_viewMode == ViewMode::Aggregate) {
return;
}
+ _viewMode = ViewMode::Aggregate;
+ _detailProviderId.clear();
+ _selectedStableKey = _aggregateSelectedStableKey;
+ _aggregateSelectedStableKey.clear();
+ Q_EMIT viewModeChanged();
+ rebuildProjection();
+ updateAccessibilityStatus();
+}
- const auto providerId = provider._id;
- /* we need to find the last result that is not a fetch-more-trigger or category-separator for the current
- provider */
- const auto itLastResultForProviderReverse =
- std::find_if(std::rbegin(_results), std::rend(_results), [&providerId](const UnifiedSearchResult &result) {
- return result._providerId == providerId && result._type == UnifiedSearchResult::Type::Default;
- });
+void UnifiedSearchResultsListModel::loadMore(const QString &providerId)
+{
+ if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != providerId || !_providers.contains(providerId)) {
+ return;
+ }
+ const auto &provider = _providers[providerId];
+ if (provider.paging || (!provider.hasMore && !provider.loadMoreFailed)) {
+ return;
+ }
+ startSearchForProvider(providerId, true);
+}
- if (itLastResultForProviderReverse != std::rend(_results)) {
- // #1 Insert rows
- // convert reverse_iterator to iterator
- const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base();
- const auto first = static_cast(std::distance(std::begin(_results), itLastResultForProvider + 1));
- const auto last = first + results.size() - 1;
- beginInsertRows({}, first, last);
- std::copy(std::begin(results), std::end(results), std::inserter(_results, itLastResultForProvider + 1));
- endInsertRows();
+void UnifiedSearchResultsListModel::retryLoadMore(const QString &providerId) { loadMore(providerId); }
- // #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider
- if (!provider._isPaginated) {
- removeFetchMoreTrigger(providerId);
+void UnifiedSearchResultsListModel::retryFailedProviders()
+{
+ if (!hasSearchTerm()) {
+ return;
+ }
+ _revealWindowClosed = true;
+ setErrorString({});
+ for (const auto &providerId : _providerOrder) {
+ auto &provider = _providers[providerId];
+ if (provider.status == ProviderStatus::Failed && providerIsApplicable(provider)) {
+ provider.status = ProviderStatus::Loading;
+ startSearchForProvider(providerId);
}
}
+ if (_hasPartialFailure) {
+ _hasPartialFailure = false;
+ Q_EMIT hasPartialFailureChanged();
+ }
}
-void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId)
+void UnifiedSearchResultsListModel::retry()
{
- const auto itFetchMoreTriggerForProviderReverse = std::find_if(
- std::rbegin(_results),
- std::rend(_results),
- [providerId](const UnifiedSearchResult &result) {
- return result._providerId == providerId && result._type == UnifiedSearchResult::Type::FetchMoreTrigger;
- });
-
- if (itFetchMoreTriggerForProviderReverse != std::rend(_results)) {
- // convert reverse_iterator to iterator
- const auto itFetchMoreTriggerForProvider = (itFetchMoreTriggerForProviderReverse + 1).base();
-
- if (itFetchMoreTriggerForProvider != std::end(_results)
- && itFetchMoreTriggerForProvider != std::begin(_results)) {
- const auto eraseIndex = static_cast(std::distance(std::begin(_results), itFetchMoreTriggerForProvider));
- Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast(_results.size()));
- beginRemoveRows({}, eraseIndex, eraseIndex);
- _results.erase(itFetchMoreTriggerForProvider);
- endRemoveRows();
- }
+ if (!_providersReady) {
+ discoverProviders();
+ } else if (hasSearchTerm()) {
+ scheduleSearch();
}
}
-void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs()
+void UnifiedSearchResultsListModel::moveSelection(SelectionDirection direction)
{
- for (const auto &connection : std::as_const(_searchJobConnections)) {
- if (connection) {
- QObject::disconnect(connection);
+ QVector selectableRows;
+ for (auto row = 0; row < _results.size(); ++row) {
+ if (_results[row]._isSelectable) {
+ selectableRows.push_back(row);
}
}
+ if (selectableRows.isEmpty()) {
+ return;
+ }
- if (!_searchJobConnections.isEmpty()) {
- _searchJobConnections.clear();
- Q_EMIT isSearchInProgressChanged();
+ auto newRow = selectableRows.constFirst();
+ const auto currentPosition = selectableRows.indexOf(_selectedRow);
+ switch (direction) {
+ case SelectionDirection::Previous:
+ newRow = currentPosition <= 0 ? selectableRows.constFirst() : selectableRows[currentPosition - 1];
+ break;
+ case SelectionDirection::Next:
+ newRow = currentPosition < 0 || currentPosition >= selectableRows.size() - 1
+ ? selectableRows.constLast()
+ : selectableRows[currentPosition + 1];
+ break;
+ case SelectionDirection::First:
+ newRow = selectableRows.constFirst();
+ break;
+ case SelectionDirection::Last:
+ newRow = selectableRows.constLast();
+ break;
+ }
+ if (newRow == _selectedRow) {
+ return;
+ }
+ const auto previousRow = _selectedRow;
+ if (previousRow >= 0 && previousRow < _results.size()) {
+ _results[previousRow]._isSelected = false;
+ }
+ _results[newRow]._isSelected = true;
+ _selectedStableKey = _results[newRow]._stableKey;
+ setSelectedRow(newRow);
+ if (previousRow >= 0) {
+ Q_EMIT dataChanged(index(previousRow), index(previousRow), {SelectedRole});
}
+ Q_EMIT dataChanged(index(newRow), index(newRow), {SelectedRole});
}
-void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId()
+void UnifiedSearchResultsListModel::activateSelected() const
{
- if (!_currentFetchMoreInProgressProviderId.isEmpty()) {
- _currentFetchMoreInProgressProviderId.clear();
- Q_EMIT currentFetchMoreInProgressProviderIdChanged();
+ if (_selectedRow < 0 || _selectedRow >= _results.size()) {
+ return;
+ }
+ const auto &result = _results[_selectedRow];
+ if (result._isSelectable) {
+ resultClicked(result._providerId, result._resourceUrl);
}
}
-
}
diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h
index 2a8863f8c44f5..8dbff91f395d4 100644
--- a/src/gui/search/unifiedsearchresultslistmodel.h
+++ b/src/gui/search/unifiedsearchresultslistmodel.h
@@ -5,28 +5,36 @@
#pragma once
+#include "accountstate.h"
#include "unifiedsearchresult.h"
-#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
-#include
+#include
namespace OCC {
-class AccountState;
/**
- * @brief The UnifiedSearchResultsListModel
+ * @brief Account-scoped presentation model for Nextcloud Unified Search.
* @ingroup gui
- * Simple list model to provide the list view with data for the Unified Search results.
+ *
+ * The model owns provider discovery, concurrent searches, stable provider
+ * reveal order, filters, aggregate/detail projections and keyboard selection.
*/
-
class UnifiedSearchResultsListModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(bool isSearchInProgress READ isSearchInProgress NOTIFY isSearchInProgressChanged)
- Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY
- currentFetchMoreInProgressProviderIdChanged)
+ Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY currentFetchMoreInProgressProviderIdChanged)
Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged)
Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged)
Q_PROPERTY(bool waitingForSearchTermEditEnd READ waitingForSearchTermEditEnd NOTIFY waitingForSearchTermEditEndChanged)
@@ -36,18 +44,23 @@ class UnifiedSearchResultsListModel : public QAbstractListModel
Q_PROPERTY(bool canEditSearch READ canEditSearch NOTIFY canEditSearchChanged)
Q_PROPERTY(bool isAccountConnected READ isAccountConnected NOTIFY canEditSearchChanged)
Q_PROPERTY(SearchState searchState READ searchState NOTIFY searchStateChanged)
-
- struct UnifiedSearchProvider
- {
- QString _id;
- QString _name;
- qint32 _cursor = -1; // current pagination value
- qint32 _pageSize = -1; // how many max items per step of pagination
- bool _isPaginated = false;
- qint32 _order = std::numeric_limits::max(); // sorting order (smaller number has bigger priority)
- };
+ Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged)
+ Q_PROPERTY(QVariantList activeFilters READ activeFilters NOTIFY activeFiltersChanged)
+ Q_PROPERTY(bool providersReady READ providersReady NOTIFY providersReadyChanged)
+ Q_PROPERTY(bool dateFilterAvailable READ dateFilterAvailable NOTIFY providersChanged)
+ Q_PROPERTY(bool peopleFilterAvailable READ peopleFilterAvailable NOTIFY providersChanged)
+ Q_PROPERTY(bool hasExternalProviders READ hasExternalProviders NOTIFY providersChanged)
+ Q_PROPERTY(bool externalProvidersEnabled READ externalProvidersEnabled NOTIFY externalProvidersEnabledChanged)
+ Q_PROPERTY(bool showConnectedServicesAction READ showConnectedServicesAction NOTIFY showConnectedServicesActionChanged)
+ Q_PROPERTY(bool hasPartialFailure READ hasPartialFailure NOTIFY hasPartialFailureChanged)
+ Q_PROPERTY(ViewMode viewMode READ viewMode NOTIFY viewModeChanged)
+ Q_PROPERTY(QString detailProviderName READ detailProviderName NOTIFY viewModeChanged)
+ Q_PROPERTY(int selectedRow READ selectedRow NOTIFY selectedRowChanged)
+ Q_PROPERTY(QString accessibilityStatus READ accessibilityStatus NOTIFY accessibilityStatusChanged)
+ Q_PROPERTY(AccountState *accountState READ accountState CONSTANT)
public:
+ /** @brief The top-level presentation state of the search results view. */
enum class SearchState {
None,
Placeholder,
@@ -58,9 +71,36 @@ class UnifiedSearchResultsListModel : public QAbstractListModel
};
Q_ENUM(SearchState)
+ /** @brief Selects between results from all providers and one provider. */
+ enum class ViewMode {
+ Aggregate,
+ ProviderDetail,
+ };
+ Q_ENUM(ViewMode)
+
+ /** @brief Describes how keyboard navigation moves the current selection. */
+ enum class SelectionDirection {
+ Previous,
+ Next,
+ First,
+ Last,
+ };
+ Q_ENUM(SelectionDirection)
+
+ /** @brief Identifies how a result-model row is rendered in QML. */
+ enum class ResultType {
+ Default = static_cast(UnifiedSearchResult::Type::Default),
+ ProviderHeader = static_cast(UnifiedSearchResult::Type::ProviderHeader),
+ PartialMatchesHeader = static_cast(UnifiedSearchResult::Type::PartialMatchesHeader),
+ FetchMoreTrigger = static_cast(UnifiedSearchResult::Type::FetchMoreTrigger),
+ RetryFetchMoreTrigger = static_cast(UnifiedSearchResult::Type::RetryFetchMoreTrigger),
+ };
+ Q_ENUM(ResultType)
+
enum DataRole {
ProviderNameRole = Qt::UserRole + 1,
ProviderIdRole,
+ ProviderIconRole,
DarkImagePlaceholderRole,
LightImagePlaceholderRole,
DarkIconsRole,
@@ -73,50 +113,69 @@ class UnifiedSearchResultsListModel : public QAbstractListModel
RoundedRole,
TypeRole,
TypeAsStringRole,
+ StableKeyRole,
+ SelectedRole,
+ SelectableRole,
+ PartialMatchRole,
+ HasOverflowRole,
+ LoadingRole,
};
- explicit UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent = nullptr);
+ explicit UnifiedSearchResultsListModel(AccountState *accountState,
+ int debounceInterval = 300,
+ int revealInterval = 1000,
+ QObject *parent = nullptr);
+ ~UnifiedSearchResultsListModel() override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ [[nodiscard]] QHash roleNames() const override;
[[nodiscard]] bool isSearchInProgress() const;
-
[[nodiscard]] QString currentFetchMoreInProgressProviderId() const;
[[nodiscard]] QString searchTerm() const;
[[nodiscard]] QString errorString() const;
[[nodiscard]] bool waitingForSearchTermEditEnd() const;
-
[[nodiscard]] bool isFetchMoreInProgress() const;
[[nodiscard]] bool hasSearchTerm() const;
[[nodiscard]] bool hasSearchError() const;
[[nodiscard]] bool canEditSearch() const;
- /** @brief Returns whether the account is connected. */
[[nodiscard]] bool isAccountConnected() const;
[[nodiscard]] SearchState searchState() const;
+ [[nodiscard]] QVariantList providers() const;
+ [[nodiscard]] QVariantList activeFilters() const;
+ [[nodiscard]] bool providersReady() const;
+ [[nodiscard]] bool dateFilterAvailable() const;
+ [[nodiscard]] bool peopleFilterAvailable() const;
+ [[nodiscard]] bool hasExternalProviders() const;
+ [[nodiscard]] bool externalProvidersEnabled() const;
+ [[nodiscard]] bool showConnectedServicesAction() const;
+ [[nodiscard]] bool hasPartialFailure() const;
+ [[nodiscard]] ViewMode viewMode() const;
+ [[nodiscard]] QString detailProviderName() const;
+ [[nodiscard]] int selectedRow() const;
+ [[nodiscard]] QString accessibilityStatus() const;
+ [[nodiscard]] AccountState *accountState() const;
Q_INVOKABLE void resultClicked(const QString &providerId, const QUrl &resourceUrl) const;
Q_INVOKABLE void fetchMoreTriggerClicked(const QString &providerId);
-
- [[nodiscard]] QHash roleNames() const override;
-
-private:
- void startSearch();
- void startSearchForProvider(const QString &providerId, qint32 cursor = -1);
-
- void parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore = false);
-
- // append initial search results to the list
- void appendResults(QVector results, const UnifiedSearchProvider &provider);
-
- // append pagination results to existing results from the initial search
- void appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider);
-
- void removeFetchMoreTrigger(const QString &providerId);
-
- void disconnectAndClearSearchJobs();
-
- void clearCurrentFetchMoreInProgressProviderId();
+ Q_INVOKABLE void toggleProviderFilter(const QString &providerId);
+ Q_INVOKABLE void clearTypeFilters();
+ Q_INVOKABLE void setDatePreset(const QString &preset);
+ Q_INVOKABLE bool setCustomDateRange(const QString &sinceDate, const QString &untilDate);
+ Q_INVOKABLE void clearDateFilter();
+ Q_INVOKABLE void setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl = {});
+ Q_INVOKABLE void clearPersonFilter();
+ Q_INVOKABLE void removeFilter(const QString &type, const QString &id = {});
+ Q_INVOKABLE void setExternalProvidersEnabled(bool enabled);
+ Q_INVOKABLE void openProviderDetail(const QString &providerId);
+ Q_INVOKABLE void closeProviderDetail();
+ Q_INVOKABLE void loadMore(const QString &providerId);
+ Q_INVOKABLE void retryLoadMore(const QString &providerId);
+ Q_INVOKABLE void retryFailedProviders();
+ Q_INVOKABLE void retry();
+ Q_INVOKABLE void moveSelection(SelectionDirection direction);
+ Q_INVOKABLE void activateSelected() const;
Q_SIGNALS:
void currentFetchMoreInProgressProviderIdChanged();
@@ -126,31 +185,133 @@ class UnifiedSearchResultsListModel : public QAbstractListModel
void waitingForSearchTermEditEndChanged();
void canEditSearchChanged();
void searchStateChanged();
+ void providersChanged();
+ void providersReadyChanged();
+ void activeFiltersChanged();
+ void externalProvidersEnabledChanged();
+ void showConnectedServicesActionChanged();
+ void hasPartialFailureChanged();
+ void viewModeChanged();
+ void selectedRowChanged();
+ void accessibilityStatusChanged();
public Q_SLOTS:
void setSearchTerm(const QString &term);
-private Q_SLOTS:
- void slotSearchTermEditingFinished();
- void slotFetchProvidersFinished(const QJsonDocument &json, int statusCode);
- void slotSearchForProviderFinished(const QJsonDocument &json, int statusCode);
-
private:
- static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl);
+ enum class ProviderStatus {
+ Idle,
+ Loading,
+ Blocked,
+ Loaded,
+ Failed,
+ };
+
+ struct UnifiedSearchProvider
+ {
+ QString id;
+ QString appId;
+ QString name;
+ QString icon;
+ QSet filters;
+ int order = std::numeric_limits::max();
+ int discoveryIndex = -1;
+ bool isExternalProvider = false;
+
+ ProviderStatus status = ProviderStatus::Idle;
+ QVector entries;
+ QJsonValue cursor;
+ bool hasMore = false;
+ bool loadMoreFailed = false;
+ bool paging = false;
+ bool partialMatch = false;
+ };
- QMap _providers;
+ void discoverProviders();
+ void providerDiscoveryFinished(const QJsonDocument &json, int statusCode, quint64 generation, QObject *jobObject);
+ void scheduleSearch();
+ void startSearch();
+ void startSearchForProvider(const QString &providerId, bool pagination = false);
+ void providerSearchFinished(const QJsonDocument &json,
+ int statusCode,
+ const QString &providerId,
+ bool pagination,
+ quint64 generation,
+ QObject *jobObject);
+ [[nodiscard]] QVector parseEntries(const QJsonArray &entries,
+ const UnifiedSearchProvider &provider,
+ int firstEntryIndex = 0) const;
+ void promoteReadyProviders();
+ void closeRevealWindow();
+ void rebuildProjection();
+ [[nodiscard]] UnifiedSearchResult pagingRowForProvider(const UnifiedSearchProvider &provider) const;
+ [[nodiscard]] bool updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider, int previousEntryCount);
+ [[nodiscard]] bool updateDetailPagingState(const UnifiedSearchProvider &provider);
+ void appendProviderProjection(const UnifiedSearchProvider &provider,
+ bool partial,
+ const QSet &filteredResourceUrls,
+ QVector &projection) const;
+ void resetProviderRuntime();
+ void abortSearchJobs();
+ void abortProviderDiscovery();
+ void trackSearchJob(QObject *jobObject);
+ void untrackSearchJob(QObject *jobObject);
+ void updateProgressSignals(bool wasInProgress);
+ void updateAggregateState();
+ void updateAccessibilityStatus();
+ void restartForFilterChange();
+ void setErrorString(const QString &error);
+ void setProvidersReady(bool ready);
+ void setWaitingForSearchTermEditEnd(bool waiting);
+ void setSelectedRow(int row);
+ void setAccessibilityStatus(const QString &status);
+ [[nodiscard]] bool providerIsApplicable(const UnifiedSearchProvider &provider) const;
+ [[nodiscard]] bool providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const;
+ [[nodiscard]] QString providerIcon(const UnifiedSearchProvider &provider, bool darkMode) const;
+ [[nodiscard]] QUrlQuery queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const;
+ [[nodiscard]] static QString stableKeyForResult(const QString &providerId,
+ const UnifiedSearchResult &result,
+ int entryIndex);
+ [[nodiscard]] static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl);
+
+ QHash _providers;
+ QVector _providerOrder;
+ QVector _revealOrder;
QVector _results;
+ QVector> _activeSearchJobs;
+ QPointer _providerDiscoveryJob;
QString _searchTerm;
QString _errorString;
- bool _waitingForSearchTermEditEnd = false;
-
QString _currentFetchMoreInProgressProviderId;
+ QStringList _selectedProviderIds;
+ QDateTime _since;
+ QDateTime _until;
+ QString _dateLabel;
+ QString _personId;
+ QString _personName;
+ QString _personAvatarUrl;
+ QString _detailProviderId;
+ QString _selectedStableKey;
+ QString _aggregateSelectedStableKey;
+ QString _accessibilityStatus;
- QMap _searchJobConnections;
-
- QTimer _unifiedSearchTextEditingFinishedTimer;
-
+ bool _waitingForSearchTermEditEnd = false;
+ bool _providersLoading = false;
+ bool _providersReady = false;
+ bool _pendingSearch = false;
+ bool _revealWindowClosed = false;
+ bool _externalProvidersEnabled = false;
+ bool _hasPartialFailure = false;
+ bool _lastKnownConnected = false;
+ int _inFlightSearchRequests = 0;
+ int _selectedRow = -1;
+ quint64 _queryGeneration = 0;
+ quint64 _providerGeneration = 0;
+ ViewMode _viewMode = ViewMode::Aggregate;
+
+ QTimer _debounceTimer;
+ QTimer _revealTimer;
AccountState *_accountState = nullptr;
};
}
diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp
index e40778acf9153..50d4aafcfb949 100644
--- a/src/gui/systray.cpp
+++ b/src/gui/systray.cpp
@@ -461,7 +461,8 @@ void Systray::showSearchWindow(int userIndex)
return;
}
- auto *const searchModel = new UnifiedSearchResultsListModel(accountState.data(), accountState.data());
+ const auto searchModel = new UnifiedSearchResultsListModel(accountState.data());
+ searchModel->setParent(accountState.data());
const QVariantMap initialProperties{
{"account", QVariantMap{
{"avatar", user->avatarUrl()},
diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml
index fe8de4c521ab5..f86d038dcaac5 100644
--- a/src/gui/wizard/qml/WizardButton.qml
+++ b/src/gui/wizard/qml/WizardButton.qml
@@ -4,8 +4,9 @@
*/
import QtQuick
-import QtQuick.Controls
import QtQuick.Controls.Basic as BasicControls
+import QtQuick.Effects
+import QtQuick.Layouts
import Style
BasicControls.Button {
@@ -14,7 +15,11 @@ BasicControls.Button {
property bool primary: false
property string iconSource: ""
property bool iconBeforeText: false
+ property bool tintIcon: false
+ property color iconTintColor: Style.wizardPrimaryText
+ property real cornerRadius: Style.mediumRoundedButtonRadius
property string textSuffix: ""
+ property string trailingIconSource: ""
readonly property color primaryColor: Style.wizardPrimaryButtonBackground
readonly property color primaryPressedColor: Style.wizardPrimaryButtonPressed
readonly property color secondaryColor: Style.wizardSecondaryButtonBackground
@@ -24,75 +29,112 @@ BasicControls.Button {
readonly property color disabledBorderColor: Style.wizardDisabledButtonBorder
implicitHeight: Style.wizardFooterButtonHeight
- leftPadding: 18
- rightPadding: 18
- font.pixelSize: Style.pixelSize + 3
+ leftPadding: Style.wizardButtonHorizontalPadding
+ rightPadding: Style.wizardButtonHorizontalPadding
+ font.pixelSize: Style.wizardButtonFontPixelSize
font.weight: Font.Medium
Accessible.role: Accessible.Button
Accessible.name: textSuffix === "" ? text : text + " " + textSuffix
- contentItem: Item {
- implicitWidth: contentRow.implicitWidth
- implicitHeight: contentRow.implicitHeight
+ contentItem: RowLayout {
+ spacing: Style.wizardButtonContentSpacing
- Row {
- id: contentRow
-
- anchors.centerIn: parent
- spacing: 6
+ Item {
+ visible: root.iconSource !== "" && root.iconBeforeText
+ Layout.preferredWidth: visible ? Style.smallIconSize : 0
+ Layout.preferredHeight: Style.smallIconSize
Image {
- visible: root.iconSource !== "" && root.iconBeforeText
- source: root.iconSource
+ id: leadingIconImage
+
+ anchors.fill: parent
+ visible: !root.tintIcon
+ source: root.iconSource !== "" && root.iconBeforeText ? root.iconSource : ""
sourceSize.width: Style.smallIconSize
sourceSize.height: Style.smallIconSize
- width: visible ? Style.smallIconSize : 0
- height: Style.smallIconSize
- anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
+ Accessible.ignored: true
}
- Text {
- text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix
- font: root.font
- color: root.enabled
- ? (root.primary ? Style.wizardSelectedText : root.palette.buttonText)
- : Style.wizardDisabledText
- anchors.verticalCenter: parent.verticalCenter
- elide: Text.ElideRight
+ MultiEffect {
+ objectName: "wizardButtonLeadingIconTint"
+ anchors.fill: leadingIconImage
+ visible: root.tintIcon
+ source: leadingIconImage
+ colorization: 1.0
+ colorizationColor: root.iconTintColor
+ Accessible.ignored: true
}
+ }
- Image {
- visible: root.iconSource !== "" && !root.iconBeforeText
- source: root.iconSource
- sourceSize.width: Style.smallIconSize
- sourceSize.height: Style.smallIconSize
- width: visible ? Style.smallIconSize : 0
- height: Style.smallIconSize
- anchors.verticalCenter: parent.verticalCenter
- fillMode: Image.PreserveAspectFit
+ Text {
+ objectName: "wizardButtonText"
+ Layout.fillWidth: true
+ text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix
+ font: root.font
+ color: {
+ if (!root.enabled) {
+ return Style.wizardDisabledText
+ }
+ if (root.primary) {
+ return Style.wizardSelectedText
+ }
+ return root.palette.buttonText
}
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+
+ Image {
+ visible: root.iconSource !== "" && !root.iconBeforeText
+ source: root.iconSource !== "" && !root.iconBeforeText ? root.iconSource : ""
+ sourceSize.width: Style.smallIconSize
+ sourceSize.height: Style.smallIconSize
+ Layout.preferredWidth: visible ? Style.smallIconSize : 0
+ Layout.preferredHeight: Style.smallIconSize
+ fillMode: Image.PreserveAspectFit
+ Accessible.ignored: true
+ }
+
+ Image {
+ id: trailingIcon
+
+ objectName: "wizardButtonTrailingIcon"
+ visible: root.trailingIconSource !== ""
+ source: root.trailingIconSource
+ sourceSize.width: Style.smallIconSize
+ sourceSize.height: Style.smallIconSize
+ Layout.preferredWidth: visible ? Style.smallIconSize : 0
+ Layout.preferredHeight: Style.smallIconSize
+ fillMode: Image.PreserveAspectFit
+ Accessible.ignored: true
}
}
background: Rectangle {
- radius: Style.mediumRoundedButtonRadius
- border.width: root.primary ? 0 : 1
+ radius: root.cornerRadius
+ border.width: root.primary ? 0 : Style.normalBorderWidth
border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor
color: {
if (!root.enabled) {
return root.disabledColor
}
if (root.primary) {
- return root.down ? root.primaryPressedColor : root.primaryColor
+ return root.down || hoverArea.containsMouse
+ ? root.primaryPressedColor
+ : root.primaryColor
}
- return root.down
+ return root.down || hoverArea.containsMouse
? root.secondaryPressedColor
: root.secondaryColor
}
}
MouseArea {
+ id: hoverArea
+
+ objectName: "wizardButtonHoverArea"
anchors.fill: parent
acceptedButtons: Qt.NoButton
enabled: root.enabled
diff --git a/src/gui/wizard/qml/WizardChipButton.qml b/src/gui/wizard/qml/WizardChipButton.qml
new file mode 100644
index 0000000000000..91ad50b30cbde
--- /dev/null
+++ b/src/gui/wizard/qml/WizardChipButton.qml
@@ -0,0 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import Style
+
+WizardButton {
+ implicitHeight: Style.wizardChipButtonHeight
+ cornerRadius: Style.veryRoundedButtonRadius
+}
diff --git a/src/gui/wizard/qml/WizardMenu.qml b/src/gui/wizard/qml/WizardMenu.qml
new file mode 100644
index 0000000000000..8e64daf3b6ccd
--- /dev/null
+++ b/src/gui/wizard/qml/WizardMenu.qml
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls.Basic as BasicControls
+
+import Style
+
+BasicControls.Menu {
+ id: root
+
+ required property Item anchorItem
+
+ function toggle() {
+ if (opened) {
+ close()
+ } else {
+ popup(anchorItem, 0, anchorItem.height + Style.smallSpacing)
+ }
+ }
+
+ width: anchorItem.width
+ padding: Style.extraSmallSpacing
+ closePolicy: BasicControls.Menu.CloseOnPressOutsideParent | BasicControls.Menu.CloseOnEscape
+
+ background: Rectangle {
+ radius: Style.mediumRoundedButtonRadius
+ color: Style.wizardFieldBackground
+ border.width: Style.normalBorderWidth
+ border.color: Style.wizardSecondaryButtonBorder
+ }
+}
diff --git a/src/gui/wizard/qml/WizardMenuItem.qml b/src/gui/wizard/qml/WizardMenuItem.qml
new file mode 100644
index 0000000000000..4ac379ccbdf3c
--- /dev/null
+++ b/src/gui/wizard/qml/WizardMenuItem.qml
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtQuick.Controls.Basic as BasicControls
+import QtQuick.Effects
+import QtQuick.Layouts
+
+import Style
+
+BasicControls.MenuItem {
+ id: root
+
+ property bool tintIcon: false
+ property color iconTintColor: Style.wizardPrimaryText
+
+ hoverEnabled: true
+ implicitHeight: Style.standardPrimaryButtonHeight
+ leftPadding: Style.wizardMenuItemHorizontalPadding
+ rightPadding: Style.wizardMenuItemHorizontalPadding
+ font.pixelSize: Style.pixelSize + Style.extraSmallSpacing
+
+ contentItem: RowLayout {
+ spacing: Style.smallSpacing
+
+ Item {
+ Layout.preferredWidth: visible ? Style.smallIconSize : 0
+ Layout.preferredHeight: Style.smallIconSize
+ visible: root.icon.source.toString() !== ""
+
+ Image {
+ id: menuIconImage
+
+ anchors.fill: parent
+ visible: !root.tintIcon
+ source: root.icon.source.toString() !== "" ? root.icon.source : ""
+ sourceSize.width: Style.smallIconSize
+ sourceSize.height: Style.smallIconSize
+ fillMode: Image.PreserveAspectFit
+ Accessible.ignored: true
+ }
+
+ MultiEffect {
+ objectName: "wizardMenuItemIconTint"
+ anchors.fill: menuIconImage
+ visible: root.tintIcon
+ source: menuIconImage
+ colorization: 1.0
+ colorizationColor: root.iconTintColor
+ Accessible.ignored: true
+ }
+
+ Accessible.ignored: true
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: root.text
+ font: root.font
+ color: root.enabled ? Style.wizardPrimaryText : Style.wizardDisabledText
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+
+ background: Rectangle {
+ color: root.hovered || root.highlighted || root.down
+ ? Style.wizardSecondaryButtonPressed
+ : "transparent"
+ radius: Style.mediumRoundedButtonRadius
+ }
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+}
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index f30eba02090e3..4a8f520031237 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -10,6 +10,7 @@ set(CMAKE_AUTOMOC TRUE)
find_package(Qt${QT_VERSION_MAJOR}Core5Compat ${REQUIRED_QT_VERSION} CONFIG QUIET)
find_package(Qt${QT_VERSION_MAJOR} OPTIONAL_COMPONENTS HttpServer CONFIG QUIET)
+find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS QuickTest)
add_library(testutils
STATIC
@@ -112,6 +113,7 @@ if(NOT APPLE)
nextcloud_add_test(SystraySyncControlQt)
endif()
nextcloud_add_test(UnifiedSearchListmodel)
+nextcloud_add_test(UnifiedSearchPeopleModel)
nextcloud_add_test(ActivityListModel)
nextcloud_add_test(SortedActivityListModel)
nextcloud_add_test(ActivityData)
@@ -119,6 +121,20 @@ add_test(NAME ActivityFileMenuQmlTest
COMMAND Qt6::qmltestrunner
-input "${CMAKE_CURRENT_SOURCE_DIR}/qml/activityfilemenu/testactivityfilemenu.qml"
)
+add_executable(SearchQmlTest qml/search/searchqmltestrunner.cpp)
+target_link_libraries(SearchQmlTest PRIVATE nextcloudCore Qt::QuickTest)
+target_compile_definitions(SearchQmlTest PRIVATE
+ SEARCH_QML_TEST_IMPORT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/qml/search/imports"
+)
+set_target_properties(SearchQmlTest PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY ${BIN_OUTPUT_DIRECTORY}
+ FOLDER Tests
+)
+add_test(NAME SearchQmlTest
+ COMMAND SearchQmlTest -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/search/testsearch.qml"
+ WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
+)
+set_tests_properties(SearchQmlTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
nextcloud_add_test(TalkReply)
nextcloud_add_test(LockFile)
nextcloud_add_test(ShareModel)
diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml
new file mode 100644
index 0000000000000..727397b64a081
--- /dev/null
+++ b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+pragma Singleton
+
+import QtQuick
+
+QtObject {
+ readonly property bool darkMode: false
+ readonly property color wizardHeaderBackgroundColor: "#0082c9"
+ readonly property color wizardHeaderTitleColor: "#ffffff"
+ readonly property string stateOfflineImageSource: ""
+ readonly property string stateOnlineImageSource: ""
+}
diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml
new file mode 100644
index 0000000000000..751f9c58afb79
--- /dev/null
+++ b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+pragma Singleton
+
+import QtQml
+
+QtObject {
+ readonly property var currentUser: null
+}
diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/qmldir b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir
new file mode 100644
index 0000000000000..629543ae97754
--- /dev/null
+++ b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir
@@ -0,0 +1,6 @@
+# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+module com.nextcloud.desktopclient
+singleton Theme 1.0 Theme.qml
+singleton UserModel 1.0 UserModel.qml
diff --git a/test/qml/search/searchqmltestrunner.cpp b/test/qml/search/searchqmltestrunner.cpp
new file mode 100644
index 0000000000000..ebfe99bfb42a5
--- /dev/null
+++ b/test/qml/search/searchqmltestrunner.cpp
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include
+#include
+#include
+
+#include "gui/search/unifiedsearchpeoplemodel.h"
+#include "gui/search/unifiedsearchresultslistmodel.h"
+
+class SearchQmlTestSetup : public QObject
+{
+ Q_OBJECT
+
+public:
+ SearchQmlTestSetup()
+ {
+ Q_INIT_RESOURCE(resources);
+ Q_INIT_RESOURCE(theme);
+
+ qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel");
+ qmlRegisterUncreatableType("com.nextcloud.desktopclient",
+ 1,
+ 0,
+ "UnifiedSearchResultsListModel",
+ "UnifiedSearchResultsListModel");
+ }
+
+public slots:
+ void qmlEngineAvailable(QQmlEngine *engine)
+ {
+ engine->addImportPath(QStringLiteral(SEARCH_QML_TEST_IMPORT_PATH));
+ engine->addImportPath(QCoreApplication::applicationDirPath());
+ engine->addImportPath(QCoreApplication::applicationDirPath() + QStringLiteral("/qml"));
+ engine->addImportPath(QStringLiteral("qrc:/qml/theme"));
+ }
+};
+
+QUICK_TEST_MAIN_WITH_SETUP(search, SearchQmlTestSetup)
+
+#include "searchqmltestrunner.moc"
diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml
new file mode 100644
index 0000000000000..e38795f4564b7
--- /dev/null
+++ b/test/qml/search/testsearch.qml
@@ -0,0 +1,826 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import QtQuick
+import QtTest
+import com.nextcloud.desktopclient
+import com.nextcloud.desktopclient.search
+import Style
+import "qrc:/qml/src/gui/wizard/qml"
+
+Item {
+ width: 640
+ height: 220
+
+ Component {
+ id: productionSearchWindow
+
+ SearchWindow {}
+ }
+
+ Component {
+ id: windowSearchModel
+
+ ListModel {
+ property int viewMode: UnifiedSearchResultsListModel.Aggregate
+ property int searchState: UnifiedSearchResultsListModel.Results
+ property int selectedRow: 0
+ property string searchTerm: "calendar"
+ property string accessibilityStatus: ""
+ property string detailProviderName: "Files"
+ property string errorString: ""
+ property var accountState: null
+ property var activeFilters: []
+ property var providers: []
+ property bool providersReady: true
+ property bool canEditSearch: true
+ property bool waitingForSearchTermEditEnd: false
+ property bool isSearchInProgress: false
+ property bool isAccountConnected: true
+ property bool dateFilterAvailable: false
+ property bool peopleFilterAvailable: false
+ property bool hasPartialFailure: false
+ property bool showConnectedServicesAction: false
+ property bool externalProvidersEnabled: false
+ property string currentFetchMoreInProgressProviderId: ""
+ readonly property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId.length > 0
+
+ function moveSelection(direction) {}
+ function activateSelected() {}
+ function closeProviderDetail() {}
+ function retry() {}
+ function retryFailedProviders() {}
+ function setExternalProvidersEnabled(enabled) {}
+ function removeFilter(type, id) {}
+
+ Component.onCompleted: {
+ const row = {
+ providerName: "Files",
+ providerId: "files",
+ providerIcon: "",
+ resultTitle: "Calendar",
+ subline: "Documents",
+ resourceUrlRole: "https://cloud.example.test/f/1",
+ darkIcons: "",
+ lightIcons: "",
+ darkIconsIsThumbnail: false,
+ lightIconsIsThumbnail: false,
+ darkImagePlaceholder: "",
+ lightImagePlaceholder: "",
+ isRounded: false,
+ type: UnifiedSearchResultsListModel.Default,
+ isSelected: true,
+ isPartialMatch: false,
+ hasOverflow: false,
+ isLoading: false
+ }
+ append(row)
+ row.resultTitle = "Calendar 2"
+ row.resourceUrlRole = "https://cloud.example.test/f/2"
+ row.isSelected = false
+ append(row)
+ }
+ }
+ }
+
+ QtObject {
+ id: fakeSearchModel
+
+ property int openedProviderDetails: 0
+ property int activatedResults: 0
+ property int loadedPages: 0
+ property int retriedPages: 0
+
+ function openProviderDetail(providerId) {
+ if (providerId === "files") {
+ ++openedProviderDetails
+ }
+ }
+ function resultClicked(providerId, resourceUrl) {
+ if (providerId === "files" && resourceUrl.toString().length > 0) {
+ ++activatedResults
+ }
+ }
+ function retryLoadMore(providerId) {
+ if (providerId === "files") {
+ ++retriedPages
+ }
+ }
+ function loadMore(providerId) {
+ if (providerId === "files") {
+ ++loadedPages
+ }
+ }
+ }
+
+ UnifiedSearchInputContainer {
+ id: input
+ width: parent.width
+ height: 44
+ }
+
+ UnifiedSearchResultDelegate {
+ id: resultDelegate
+
+ y: 60
+ width: 500
+ searchModel: fakeSearchModel
+ providerName: "Files"
+ providerId: "files"
+ providerIcon: ""
+ resultTitle: "README.md"
+ subline: "Documents"
+ resourceUrlRole: "https://cloud.example.test/f/1"
+ darkIcons: ""
+ lightIcons: ""
+ darkIconsIsThumbnail: false
+ lightIconsIsThumbnail: false
+ darkImagePlaceholder: ""
+ lightImagePlaceholder: ""
+ isRounded: false
+ resultType: UnifiedSearchResultsListModel.ProviderHeader
+ isSelected: false
+ isPartialMatch: false
+ hasOverflow: true
+ isLoading: false
+ }
+
+ WizardButton {
+ id: wizardHoverButton
+
+ x: 500
+ y: 170
+ width: 120
+ text: qsTr("Hover")
+ }
+
+ WizardMenuItem {
+ id: wizardMenuHoverItem
+
+ x: 350
+ y: 170
+ width: 120
+ text: qsTr("Menu hover")
+ icon.source: "qrc:/client/theme/black/folder.svg"
+ tintIcon: true
+ iconTintColor: Style.wizardPrimaryText
+ }
+
+ SignalSpy { id: clearSpy; target: input; signalName: "clearText" }
+ SignalSpy { id: moveSpy; target: input; signalName: "moveSelection" }
+ SignalSpy { id: activateSpy; target: input; signalName: "activateSelection" }
+
+ TestCase {
+ name: "SearchQmlModule"
+ when: windowShown
+
+ function init() {
+ resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader
+ resultDelegate.isSelected = false
+ resultDelegate.isLoading = false
+ fakeSearchModel.loadedPages = 0
+ fakeSearchModel.retriedPages = 0
+ }
+
+ function test_searchWindowLoadsFromPackagedModule() {
+ const searchWindow = productionSearchWindow.createObject(null)
+ verify(searchWindow !== null)
+ compare(searchWindow.height, Style.searchWindowHeight)
+ verify(searchWindow.height >= 620 + Style.unifiedSearchItemHeight)
+ searchWindow.destroy()
+ }
+
+ function test_nothingFoundContentIsGroupedInTheCenter() {
+ const model = windowSearchModel.createObject(this, {
+ searchState: UnifiedSearchResultsListModel.NothingFound,
+ searchTerm: "Nextcloud"
+ })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const nothingFoundView = findChild(searchWindow, "nothingFoundView")
+ const content = findChild(searchWindow, "nothingFoundContent")
+ const icon = findChild(searchWindow, "nothingFoundIcon")
+ const message = findChild(searchWindow, "nothingFoundMessage")
+ const query = findChild(searchWindow, "nothingFoundQuery")
+
+ verify(nothingFoundView !== null)
+ verify(content !== null)
+ verify(icon !== null)
+ verify(message !== null)
+ verify(query !== null)
+ tryVerify(() => nothingFoundView.height > 0 && content.height > 0)
+ compare(query.text, "Nextcloud")
+ verify(content.height < nothingFoundView.height / 2)
+
+ const contentCenter = content.mapToItem(nothingFoundView,
+ content.width / 2,
+ content.height / 2)
+ fuzzyCompare(contentCenter.x, nothingFoundView.width / 2, 1)
+ fuzzyCompare(contentCenter.y, nothingFoundView.height / 2, 1)
+
+ const iconBottom = icon.mapToItem(content, 0, icon.height).y
+ const messageTop = message.mapToItem(content, 0, 0).y
+ const messageBottom = message.mapToItem(content, 0, message.height).y
+ const queryTop = query.mapToItem(content, 0, 0).y
+ fuzzyCompare(messageTop - iconBottom, Style.standardSpacing, 1)
+ fuzzyCompare(queryTop - messageBottom, Style.standardSpacing, 1)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_selectionBindingSurvivesViewAndModelChanges() {
+ const firstModel = windowSearchModel.createObject(this, { selectedRow: 0 })
+ const secondModel = windowSearchModel.createObject(this, { selectedRow: 1 })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: firstModel,
+ visible: true
+ })
+ const resultsList = findChild(searchWindow, "searchResultsList")
+
+ verify(resultsList !== null)
+ tryCompare(resultsList, "count", 2)
+ tryVerify(() => resultsList.itemAtIndex(0) !== null)
+ verify(resultsList.itemAtIndex(0).loadedItem !== null)
+ compare(resultsList.itemAtIndex(0).loadedItem.objectName, "searchResultRow")
+ compare(resultsList.currentIndex, 0)
+
+ firstModel.viewMode = UnifiedSearchResultsListModel.ProviderDetail
+ wait(0)
+ searchWindow.searchModel = secondModel
+ tryCompare(resultsList, "currentIndex", 1)
+
+ searchWindow.destroy()
+ firstModel.destroy()
+ secondModel.destroy()
+ }
+
+ function test_partialFailureFooterIsAggregateOnly() {
+ const model = windowSearchModel.createObject(this, { hasPartialFailure: true })
+ const searchWindow = productionSearchWindow.createObject(null, { searchModel: model })
+ const footer = findChild(searchWindow, "partialFailureFooter")
+
+ verify(footer !== null)
+ compare(footer.visible, true)
+ model.viewMode = UnifiedSearchResultsListModel.ProviderDetail
+ tryCompare(footer, "visible", false)
+ model.viewMode = UnifiedSearchResultsListModel.Aggregate
+ tryCompare(footer, "visible", true)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_hiddenLoadingFooterDoesNotReserveSpace() {
+ const model = windowSearchModel.createObject(this)
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const footer = findChild(searchWindow, "searchResultsLoadingFooter")
+
+ verify(footer !== null)
+ compare(footer.visible, false)
+ compare(footer.height, 0)
+ model.isSearchInProgress = true
+ tryVerify(() => footer.height > 0)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_fetchMoreShowsSearchProgressIndicator() {
+ const model = windowSearchModel.createObject(this)
+ const searchWindow = productionSearchWindow.createObject(null, { searchModel: model })
+ const progressIndicator = findChild(searchWindow, "searchProgressIndicator")
+
+ verify(progressIndicator !== null)
+ compare(progressIndicator.visible, false)
+ model.currentFetchMoreInProgressProviderId = "files"
+ tryCompare(progressIndicator, "visible", true)
+ compare(progressIndicator.running, true)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_detailHeaderCentersProviderAndShowsBackArrow() {
+ const model = windowSearchModel.createObject(this, {
+ viewMode: UnifiedSearchResultsListModel.ProviderDetail,
+ detailProviderName: "A provider name that is intentionally much too long for the available search header"
+ })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const header = findChild(searchWindow, "searchDetailHeader")
+ const title = findChild(searchWindow, "searchDetailProviderTitle")
+ const backButton = findChild(searchWindow, "searchDetailBackButton")
+
+ verify(header !== null)
+ verify(title !== null)
+ verify(backButton !== null)
+ compare(title.font.bold, true)
+ verify(title.font.pixelSize > Style.unifiedSearchResultTitleFontSize)
+ const titleCenter = title.mapToItem(header, title.width / 2, title.height / 2)
+ fuzzyCompare(titleCenter.x, header.width / 2, 1)
+ const backButtonRight = backButton.mapToItem(header, backButton.width, 0).x
+ const titleLeft = title.mapToItem(header, 0, 0).x
+ verify(titleLeft > backButtonRight)
+ verify(title.implicitWidth > title.width)
+ verify(backButton.icon.source.toString().includes("arrow-left.svg"))
+ compare(backButton.icon.width, Style.smallIconSize)
+ compare(backButton.icon.height, Style.smallIconSize)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_filterButtonsUseWizardHoverAndTrailingCarets() {
+ const model = windowSearchModel.createObject(this)
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const typeButton = findChild(searchWindow, "typeFilterButton")
+ const dateButton = findChild(searchWindow, "dateFilterButton")
+ const peopleButton = findChild(searchWindow, "peopleFilterButton")
+
+ verify(typeButton !== null)
+ verify(dateButton !== null)
+ verify(peopleButton !== null)
+ const todayMenuItem = findChild(searchWindow, "dateTodayMenuItem")
+ verify(todayMenuItem !== null)
+ compare(todayMenuItem.hoverEnabled, true)
+ for (const button of [typeButton, dateButton, peopleButton]) {
+ verify(button.trailingIconSource.toString().includes("caret-down.svg"))
+ const caret = findChild(button, "wizardButtonTrailingIcon")
+ verify(caret !== null)
+ const caretPosition = caret.mapToItem(button, 0, 0)
+ verify(caretPosition.x > button.width / 2)
+ fuzzyCompare(caretPosition.y + caret.height / 2, button.height / 2, 1)
+ }
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_filterMenusUseStandardDropdownGeometry() {
+ const model = windowSearchModel.createObject(this, { dateFilterAvailable: true })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const typeButton = findChild(searchWindow, "typeFilterButton")
+ const typeMenu = findChild(searchWindow, "typeFilterMenu")
+ const dateButton = findChild(searchWindow, "dateFilterButton")
+ const dateMenu = findChild(searchWindow, "dateFilterMenu")
+
+ verify(typeButton !== null)
+ verify(typeMenu !== null)
+ verify(dateButton !== null)
+ verify(dateMenu !== null)
+ compare(dateMenu.width, dateButton.width)
+ verify(dateMenu.width > Style.standardPrimaryButtonHeight)
+ compare(typeMenu.background.radius, Style.mediumRoundedButtonRadius)
+ compare(dateMenu.background.radius, Style.mediumRoundedButtonRadius)
+ compare(typeMenu.background.border.width, Style.normalBorderWidth)
+ compare(dateMenu.background.border.width, Style.normalBorderWidth)
+
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", true)
+ compare(typeMenu.y, typeButton.height + Style.smallSpacing)
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", false)
+ mouseClick(dateButton)
+ tryCompare(dateMenu, "opened", true)
+ compare(dateMenu.y, dateButton.height + Style.smallSpacing)
+ verify(dateMenu.height > Style.standardPrimaryButtonHeight)
+ mouseClick(dateButton)
+ tryCompare(dateMenu, "opened", false)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function providerEntries(count) {
+ const providers = []
+ for (let index = 0; index < count; ++index) {
+ providers.push({
+ id: "provider-" + index,
+ name: "Provider " + index,
+ icon: "",
+ selected: false
+ })
+ }
+ return providers
+ }
+
+ function test_typeFilterMenuShowsEightRowsWithoutScrolling() {
+ const model = windowSearchModel.createObject(this, {
+ providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows)
+ })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const typeButton = findChild(searchWindow, "typeFilterButton")
+ const typeMenu = findChild(searchWindow, "typeFilterMenu")
+
+ verify(typeButton !== null)
+ verify(typeMenu !== null)
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", true)
+ compare(typeMenu.height, typeMenu.implicitHeight)
+ compare(typeMenu.contentItem.interactive, false)
+
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", false)
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_typeFilterMenuScrollsBeyondEightRows() {
+ const model = windowSearchModel.createObject(this, {
+ providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows + 1)
+ })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const typeButton = findChild(searchWindow, "typeFilterButton")
+ const typeMenu = findChild(searchWindow, "typeFilterMenu")
+ const maximumHeight = Style.unifiedSearchProviderMenuMaximumVisibleRows
+ * Style.standardPrimaryButtonHeight + typeMenu.topPadding + typeMenu.bottomPadding
+
+ verify(typeButton !== null)
+ verify(typeMenu !== null)
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", true)
+ compare(typeMenu.height, maximumHeight)
+ verify(typeMenu.implicitHeight > typeMenu.height)
+ verify(typeMenu.contentItem.contentHeight > typeMenu.contentItem.height)
+ compare(typeMenu.contentItem.interactive, true)
+
+ mouseClick(typeButton)
+ tryCompare(typeMenu, "opened", false)
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_categoryFiltersAreVisibleBeforeSearchBegins() {
+ const model = windowSearchModel.createObject(this, { searchTerm: "" })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const filterFlow = findChild(searchWindow, "categoryFilterFlow")
+ const typeButton = findChild(searchWindow, "typeFilterButton")
+ const dateButton = findChild(searchWindow, "dateFilterButton")
+ const peopleButton = findChild(searchWindow, "peopleFilterButton")
+
+ verify(filterFlow !== null)
+ compare(filterFlow.visible, true)
+ compare(typeButton.visible, true)
+ compare(dateButton.visible, true)
+ compare(peopleButton.visible, true)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ function test_activeFilterUsesCompactPillButton() {
+ const model = windowSearchModel.createObject(this, {
+ activeFilters: [{
+ type: "date",
+ id: "last7days",
+ label: "Last 7 days",
+ icon: ""
+ }]
+ })
+ const searchWindow = productionSearchWindow.createObject(null, {
+ searchModel: model,
+ visible: true
+ })
+ const activeFilterFlow = findChild(searchWindow, "activeFilterFlow")
+ const dateButton = findChild(searchWindow, "dateFilterButton")
+ function activeFilterChip() {
+ for (let index = 0; index < activeFilterFlow.children.length; ++index) {
+ if (activeFilterFlow.children[index].objectName === "activeFilterChip") {
+ return activeFilterFlow.children[index]
+ }
+ }
+ return null
+ }
+
+ verify(activeFilterFlow !== null)
+ tryVerify(() => activeFilterChip() !== null)
+ const chip = activeFilterChip()
+ verify(dateButton !== null)
+ compare(chip.implicitHeight, Style.wizardChipButtonHeight)
+ verify(chip.implicitHeight < dateButton.implicitHeight)
+ compare(chip.background.radius, Style.veryRoundedButtonRadius)
+ compare(chip.leftPadding, dateButton.leftPadding)
+ compare(chip.rightPadding, chip.leftPadding)
+
+ searchWindow.destroy()
+ model.destroy()
+ }
+
+ }
+
+ TestCase {
+ name: "UnifiedSearchInput"
+ when: windowShown
+
+ function init() {
+ input.text = ""
+ input.isSearchInProgress = false
+ input.forceActiveFocus()
+ clearSpy.clear()
+ moveSpy.clear()
+ activateSpy.clear()
+ }
+
+ function test_keyboardNavigationKeepsInputFocus() {
+ keyClick(Qt.Key_Down)
+ keyClick(Qt.Key_End)
+ keyClick(Qt.Key_Return)
+ compare(moveSpy.count, 2)
+ compare(activateSpy.count, 1)
+ verify(input.activeFocus)
+ }
+
+ function test_activeFocusKeepsNeutralFrame() {
+ verify(input.activeFocus)
+ compare(input.background.border.width, 1)
+ compare(input.background.border.color, Style.wizardFieldBorder)
+ }
+
+ function test_progressIndicatorDoesNotReplaceSearchIcon() {
+ const searchIcon = findChild(input, "searchLeadingIcon")
+ const progressIndicator = findChild(input, "searchProgressIndicator")
+ const clearButton = findChild(input, "clearSearchButton")
+
+ verify(searchIcon !== null)
+ verify(progressIndicator !== null)
+ verify(clearButton !== null)
+ verify(progressIndicator.imageSource.includes("change.svg"))
+ input.text = "loading"
+ compare(searchIcon.visible, true)
+ compare(progressIndicator.visible, false)
+
+ input.isSearchInProgress = true
+ tryCompare(progressIndicator, "visible", true)
+ compare(progressIndicator.running, true)
+ compare(searchIcon.visible, true)
+ const searchIconPosition = searchIcon.mapToItem(input, 0, 0)
+ const progressPosition = progressIndicator.mapToItem(input, 0, 0)
+ const clearButtonPosition = clearButton.mapToItem(input, 0, 0)
+ verify(progressPosition.x > searchIconPosition.x)
+ verify(progressPosition.x + progressIndicator.width <= clearButtonPosition.x)
+ verify(input.width - input.rightPadding < progressPosition.x)
+
+ input.isSearchInProgress = false
+ tryCompare(progressIndicator, "visible", false)
+ compare(progressIndicator.running, false)
+ compare(searchIcon.visible, true)
+ }
+
+ function test_textEditingSignalIsIndependentFromClear() {
+ keyClick(Qt.Key_C)
+ keyClick(Qt.Key_A)
+ keyClick(Qt.Key_L)
+ keyClick(Qt.Key_E)
+ keyClick(Qt.Key_N)
+ keyClick(Qt.Key_D)
+ keyClick(Qt.Key_A)
+ keyClick(Qt.Key_R)
+ compare(input.text, "calendar")
+ compare(clearSpy.count, 0)
+ }
+
+ function test_clearActionReplacesFilterAction() {
+ const clearButton = findChild(input, "clearSearchButton")
+
+ verify(clearButton !== null)
+ compare(clearButton.visible, false)
+ keyClick(Qt.Key_C)
+ compare(clearButton.visible, true)
+ verify(clearButton.icon.source.toString().includes("clear.svg"))
+ mouseClick(clearButton)
+ compare(clearSpy.count, 1)
+ }
+ }
+
+ TestCase {
+ name: "WizardButton"
+ when: windowShown
+
+ function test_hoverUsesSharedWizardSurface() {
+ mouseMove(input, 1, 1)
+ wait(0)
+ const restingColor = wizardHoverButton.background.color.toString()
+ const hoverArea = findChild(wizardHoverButton, "wizardButtonHoverArea")
+
+ verify(hoverArea !== null)
+ mouseMove(wizardHoverButton, 2, 2)
+ tryCompare(hoverArea, "containsMouse", true)
+ verify(wizardHoverButton.background.color.toString() !== restingColor)
+ }
+
+ function test_tintedLeadingIconUsesRequestedColor() {
+ wizardHoverButton.iconBeforeText = true
+ wizardHoverButton.tintIcon = true
+ wizardHoverButton.iconTintColor = Style.wizardPrimaryText
+ wizardHoverButton.iconSource = "qrc:/client/theme/black/folder.svg"
+ wait(0)
+
+ const iconTint = findChild(wizardHoverButton, "wizardButtonLeadingIconTint")
+ verify(iconTint !== null)
+ compare(iconTint.visible, true)
+ compare(iconTint.colorization, 1.0)
+ compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString())
+ }
+
+ function test_trailingIconCannotOverlapLongText() {
+ wizardHoverButton.iconBeforeText = false
+ wizardHoverButton.iconSource = ""
+ wizardHoverButton.trailingIconSource = "qrc:/client/theme/black/caret-down.svg"
+ wizardHoverButton.text = "An intentionally long translated button label"
+ wait(0)
+
+ const textItem = findChild(wizardHoverButton, "wizardButtonText")
+ const trailingIcon = findChild(wizardHoverButton, "wizardButtonTrailingIcon")
+ verify(textItem !== null)
+ verify(trailingIcon !== null)
+ tryVerify(() => {
+ const textRight = textItem.mapToItem(wizardHoverButton, textItem.width, 0).x
+ const iconLeft = trailingIcon.mapToItem(wizardHoverButton, 0, 0).x
+ return textRight < iconLeft
+ })
+ }
+ }
+
+ TestCase {
+ name: "WizardMenuItem"
+ when: windowShown
+
+ function test_hoverUsesSharedWizardMenuSurface() {
+ mouseMove(input, 1, 1)
+ wait(0)
+ const restingColor = wizardMenuHoverItem.background.color.toString()
+
+ mouseMove(wizardMenuHoverItem, 2, 2)
+ tryCompare(wizardMenuHoverItem, "hovered", true)
+ verify(wizardMenuHoverItem.background.color.toString() !== restingColor)
+ }
+
+ function test_tintedIconUsesRequestedColor() {
+ const iconTint = findChild(wizardMenuHoverItem, "wizardMenuItemIconTint")
+ verify(iconTint !== null)
+ compare(iconTint.visible, true)
+ compare(iconTint.colorization, 1.0)
+ compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString())
+ }
+ }
+
+ TestCase {
+ name: "UnifiedSearchResultDelegate"
+ when: windowShown
+
+ function init() {
+ resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader
+ resultDelegate.hasOverflow = true
+ resultDelegate.isSelected = false
+ fakeSearchModel.openedProviderDetails = 0
+ fakeSearchModel.activatedResults = 0
+ fakeSearchModel.loadedPages = 0
+ fakeSearchModel.retriedPages = 0
+ wait(0)
+ }
+
+ function test_providerHeaderReceivesDelegateDataAndGeometry() {
+ verify(resultDelegate.loadedItem !== null)
+ compare(resultDelegate.loadedItem.objectName, "providerHeaderRow")
+ compare(resultDelegate.loadedItem.width, resultDelegate.width)
+ compare(resultDelegate.height, 44)
+ compare(resultDelegate.loadedItem.text, "More from Files →")
+ compare(resultDelegate.loadedItem.font.bold, false)
+ compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize)
+ compare(resultDelegate.loadedItem.leftPadding, 0)
+ compare(resultDelegate.loadedItem.hoverEnabled, true)
+ compare(findChild(resultDelegate.loadedItem, "providerHeaderIconTint"), null)
+
+ mouseMove(input, 1, 1)
+ mouseMove(input, 2, 2)
+ tryCompare(resultDelegate.loadedItem, "hovered", false)
+ compare(resultDelegate.loadedItem.background.color.toString(), "#00000000")
+ mouseMove(resultDelegate.loadedItem, 1, 1)
+ mouseMove(resultDelegate.loadedItem, 2, 2)
+ tryCompare(resultDelegate.loadedItem, "hovered", true)
+ compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString())
+
+ mousePress(resultDelegate.loadedItem)
+ compare(fakeSearchModel.openedProviderDetails, 1)
+ mouseRelease(resultDelegate.loadedItem)
+ compare(fakeSearchModel.openedProviderDetails, 1)
+ }
+
+ function test_plainProviderHeaderStaysRegularAndNonInteractive() {
+ resultDelegate.hasOverflow = false
+ wait(0)
+
+ compare(resultDelegate.loadedItem.text, "Files")
+ compare(resultDelegate.loadedItem.font.bold, false)
+ compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize)
+ compare(resultDelegate.loadedItem.leftPadding, 0)
+ compare(resultDelegate.loadedItem.hoverEnabled, false)
+ }
+
+ function test_resultRowReceivesDelegateDataAndGeometry() {
+ resultDelegate.resultType = UnifiedSearchResultsListModel.Default
+ wait(0)
+
+ verify(resultDelegate.loadedItem !== null)
+ compare(resultDelegate.loadedItem.objectName, "searchResultRow")
+ compare(resultDelegate.loadedItem.width, resultDelegate.width)
+ compare(resultDelegate.height, Style.unifiedSearchItemHeight)
+ verify(resultDelegate.height <= 44)
+ compare(resultDelegate.loadedItem.leftPadding, 0)
+ compare(resultDelegate.loadedItem.rightPadding, 0)
+ compare(resultDelegate.loadedItem.topPadding, 0)
+ compare(resultDelegate.loadedItem.bottomPadding, 0)
+ compare(resultDelegate.loadedItem.contentItem.height, resultDelegate.loadedItem.height)
+ compare(resultDelegate.loadedItem.background.height, resultDelegate.loadedItem.height)
+ compare(resultDelegate.loadedItem.contentItem.Accessible.ignored, true)
+
+ const title = findChild(resultDelegate.loadedItem, "searchResultTitle")
+ const subline = findChild(resultDelegate.loadedItem, "searchResultSubline")
+ const textContainer = findChild(resultDelegate.loadedItem, "searchResultTextContainer")
+ verify(title !== null)
+ verify(subline !== null)
+ verify(textContainer !== null)
+ compare(title.font.pixelSize, Style.unifiedSearchResultTitleFontSize)
+ compare(subline.font.pixelSize, title.font.pixelSize)
+ compare(textContainer.spacing, Style.unifiedSearchResultTextSpacing)
+
+ resultDelegate.isSelected = true
+ tryCompare(resultDelegate.loadedItem.background, "color", Style.wizardSecondaryButtonPressed)
+ resultDelegate.isSelected = false
+ mouseMove(resultDelegate.loadedItem, 2, 2)
+ tryCompare(resultDelegate.loadedItem, "hovered", true)
+ compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString())
+
+ mouseClick(resultDelegate.loadedItem)
+ compare(fakeSearchModel.activatedResults, 1)
+ }
+
+ function test_loadMoreUsesAFlatItemRow() {
+ resultDelegate.resultType = UnifiedSearchResultsListModel.FetchMoreTrigger
+ wait(0)
+
+ verify(resultDelegate.loadedItem !== null)
+ compare(resultDelegate.loadedItem.objectName, "searchPagingRow")
+ compare(resultDelegate.loadedItem.text, qsTr("Load more results"))
+ const pagingLabel = findChild(resultDelegate.loadedItem, "searchPagingLabel")
+ verify(pagingLabel !== null)
+ compare(pagingLabel.font.bold, false)
+ verify(resultDelegate.loadedItem.icon.source.toString().includes("more.svg"))
+ verify(!resultDelegate.loadedItem.hasOwnProperty("primary"))
+ compare(resultDelegate.loadedItem.height, 44)
+ mouseMove(input, 1, 1)
+ wait(0)
+ compare(resultDelegate.loadedItem.background.color.toString(), "#00000000")
+
+ mouseMove(resultDelegate.loadedItem, 2, 2)
+ tryCompare(resultDelegate.loadedItem, "hovered", true)
+ compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString())
+
+ mousePress(resultDelegate.loadedItem)
+ compare(fakeSearchModel.loadedPages, 1)
+ mouseRelease(resultDelegate.loadedItem)
+ compare(fakeSearchModel.loadedPages, 1)
+ }
+
+ function test_retryPagingUsesAFlatItemRow() {
+ resultDelegate.resultType = UnifiedSearchResultsListModel.RetryFetchMoreTrigger
+ wait(0)
+
+ verify(resultDelegate.loadedItem !== null)
+ compare(resultDelegate.loadedItem.objectName, "searchPagingRow")
+ compare(resultDelegate.loadedItem.text, qsTr("Retry loading more results"))
+ verify(!resultDelegate.loadedItem.hasOwnProperty("primary"))
+ compare(resultDelegate.loadedItem.height, 44)
+
+ mousePress(resultDelegate.loadedItem)
+ compare(fakeSearchModel.retriedPages, 1)
+ mouseRelease(resultDelegate.loadedItem)
+ compare(fakeSearchModel.retriedPages, 1)
+ }
+ }
+}
diff --git a/test/testcustomstateicons.cpp b/test/testcustomstateicons.cpp
index 74aa22ff7866b..591eb77ecd2fd 100644
--- a/test/testcustomstateicons.cpp
+++ b/test/testcustomstateicons.cpp
@@ -41,7 +41,7 @@ class TestCustomStateIcons : public QObject
{
Q_OBJECT
-private slots:
+private Q_SLOTS:
void testSvgContrast_data()
{
QTest::addColumn("fileName");
diff --git a/test/testiconutils.cpp b/test/testiconutils.cpp
index 3098827dedff9..e5973383ae4e5 100644
--- a/test/testiconutils.cpp
+++ b/test/testiconutils.cpp
@@ -43,6 +43,13 @@ private Q_SLOTS:
QVERIFY(!OCC::Ui::IconUtils::drawSvgWithCustomFillColor(blackSvgDirPath + QStringLiteral("/") + blackImages.at(0), QColorConstants::Svg::green).isNull());
+ const auto imageFromZeroWidthRequest = OCC::Ui::IconUtils::drawSvgWithCustomFillColor(
+ blackSvgDirPath + QStringLiteral("/") + blackImages.at(0),
+ QColorConstants::Svg::green,
+ nullptr,
+ QSize(0, 24));
+ QVERIFY(!imageFromZeroWidthRequest.isNull());
+
const QString whiteSvgDirPath{QString{OCC::Theme::themePrefix} + QStringLiteral("white")};
const QDir whiteSvgDir(whiteSvgDirPath);
const QStringList whiteImages = whiteSvgDir.entryList(QStringList("*.svg"));
diff --git a/test/testunifiedsearchlistmodel.cpp b/test/testunifiedsearchlistmodel.cpp
index e20b19f9e7fca..e1bdcd912f73a 100644
--- a/test/testunifiedsearchlistmodel.cpp
+++ b/test/testunifiedsearchlistmodel.cpp
@@ -8,6 +8,7 @@
#include "account.h"
#include "accountstate.h"
#include "syncenginetestutils.h"
+#include "testhelper.h"
#include
#include
@@ -134,15 +135,25 @@ class FakeSearchResultsStorage
}
// initialize the JSON response containing the fake list of providers and their properties
- void initProvidersResponse()
+ void initProvidersResponse(const QString &excludedProviderId = {})
{
QList providersList;
for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) {
+ if (fakeProviderInitInfo._id == excludedProviderId) {
+ continue;
+ }
providersList.push_back(QVariantMap{
{QStringLiteral("id"), fakeProviderInitInfo._id},
+ {QStringLiteral("appId"), fakeProviderInitInfo._id},
{QStringLiteral("name"), fakeProviderInitInfo._name},
{QStringLiteral("order"), fakeProviderInitInfo._order},
+ {QStringLiteral("filters"), QVariantMap{
+ {QStringLiteral("term"), QVariantMap{}},
+ {QStringLiteral("since"), QVariantMap{}},
+ {QStringLiteral("until"), QVariantMap{}},
+ {QStringLiteral("person"), QVariantMap{}},
+ }},
});
}
@@ -242,6 +253,65 @@ class FakeSearchResultsStorage
.toJson(QJsonDocument::Compact);
}
+ if (searchTerm == QStringLiteral("[app-icons]")) {
+ auto entries = QVariantList{};
+ if (providerId == QStringLiteral("settings_apps")) {
+ const auto titles = QStringList{
+ QStringLiteral("Dashboard"),
+ QStringLiteral("Talk"),
+ QStringLiteral("Activity"),
+ QStringLiteral("Unknown app"),
+ };
+ for (const auto &title : titles) {
+ entries.push_back(QVariantMap{
+ {QStringLiteral("thumbnailUrl"), QString()},
+ {QStringLiteral("title"), title},
+ {QStringLiteral("subline"), QString()},
+ {QStringLiteral("resourceUrl"), QStringLiteral("http://example.de/index.php/apps/dashboard/")},
+ {QStringLiteral("icon"), QStringLiteral("icon-arrow-right")},
+ {QStringLiteral("rounded"), false},
+ });
+ }
+ }
+ const auto dataMap = QVariantMap{
+ {QStringLiteral("name"), _searchResultsData[providerId]._name},
+ {QStringLiteral("isPaginated"), false},
+ {QStringLiteral("cursor"), QVariant()},
+ {QStringLiteral("entries"), entries},
+ };
+ const auto ocsMap = QVariantMap{{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
+ return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact);
+ }
+
+ if (searchTerm == QStringLiteral("[small-page]")) {
+ auto entries = resultsForProvider(providerId, 0);
+ while (entries.size() > 2) {
+ entries.removeLast();
+ }
+ const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
+ {QStringLiteral("isPaginated"), true}, {QStringLiteral("cursor"), cursor + entries.size()},
+ {QStringLiteral("entries"), entries}};
+ const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
+ return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}})
+ .toJson(QJsonDocument::Compact);
+ }
+
+ if (searchTerm == QStringLiteral("[oversized-page]")) {
+ auto entries = QList();
+ const auto availableEntries = resultsForProvider(providerId, 0);
+ if (!availableEntries.isEmpty()) {
+ for (auto index = 0; index < 25; ++index) {
+ entries.push_back(availableEntries.constFirst());
+ }
+ }
+ const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
+ {QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), QVariant()},
+ {QStringLiteral("entries"), entries}};
+ const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
+ return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}})
+ .toJson(QJsonDocument::Compact);
+ }
+
const auto provider = _searchResultsData.value(providerId, Provider());
const auto nextCursor = cursor + pageSize;
@@ -283,9 +353,10 @@ class TestUnifiedSearchListmodel : public QObject
QScopedPointer fakeQnam;
OCC::AccountPtr account;
- QScopedPointer accountState;
+ QScopedPointer accountState;
QScopedPointer model;
QScopedPointer modelTester;
+ QList requestedUrls;
QScopedPointer fakeDesktopServicesUrlHandler;
@@ -304,10 +375,11 @@ private Q_SLOTS:
account->setCredentials(new FakeCredentials{fakeQnam.data()});
account->setUrl(QUrl(("http://example.de")));
- accountState.reset(new OCC::AccountState(account));
+ accountState.reset(new FakeAccountState(account));
fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) {
Q_UNUSED(device);
+ requestedUrls.push_back(req.url());
QNetworkReply *reply = nullptr;
const auto urlQuery = QUrlQuery(req.url());
@@ -370,15 +442,10 @@ private Q_SLOTS:
QCOMPARE(model->searchTerm(), QStringLiteral("discuss"));
QCOMPARE(searhTermChanged.count(), 1);
- // #3 test that model has not started search yet
QVERIFY(!model->isSearchInProgress());
-
- // #4 test that model has started the search after specific delay
QSignalSpy searchInProgressChanged(model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
- // allow search jobs to get created within the model
QVERIFY(searchInProgressChanged.wait());
- QCOMPARE(searchInProgressChanged.count(), 1);
QVERIFY(model->isSearchInProgress());
// #5 test that model has stopped the search after setting empty search term
@@ -395,21 +462,8 @@ private Q_SLOTS:
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss"));
- QSignalSpy searchInProgressChanged(
- model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has started
- QCOMPARE(searchInProgressChanged.count(), 1);
- QVERIFY(model->isSearchInProgress());
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has finished
- QVERIFY(!model->isSearchInProgress());
-
- QVERIFY(model->rowCount() > 0);
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
}
void testSetSearchTermResultsNotFound()
@@ -421,21 +475,9 @@ private Q_SLOTS:
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("[empty]"));
- QSignalSpy searchInProgressChanged(
- model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has started
- QCOMPARE(searchInProgressChanged.count(), 1);
- QVERIFY(model->isSearchInProgress());
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has finished
- QVERIFY(!model->isSearchInProgress());
-
- QVERIFY(model->rowCount() == 0);
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress(), 2000);
+ QCOMPARE(model->rowCount(), 0);
}
void testFetchMoreClicked()
@@ -460,12 +502,30 @@ private Q_SLOTS:
// make sure search has finished
QVERIFY(!model->isSearchInProgress());
+ QString providerIdFetchMoreTriggered;
+ for (auto row = 0; row < model->rowCount(); ++row) {
+ if (model->data(model->index(row), OCC::UnifiedSearchResultsListModel::TypeRole)
+ == OCC::UnifiedSearchResult::Type::ProviderHeader
+ && model->data(model->index(row), OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) {
+ providerIdFetchMoreTriggered = model->data(model->index(row), OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString();
+ break;
+ }
+ }
+ QVERIFY(!providerIdFetchMoreTriggered.isEmpty());
+ model->openProviderDetail(providerIdFetchMoreTriggered);
+ QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail);
const auto numRowsInModelPrev = model->rowCount();
+ QStringList stableKeysBeforePaging;
+ for (auto row = 0; row < numRowsInModelPrev - 1; ++row) {
+ stableKeysBeforePaging.push_back(model->data(model->index(row),
+ OCC::UnifiedSearchResultsListModel::StableKeyRole).toString());
+ }
+ QSignalSpy modelReset(model.data(), &QAbstractItemModel::modelReset);
+ QSignalSpy rowsInserted(model.data(), &QAbstractItemModel::rowsInserted);
+ QSignalSpy rowsRemoved(model.data(), &QAbstractItemModel::rowsRemoved);
- // test fetch more results
QSignalSpy currentFetchMoreInProgressProviderIdChanged(
model.data(), &OCC::UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged);
- QSignalSpy rowsInserted(model.data(), &OCC::UnifiedSearchResultsListModel::rowsInserted);
for (int i = 0; i < model->rowCount(); ++i) {
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
@@ -479,47 +539,24 @@ private Q_SLOTS:
}
// make sure the currentFetchMoreInProgressProviderId was set back and forth and correct number fows has been inserted
- QCOMPARE(currentFetchMoreInProgressProviderIdChanged.count(), 1);
-
- const auto providerIdFetchMoreTriggered = model->currentFetchMoreInProgressProviderId();
-
- QVERIFY(!providerIdFetchMoreTriggered.isEmpty());
-
- QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait());
-
- QVERIFY(model->currentFetchMoreInProgressProviderId().isEmpty());
-
- QCOMPARE(rowsInserted.count(), 1);
-
- const auto arguments = rowsInserted.takeFirst();
-
- QVERIFY(arguments.size() > 0);
-
- const auto first = arguments.at(0).toInt();
- const auto last = arguments.at(1).toInt();
-
- const int numInsertedExpected = last - first;
-
- QCOMPARE(model->rowCount() - numRowsInModelPrev, numInsertedExpected);
+ QVERIFY(currentFetchMoreInProgressProviderIdChanged.count() > 0);
+ QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000);
+ QVERIFY(model->rowCount() > numRowsInModelPrev);
+ QCOMPARE(modelReset.count(), 0);
+ QVERIFY(rowsInserted.count() > 0);
+ QCOMPARE(rowsRemoved.count(), 0);
+ for (auto row = 0; row < stableKeysBeforePaging.size(); ++row) {
+ QCOMPARE(model->data(model->index(row), OCC::UnifiedSearchResultsListModel::StableKeyRole).toString(),
+ stableKeysBeforePaging[row]);
+ }
// make sure the FetchMoreTrigger gets removed when no more results available
if (!providerIdFetchMoreTriggered.isEmpty()) {
currentFetchMoreInProgressProviderIdChanged.clear();
- rowsInserted.clear();
-
- QSignalSpy rowsRemoved(model.data(), &OCC::UnifiedSearchResultsListModel::rowsRemoved);
-
for (int i = 0; i < 10; ++i) {
model->fetchMoreTriggerClicked(providerIdFetchMoreTriggered);
-
- QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait());
-
- if (rowsRemoved.count() > 0) {
- break;
- }
+ QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000);
}
-
- QCOMPARE(rowsRemoved.count(), 1);
bool isFetchMoreTriggerFound = false;
@@ -538,6 +575,178 @@ private Q_SLOTS:
}
}
+ void testSmallPaginatedFirstPageCanOpenAndLoadMore()
+ {
+ model->setSearchTerm(QStringLiteral("[small-page]"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
+
+ auto providerId = QString();
+ for (auto row = 0; row < model->rowCount(); ++row) {
+ const auto index = model->index(row);
+ if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt()
+ == OCC::UnifiedSearchResult::Type::ProviderHeader
+ && model->data(index, OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) {
+ providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString();
+ break;
+ }
+ }
+ QVERIFY(!providerId.isEmpty());
+
+ model->openProviderDetail(providerId);
+ QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail);
+ const auto rowsBeforeLoading = model->rowCount();
+ QVERIFY(rowsBeforeLoading <= 3);
+ model->loadMore(providerId);
+ QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000);
+ QVERIFY(model->rowCount() > rowsBeforeLoading);
+ }
+
+ void testNonConformingProviderPageIsCapped()
+ {
+ model->setSearchTerm(QStringLiteral("[oversized-page]"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
+
+ auto providerId = QString();
+ for (auto row = 0; row < model->rowCount(); ++row) {
+ const auto index = model->index(row);
+ if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt()
+ == OCC::UnifiedSearchResult::Type::ProviderHeader) {
+ providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString();
+ break;
+ }
+ }
+ QVERIFY(!providerId.isEmpty());
+ model->openProviderDetail(providerId);
+ QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail);
+ QCOMPARE(model->rowCount(), 10);
+ }
+
+ void testAggregateProjectionAndKeyboardSelection()
+ {
+ model->setSearchTerm(QStringLiteral("projection"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
+
+ QHash resultsByProvider;
+ auto loadMoreFound = false;
+ for (auto row = 0; row < model->rowCount(); ++row) {
+ const auto index = model->index(row);
+ const auto type = model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt();
+ if (type == OCC::UnifiedSearchResult::Type::Default) {
+ ++resultsByProvider[model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString()];
+ } else if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) {
+ loadMoreFound = true;
+ }
+ }
+ for (const auto count : std::as_const(resultsByProvider)) QVERIFY(count <= 3);
+ QVERIFY(!loadMoreFound);
+ QVERIFY(model->selectedRow() >= 0);
+ const auto firstSelected = model->selectedRow();
+ model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next);
+ QVERIFY(model->selectedRow() >= firstSelected);
+ model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Last);
+ const auto lastSelected = model->selectedRow();
+ model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next);
+ QCOMPARE(model->selectedRow(), lastSelected);
+ }
+
+ void testAppsProviderUsesNavigationAppIcons()
+ {
+ const auto navigationApps = QVariantList{
+ QVariantMap{{QStringLiteral("name"), QStringLiteral("Dashboard")},
+ {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/dashboard/")},
+ {QStringLiteral("id"), QStringLiteral("dashboard")},
+ {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")}},
+ QVariantMap{{QStringLiteral("name"), QStringLiteral("Talk")},
+ {QStringLiteral("href"), QStringLiteral("http://example.de/call/")},
+ {QStringLiteral("id"), QStringLiteral("spreed")},
+ {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")}},
+ QVariantMap{{QStringLiteral("name"), QStringLiteral("Activity")},
+ {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/activity/")},
+ {QStringLiteral("id"), QStringLiteral("activity")},
+ {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/activity/img/app.svg")}},
+ };
+ const auto navigationResponse = QJsonDocument::fromVariant(QVariantMap{
+ {QStringLiteral("ocs"), QVariantMap{{QStringLiteral("data"), navigationApps}}},
+ });
+ QVERIFY(QMetaObject::invokeMethod(accountState.data(),
+ "slotNavigationAppsFetched",
+ Qt::DirectConnection,
+ Q_ARG(QJsonDocument, navigationResponse),
+ Q_ARG(int, 200)));
+
+ model->setSearchTerm(QString());
+ model->closeProviderDetail();
+ model->clearTypeFilters();
+ model->clearDateFilter();
+ model->clearPersonFilter();
+ model->setSearchTerm(QStringLiteral("[app-icons]"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
+
+ model->openProviderDetail(QStringLiteral("settings_apps"));
+ QCOMPARE(model->rowCount(), 4);
+
+ const auto expectedIconUrls = QHash{
+ {QStringLiteral("Dashboard"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")},
+ {QStringLiteral("Talk"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")},
+ {QStringLiteral("Activity"), QStringLiteral("http://example.de/apps/activity/img/app.svg")},
+ };
+ for (auto row = 0; row < model->rowCount(); ++row) {
+ const auto index = model->index(row);
+ const auto title = model->data(index, OCC::UnifiedSearchResultsListModel::TitleRole).toString();
+ if (expectedIconUrls.contains(title)) {
+ QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole).toString(),
+ expectedIconUrls.value(title) + QStringLiteral("/white"));
+ QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole).toString(),
+ expectedIconUrls.value(title) + QStringLiteral("/black"));
+ } else if (title == QStringLiteral("Unknown app")) {
+ QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole)
+ .toString()
+ .endsWith(QStringLiteral("change.svg")));
+ QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole)
+ .toString()
+ .endsWith(QStringLiteral("change.svg")));
+ }
+ }
+ }
+
+ void testFiltersAndRequestParameters()
+ {
+ model->setSearchTerm(QString());
+ model->clearTypeFilters();
+ model->clearDateFilter();
+ model->clearPersonFilter();
+ requestedUrls.clear();
+
+ model->toggleProviderFilter(QStringLiteral("files"));
+ model->setDatePreset(QStringLiteral("last7days"));
+ model->setPersonFilter(QStringLiteral("ada"), QStringLiteral("Ada Lovelace"));
+ model->setSearchTerm(QStringLiteral("filtered"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model->isSearchInProgress() && !model->waitingForSearchTermEditEnd(), 2000);
+
+ QList searchRequests;
+ for (const auto &url : std::as_const(requestedUrls)) {
+ if (url.path().endsWith(QStringLiteral("/search"))) searchRequests.push_back(url);
+ }
+ QCOMPARE(searchRequests.size(), 1);
+ QVERIFY(searchRequests.constFirst().path().contains(QStringLiteral("/files/search")));
+ const QUrlQuery query(searchRequests.constFirst());
+ QCOMPARE(query.queryItemValue(QStringLiteral("term")), QStringLiteral("filtered"));
+ QCOMPARE(query.queryItemValue(QStringLiteral("limit")), QStringLiteral("10"));
+ QCOMPARE(query.queryItemValue(QStringLiteral("person")), QStringLiteral("ada"));
+ QVERIFY(!query.queryItemValue(QStringLiteral("since")).isEmpty());
+ QVERIFY(!query.queryItemValue(QStringLiteral("until")).isEmpty());
+ QVERIFY(!query.hasQueryItem(QStringLiteral("from")));
+
+ model->setSearchTerm(QString());
+ model->clearTypeFilters();
+ model->clearDateFilter();
+ model->clearPersonFilter();
+ }
+
void testSearchResultlicked()
{
// make sure the model is empty
@@ -547,21 +756,8 @@ private Q_SLOTS:
// test that search term gets set, search gets started and enough results get returned
model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss"));
- QSignalSpy searchInProgressChanged(
- model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has started
- QCOMPARE(searchInProgressChanged.count(), 1);
- QVERIFY(model->isSearchInProgress());
-
- QVERIFY(searchInProgressChanged.wait());
-
- // make sure search has finished and some results has been received
- QVERIFY(!model->isSearchInProgress());
-
- QVERIFY(model->rowCount() != 0);
+ QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd()
+ && !model->isSearchInProgress() && model->rowCount() > 0, 2000);
QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked");
QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked");
@@ -737,6 +933,32 @@ private Q_SLOTS:
model->setSearchTerm(QStringLiteral(""));
}
+ void testDisconnectAndRediscoveryResetProviderState()
+ {
+ model->setSearchTerm(QString());
+ model->clearTypeFilters();
+ QVERIFY(model->providersReady());
+ model->toggleProviderFilter(QStringLiteral("files"));
+ QCOMPARE(model->activeFilters().size(), 1);
+ QSignalSpy providersReadyChanged(model.data(), &OCC::UnifiedSearchResultsListModel::providersReadyChanged);
+
+ accountState->setStateForTesting(OCC::AccountState::Disconnected);
+ QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection));
+ QCOMPARE(model->providersReady(), false);
+ QVERIFY(providersReadyChanged.count() > 0);
+
+ FakeSearchResultsStorage::instance()->initProvidersResponse(QStringLiteral("files"));
+ accountState->setStateForTesting(OCC::AccountState::Connected);
+ QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection));
+ QTRY_VERIFY_WITH_TIMEOUT(model->providersReady(), 1000);
+ QCOMPARE(model->activeFilters().size(), 0);
+ for (const auto &providerValue : model->providers()) {
+ QVERIFY(providerValue.toMap().value(QStringLiteral("id")).toString() != QStringLiteral("files"));
+ }
+
+ FakeSearchResultsStorage::instance()->initProvidersResponse();
+ }
+
void cleanupTestCase()
{
FakeSearchResultsStorage::destroy();
diff --git a/test/testunifiedsearchpeoplemodel.cpp b/test/testunifiedsearchpeoplemodel.cpp
new file mode 100644
index 0000000000000..b4006cb8257df
--- /dev/null
+++ b/test/testunifiedsearchpeoplemodel.cpp
@@ -0,0 +1,202 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "gui/search/unifiedsearchpeoplemodel.h"
+
+#include "account.h"
+#include "accountstate.h"
+#include "syncenginetestutils.h"
+#include "testhelper.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace {
+const auto response = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[{"label":"Ada Lovelace","value":{"shareWith":"ada","shareType":0}}]},"users":[{"label":"Ada duplicate","value":{"shareWith":"ada","shareType":0}},{"label":"Alan Turing","value":{"shareWith":"alan","shareType":0}}]}}})");
+const auto errorResponse = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Failure"},"data":{}}})");
+}
+
+class TestUnifiedSearchPeopleModel : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void retryIsExposedAsSlot()
+ {
+ const auto methodIndex = OCC::UnifiedSearchPeopleModel::staticMetaObject.indexOfSlot("retry()");
+ QVERIFY(methodIndex >= 0);
+ }
+
+ void emptyQueryShowsSignedInUserWithoutRequest()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ account->setDavUser(QStringLiteral("current-user"));
+ auto state = std::make_unique(account);
+ auto requestCount = 0;
+ qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) {
+ ++requestCount;
+ return static_cast(new FakePayloadReply(operation, request, response, qnam.get()));
+ });
+
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ QAbstractItemModelTester tester(&model);
+ model.setAccountState(state.get());
+
+ QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000);
+ QCOMPARE(requestCount, 0);
+ QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user"));
+ QVERIFY(model.errorString().isEmpty());
+ }
+
+ void parsesUsersAndUsesDocumentedParameters()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ account->setDavUser(QStringLiteral("me"));
+ auto state = std::make_unique(account);
+ QUrl requestedUrl;
+ qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) {
+ requestedUrl = request.url();
+ return static_cast(new FakePayloadReply(operation, request, response, qnam.get()));
+ });
+
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ QAbstractItemModelTester tester(&model);
+ model.setAccountState(state.get());
+ model.setSearchTerm(QStringLiteral("a"));
+ QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && model.rowCount() == 2, 1000);
+
+ QCOMPARE(requestedUrl.path(), QStringLiteral("/ocs/v2.php/apps/files_sharing/api/v1/sharees"));
+ const QUrlQuery query(requestedUrl);
+ QCOMPARE(query.queryItemValue(QStringLiteral("shareType")), QStringLiteral("0"));
+ QCOMPARE(query.queryItemValue(QStringLiteral("lookup")), QStringLiteral("false"));
+ QCOMPARE(query.queryItemValue(QStringLiteral("page")), QStringLiteral("1"));
+ QCOMPARE(query.queryItemValue(QStringLiteral("perPage")), QStringLiteral("50"));
+ QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("ada"));
+ QCOMPARE(model.data(model.index(1), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("alan"));
+ QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::AvatarUrlRole).toString(),
+ QStringLiteral("https://cloud.example.test/index.php/avatar/ada/64"));
+ }
+
+ void injectsMatchingSignedInUser()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ account->setDavUser(QStringLiteral("current-user"));
+ auto state = std::make_unique(account);
+ qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) {
+ return static_cast(new FakePayloadReply(operation, request, QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[]},"users":[]}}})"), qnam.get()));
+ });
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ model.setAccountState(state.get());
+ model.setSearchTerm(QStringLiteral("current"));
+ QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000);
+ QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user"));
+ }
+
+ void queryReplacementAndFailureClearOldPeople()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ account->setDavUser(QStringLiteral("me"));
+ auto state = std::make_unique(account);
+ qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) {
+ const auto query = QUrlQuery(request.url()).queryItemValue(QStringLiteral("search"));
+ if (query == QStringLiteral("broken")) {
+ return static_cast(new FakeErrorReply(operation, request, qnam.get(), 500, errorResponse));
+ }
+ return static_cast(new FakePayloadReply(operation, request, response, qnam.get()));
+ });
+
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ QAbstractItemModelTester tester(&model);
+ model.setAccountState(state.get());
+ model.setSearchTerm(QStringLiteral("a"));
+ QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 2, 1000);
+
+ model.setSearchTerm(QStringLiteral("broken"));
+ QCOMPARE(model.rowCount(), 0);
+ QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && !model.errorString().isEmpty(), 1000);
+ QCOMPARE(model.rowCount(), 0);
+ }
+
+ void accountDestructionClearsPeopleAndPointer()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ account->setDavUser(QStringLiteral("current-user"));
+ auto state = std::make_unique(account);
+
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ QAbstractItemModelTester tester(&model);
+ model.setAccountState(state.get());
+ QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000);
+
+ state->setStateForTesting(OCC::AccountState::Disconnected);
+ QVERIFY(QMetaObject::invokeMethod(state.get(), "isConnectedChanged", Qt::DirectConnection));
+ QCOMPARE(model.rowCount(), 0);
+ QVERIFY(!model.errorString().isEmpty());
+
+ state.reset();
+ QCOMPARE(model.accountState(), nullptr);
+ QCOMPARE(model.rowCount(), 0);
+ QVERIFY(!model.errorString().isEmpty());
+ }
+
+ void capsNonConformingResponses()
+ {
+ auto qnam = std::unique_ptr(new FakeQNAM({}));
+ auto account = OCC::Account::create();
+ account->setCredentials(new FakeCredentials(qnam.get()));
+ account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/")));
+ auto state = std::make_unique(account);
+
+ auto users = QJsonArray();
+ for (auto index = 0; index < 75; ++index) {
+ users.push_back(QJsonObject{
+ {QStringLiteral("label"), QStringLiteral("User %1").arg(index)},
+ {QStringLiteral("value"), QJsonObject{{QStringLiteral("shareWith"), QStringLiteral("user-%1").arg(index)}}},
+ });
+ }
+ const auto oversizedResponse = QJsonDocument(QJsonObject{
+ {QStringLiteral("ocs"), QJsonObject{
+ {QStringLiteral("meta"), QJsonObject{{QStringLiteral("statuscode"), 200}}},
+ {QStringLiteral("data"), QJsonObject{
+ {QStringLiteral("exact"), QJsonObject{{QStringLiteral("users"), QJsonArray()}}},
+ {QStringLiteral("users"), users},
+ }},
+ }},
+ }).toJson(QJsonDocument::Compact);
+ qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) {
+ return static_cast(new FakePayloadReply(operation, request, oversizedResponse, qnam.get()));
+ });
+
+ OCC::UnifiedSearchPeopleModel model(nullptr, 0);
+ model.setAccountState(state.get());
+ model.setSearchTerm(QStringLiteral("user"));
+ QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 50, 1000);
+ }
+};
+
+QTEST_MAIN(TestUnifiedSearchPeopleModel)
+#include "testunifiedsearchpeoplemodel.moc"
diff --git a/theme.qrc.in b/theme.qrc.in
index 501cae84d3d83..408d8a16e34cf 100644
--- a/theme.qrc.in
+++ b/theme.qrc.in
@@ -192,6 +192,7 @@
theme/black/caret-down.svg
theme/black/change.svg
theme/black/clear.svg
+ theme/black/filter.svg
theme/black/close.svg
theme/black/comment.svg
theme/black/confirm.svg
@@ -269,6 +270,8 @@
theme/white/error.svg
+ theme/arrow-left.svg
+ theme/arrow-right.svg
theme/sync-arrow.svg
theme/close.svg
theme/files.svg
diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml
index 84adf5c075e70..a941c8db3accb 100644
--- a/theme/Style/Style.qml
+++ b/theme/Style/Style.qml
@@ -49,6 +49,7 @@ QtObject {
readonly property color wizardErrorText: darkMode ? "#ff7b86" : "#b00020"
readonly property color wizardAvatarPlaceholder: darkMode ? "#3b464d" : "#dfe8ee"
readonly property color wizardSelectedText: "#ffffff"
+ readonly property color listItemHoverBackground: darkMode ? "#3a3a3a" : "#f2f2f2"
// ErrorBox colors
readonly property color errorBoxBackgroundColor: Qt.rgba(0.89, 0.18, 0.18, 1)
@@ -126,6 +127,11 @@ QtObject {
readonly property int wizardWindowMargin: 24
readonly property int wizardWindowTopMargin: standardSpacing
readonly property int wizardFooterButtonHeight: iconButtonWidth
+ readonly property int wizardButtonHorizontalPadding: 18
+ readonly property int wizardButtonContentSpacing: 6
+ readonly property int wizardButtonFontPixelSize: pixelSize + 3
+ readonly property int wizardChipButtonHeight: variableSize(26)
+ readonly property int wizardMenuItemHorizontalPadding: wizardSectionSpacing
readonly property int wizardFooterSpacing: trayAccountPopupActionVerticalPadding
readonly property int wizardSectionSpacing: trayAccountPopupRowPadding
readonly property int wizardDialogMaximumWidth: 420
@@ -149,7 +155,7 @@ QtObject {
readonly property int assistantWindowWidth: 640
readonly property int assistantWindowHeight: 620
readonly property int searchWindowWidth: 640
- readonly property int searchWindowHeight: 620
+ readonly property int searchWindowHeight: 670
readonly property int userStatusWindowWidth: 560
readonly property int userStatusWindowHeight: 700
readonly property int userStatusWindowMinimumHeight: 560
@@ -220,15 +226,32 @@ QtObject {
// Visual behaviour
property bool hoverEffectsEnabled: true
- // unified search constants
- readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight
+ // unified search constants
+ readonly property int unifiedSearchDetailHeaderHeight: standardPrimaryButtonHeight
+ readonly property int unifiedSearchFilterButtonMinimumWidth: 140
+ readonly property real unifiedSearchFilterMenuWidthFactor: 1.5
+ readonly property int unifiedSearchProviderMenuMaximumVisibleRows: 8
+ readonly property int unifiedSearchPeoplePopupHorizontalMargin: standardPrimaryButtonHeight
+ readonly property int unifiedSearchPeoplePopupHeight: 340
+ readonly property int unifiedSearchPeoplePopupTopMargin: 150
+ readonly property int unifiedSearchProviderHeaderHeight: 44
+ readonly property int unifiedSearchPartialMatchesHeaderHeight: standardPrimaryButtonHeight
+ readonly property int unifiedSearchPagingRowHeight: unifiedSearchProviderHeaderHeight
+ readonly property int unifiedSearchLoadingPlaceholderCount: 3
+ readonly property real unifiedSearchLoadingPlaceholderInitialWidthRatio: 0.72
+ readonly property real unifiedSearchLoadingPlaceholderWidthStep: 0.07
+ readonly property real unifiedSearchLoadingPlaceholderOpacity: 0.55
+ readonly property real unifiedSearchPartialMatchOpacity: 0.72
+ readonly property int unifiedSearchItemHeight: Math.max(44,
+ unifiedSearchResultTitleFontSize * 2 + unifiedSearchResultTextSpacing + 8)
readonly property int unifiedSearchResultTextLeftMargin: 18
readonly property int unifiedSearchResultTextRightMargin: 16
readonly property int unifiedSearchResultIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction)
readonly property int unifiedSearchResultSmallIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction * 2)
readonly property int unifiedSearchResultIconLeftMargin: 12
- readonly property int unifiedSearchResultTitleFontSize: topLinePixelSize
- readonly property int unifiedSearchResultSublineFontSize: subLinePixelSize
+ readonly property int unifiedSearchResultTitleFontSize: pixelSize + extraSmallSpacing
+ readonly property int unifiedSearchResultSublineFontSize: unifiedSearchResultTitleFontSize
+ readonly property int unifiedSearchResultTextSpacing: extraSmallSpacing
readonly property int unifiedSearchResultSectionItemLeftPadding: 16
readonly property int unifiedSearchResultSectionItemVerticalPadding: 8
readonly property int unifiedSearchResultNothingFoundHorizontalMargin: 10
diff --git a/theme/arrow-left.svg b/theme/arrow-left.svg
new file mode 100644
index 0000000000000..d2992de683874
--- /dev/null
+++ b/theme/arrow-left.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/theme/arrow-right.svg b/theme/arrow-right.svg
new file mode 100644
index 0000000000000..b93e479f6161c
--- /dev/null
+++ b/theme/arrow-right.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/theme/black/filter.svg b/theme/black/filter.svg
new file mode 100644
index 0000000000000..75cbe818fa6c2
--- /dev/null
+++ b/theme/black/filter.svg
@@ -0,0 +1,3 @@
+
+
+