diff --git a/resources.qrc b/resources.qrc index 2d7f227484ca2..fdd4bcf570e63 100644 --- a/resources.qrc +++ b/resources.qrc @@ -56,5 +56,8 @@ src/gui/tray/ListItemLineAndSubline.qml src/gui/tray/TrayFoldersMenuButton.qml src/gui/tray/TrayFolderListItem.qml + src/gui/ResolveConflictsDialog.qml + src/gui/ConflictDelegate.qml + src/gui/ConflictItemFileInfo.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 8e67738292d26..e4af0e33a6fef 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -186,6 +186,8 @@ set(client_SRCS userstatusselectormodel.cpp emojimodel.h emojimodel.cpp + syncconflictsmodel.h + syncconflictsmodel.cpp fileactivitylistmodel.h fileactivitylistmodel.cpp filedetails/filedetails.h diff --git a/src/gui/ConflictDelegate.qml b/src/gui/ConflictDelegate.qml new file mode 100644 index 0000000000000..ff6b48eb00e71 --- /dev/null +++ b/src/gui/ConflictDelegate.qml @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import Style 1.0 +import com.nextcloud.desktopclient 1.0 +import "./tray" + +Item { + id: root + + required property string existingFileName + required property string existingSize + required property string conflictSize + required property string existingDate + required property string conflictDate + required property bool existingSelected + required property bool conflictSelected + required property url existingPreviewUrl + required property url conflictPreviewUrl + required property var model + + EnforcedPlainTextLabel { + id: existingFileNameLabel + + anchors.top: parent.top + anchors.left: parent.left + + text: root.existingFileName + + font.weight: Font.Bold + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + } + + RowLayout { + anchors.top: existingFileNameLabel.bottom + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottomMargin: 8 + + ConflictItemFileInfo { + Layout.fillWidth: true + Layout.fillHeight: true + + itemSelected: root.existingSelected + itemPreviewUrl: root.existingPreviewUrl + itemVersionLabel: qsTr('Local version') + itemDateLabel: root.existingDate + itemFileSizeLabel: root.existingSize + + onSelectedChanged: function() { + model.existingSelected = itemSelected + } + } + + ConflictItemFileInfo { + Layout.fillWidth: true + Layout.fillHeight: true + + itemSelected: root.conflictSelected + itemPreviewUrl: root.conflictPreviewUrl + itemVersionLabel: qsTr('Server version') + itemDateLabel: root.conflictDate + itemFileSizeLabel: root.conflictSize + + onSelectedChanged: function() { + model.conflictSelected = itemSelected + } + } + } +} diff --git a/src/gui/ConflictItemFileInfo.qml b/src/gui/ConflictItemFileInfo.qml new file mode 100644 index 0000000000000..be345f7e8a945 --- /dev/null +++ b/src/gui/ConflictItemFileInfo.qml @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import Style 1.0 +import "./tray" + +Item { + property alias itemSelected: selectItem.checked + property alias itemPreviewUrl: itemPreview.source + property alias itemVersionLabel: versionLabel.text + property alias itemDateLabel: dateLabel.text + property alias itemFileSizeLabel: fileSizeLabel.text + + signal selectedChanged() + + CheckBox { + id: selectItem + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + leftPadding: 0 + spacing: 0 + + onToggled: function() { + selectedChanged() + } + } + + Image { + id: itemPreview + + anchors.left: selectItem.right + anchors.verticalCenter: parent.verticalCenter + + width: 48 + height: 48 + sourceSize.width: 48 + sourceSize.height: 48 + } + + ColumnLayout { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: itemPreview.right + anchors.right: parent.right + anchors.leftMargin: 10 + + spacing: 0 + + Item { + Layout.fillHeight: true + } + + EnforcedPlainTextLabel { + id: versionLabel + + Layout.fillWidth: true + + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + } + + EnforcedPlainTextLabel { + id: dateLabel + + Layout.fillWidth: true + + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + } + + EnforcedPlainTextLabel { + id: fileSizeLabel + + Layout.fillWidth: true + + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/src/gui/ResolveConflictsDialog.qml b/src/gui/ResolveConflictsDialog.qml new file mode 100644 index 0000000000000..99bd61da5a8ed --- /dev/null +++ b/src/gui/ResolveConflictsDialog.qml @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Window 2.15 as QtWindow +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import QtQml.Models 2.15 +import Style 1.0 +import com.nextcloud.desktopclient 1.0 +import "./tray" + +QtWindow.Window { + id: conflictsDialog + + required property var allConflicts + + flags: Qt.Window | Qt.Dialog + visible: true + + width: Style.minimumWidthResolveConflictsDialog + height: Style.minimumHeightResolveConflictsDialog + minimumWidth: Style.minimumWidthResolveConflictsDialog + minimumHeight: Style.minimumHeightResolveConflictsDialog + title: qsTr('Solve sync conflicts') + + onClosing: function(close) { + Systray.destroyDialog(self); + close.accepted = true + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.bottomMargin: 20 + anchors.topMargin: 20 + spacing: 15 + z: 2 + + EnforcedPlainTextLabel { + text: qsTr("%1 files in conflict", 'indicate the number of conflicts to resolve', delegateModel.count).arg(delegateModel.count) + font.bold: true + font.pixelSize: Style.bigFontPixelSizeResolveConflictsDialog + Layout.fillWidth: true + } + + EnforcedPlainTextLabel { + text: qsTr("Choose if you want to keep the local version, server version, or both. If you choose both, the local file will have a number added to its name.") + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + Layout.fillWidth: true + Layout.topMargin: -15 + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 15 + + CheckBox { + id: selectExisting + + Layout.fillWidth: true + + text: qsTr('All local versions') + + leftPadding: 0 + implicitWidth: 100 + + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + + checked: realModel.allExistingsSelected + onToggled: function() { + realModel.selectAllExisting(checked) + } + } + + CheckBox { + id: selectConflict + + Layout.fillWidth: true + + text: qsTr('All server versions') + + leftPadding: 0 + implicitWidth: 100 + + font.pixelSize: Style.fontPixelSizeResolveConflictsDialog + + checked: realModel.allConflictingSelected + onToggled: function() { + realModel.selectAllConflicting(checked) + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 5 + Layout.rightMargin: 5 + color: Style.menuBorder + height: 1 + } + + SyncConflictsModel { + id: realModel + + conflictActivities: conflictsDialog.allConflicts + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: conflictListView + + model: DelegateModel { + id: delegateModel + + model: realModel + + delegate: ConflictDelegate { + width: conflictListView.contentItem.width + height: 100 + } + } + } + } + + DialogButtonBox { + Layout.fillWidth: true + + Button { + text: qsTr("Resolve conflicts") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + } + Button { + text: qsTr("Cancel") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + } + + onAccepted: function() { + realModel.applySolution() + Systray.destroyDialog(conflictsDialog) + } + + onRejected: function() { + Systray.destroyDialog(conflictsDialog) + } + } + } + + Rectangle { + color: Theme.systemPalette.window + anchors.fill: parent + z: 1 + } +} diff --git a/src/gui/conflictsolver.h b/src/gui/conflictsolver.h index b000e78dcc2f7..018741b05ec89 100644 --- a/src/gui/conflictsolver.h +++ b/src/gui/conflictsolver.h @@ -32,6 +32,7 @@ class ConflictSolver : public QObject KeepRemoteVersion, KeepBothVersions }; + Q_ENUM(Solution); explicit ConflictSolver(QWidget *parent = nullptr); diff --git a/src/gui/folderman.h b/src/gui/folderman.h index 6b9186b72e2da..933954b173f56 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -30,6 +30,7 @@ class TestCfApiShellExtensionsIPC; class TestShareModel; class ShareTestHelper; class EndToEndTestHelper; +class TestSyncConflictsModel; namespace OCC { @@ -391,6 +392,7 @@ private slots: explicit FolderMan(QObject *parent = nullptr); friend class OCC::Application; friend class ::TestFolderMan; + friend class ::TestSyncConflictsModel; friend class ::TestCfApiShellExtensionsIPC; friend class ::ShareTestHelper; friend class ::EndToEndTestHelper; diff --git a/src/gui/main.cpp b/src/gui/main.cpp index 40af80ac019c5..a53aa160b1d54 100644 --- a/src/gui/main.cpp +++ b/src/gui/main.cpp @@ -66,7 +66,7 @@ int main(int argc, char **argv) // the platformtheme plugin won't try to force qqc2-desktops-style // anymore. // Can be removed once the bug in qqc2-desktop-style is gone. - QQuickStyle::setStyle("Default"); + QQuickStyle::setStyle("Fusion"); // OpenSSL 1.1.0: No explicit initialisation or de-initialisation is necessary. diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index a43363879ef23..45215d53ee333 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -31,6 +31,7 @@ #include "settingsdialog.h" #include "theme.h" #include "wheelhandler.h" +#include "syncconflictsmodel.h" #include "filedetails/filedetails.h" #include "filedetails/shareemodel.h" #include "filedetails/sharemodel.h" @@ -125,6 +126,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum"); @@ -138,6 +140,7 @@ ownCloudGui::ownCloudGui(Application *parent) qRegisterMetaType("SharePtr"); qRegisterMetaType("ShareePtr"); qRegisterMetaType("Sharee"); + qRegisterMetaType("ActivityList"); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserModel", UserModel::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserAppsModel", UserAppsModel::instance()); diff --git a/src/gui/syncconflictsmodel.cpp b/src/gui/syncconflictsmodel.cpp new file mode 100644 index 0000000000000..3d455d8729269 --- /dev/null +++ b/src/gui/syncconflictsmodel.cpp @@ -0,0 +1,343 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "syncconflictsmodel.h" + +#include "folderman.h" + +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcSyncConflictsModel, "nextcloud.syncconflictsmodel", QtInfoMsg) + +SyncConflictsModel::SyncConflictsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int SyncConflictsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return _data.size(); +} + +QVariant SyncConflictsModel::data(const QModelIndex &index, int role) const +{ + auto result = QVariant{}; + + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); + + if (index.parent().isValid()) { + return result; + } + + if (role >= static_cast(SyncConflictRoles::ExistingFileName) && role <= static_cast(SyncConflictRoles::ConflictPreviewUrl)) { + const auto convertedRole = static_cast(role); + + switch (convertedRole) { + case SyncConflictRoles::ExistingFileName: + result = _conflictData[index.row()].mExistingFileName; + break; + case SyncConflictRoles::ExistingSize: + result = _conflictData[index.row()].mExistingSize; + break; + case SyncConflictRoles::ConflictSize: + result = _conflictData[index.row()].mConflictSize; + break; + case SyncConflictRoles::ExistingDate: + result = _conflictData[index.row()].mExistingDate; + break; + case SyncConflictRoles::ConflictDate: + result = _conflictData[index.row()].mConflictDate; + break; + case SyncConflictRoles::ExistingSelected: + result = _conflictData[index.row()].mExistingSelected == ConflictInfo::ConflictSolution::SolutionSelected; + break; + case SyncConflictRoles::ConflictSelected: + result = _conflictData[index.row()].mConflictSelected == ConflictInfo::ConflictSolution::SolutionSelected; + break; + case SyncConflictRoles::ExistingPreviewUrl: + result = _conflictData[index.row()].mExistingPreviewUrl; + break; + case SyncConflictRoles::ConflictPreviewUrl: + result = _conflictData[index.row()].mConflictPreviewUrl; + break; + } + } + + return result; +} + +bool SyncConflictsModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + auto result = false; + + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); + + if (index.parent().isValid()) { + return result; + } + + if (role >= static_cast(SyncConflictRoles::ExistingFileName) && role <= static_cast(SyncConflictRoles::ConflictPreviewUrl)) { + const auto convertedRole = static_cast(role); + + switch(convertedRole) { + case SyncConflictRoles::ExistingFileName: + break; + case SyncConflictRoles::ExistingSize: + break; + case SyncConflictRoles::ConflictSize: + break; + case SyncConflictRoles::ExistingDate: + break; + case SyncConflictRoles::ConflictDate: + break; + case SyncConflictRoles::ExistingSelected: + setExistingSelected(value.toBool(), index, role); + result = true; + break; + case SyncConflictRoles::ConflictSelected: + setConflictingSelected(value.toBool(), index, role); + result = true; + break; + case SyncConflictRoles::ExistingPreviewUrl: + break; + case SyncConflictRoles::ConflictPreviewUrl: + break; + } + + result = false; + } + + return result; +} + +QHash SyncConflictsModel::roleNames() const +{ + auto result = QAbstractListModel::roleNames(); + + result[static_cast(SyncConflictRoles::ExistingFileName)] = "existingFileName"; + result[static_cast(SyncConflictRoles::ExistingSize)] = "existingSize"; + result[static_cast(SyncConflictRoles::ConflictSize)] = "conflictSize"; + result[static_cast(SyncConflictRoles::ExistingDate)] = "existingDate"; + result[static_cast(SyncConflictRoles::ConflictDate)] = "conflictDate"; + result[static_cast(SyncConflictRoles::ExistingSelected)] = "existingSelected"; + result[static_cast(SyncConflictRoles::ConflictSelected)] = "conflictSelected"; + result[static_cast(SyncConflictRoles::ExistingPreviewUrl)] = "existingPreviewUrl"; + result[static_cast(SyncConflictRoles::ConflictPreviewUrl)] = "conflictPreviewUrl"; + + return result; +} + +Qt::ItemFlags SyncConflictsModel::flags(const QModelIndex &index) const +{ + auto result = Qt::ItemFlags{}; + + if (!index.parent().isValid()) { + result = QAbstractListModel::flags(index); + return result; + } + + result = Qt::ItemIsSelectable | Qt::ItemIsEditable; + return result; +} + +ActivityList SyncConflictsModel::conflictActivities() const +{ + return _data; +} + +bool SyncConflictsModel::allExistingsSelected() const +{ + return _allExistingsSelected; +} + +bool SyncConflictsModel::allConflictingSelected() const +{ + return _allConflictingsSelected; +} + +void SyncConflictsModel::setConflictActivities(ActivityList conflicts) +{ + if (_data == conflicts) { + return; + } + + beginResetModel(); + + _data = conflicts; + emit conflictActivitiesChanged(); + + updateConflictsData(); + + endResetModel(); +} + +void SyncConflictsModel::selectAllExisting(bool selected) +{ + for (auto &singleConflict : _conflictData) { + singleConflict.mExistingSelected = selected ? ConflictInfo::ConflictSolution::SolutionSelected : ConflictInfo::ConflictSolution::SolutionDeselected; + } + + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {static_cast(SyncConflictRoles::ExistingSelected)}); + + if (selected && !_allExistingsSelected) { + _allExistingsSelected = true; + Q_EMIT allExistingsSelectedChanged(); + } else if (!selected && _allExistingsSelected) { + _allExistingsSelected = false; + Q_EMIT allExistingsSelectedChanged(); + } +} + +void SyncConflictsModel::selectAllConflicting(bool selected) +{ + for (auto &singleConflict : _conflictData) { + singleConflict.mConflictSelected = selected ? ConflictInfo::ConflictSolution::SolutionSelected : ConflictInfo::ConflictSolution::SolutionDeselected; + } + + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {static_cast(SyncConflictRoles::ConflictSelected)}); + + if (selected && !_allConflictingsSelected) { + _allConflictingsSelected = true; + Q_EMIT allConflictingSelectedChanged(); + } else if (!selected && _allConflictingsSelected) { + _allConflictingsSelected = false; + Q_EMIT allConflictingSelectedChanged(); + } +} + +void SyncConflictsModel::applySolution() +{ + for(const auto &syncConflict : qAsConst(_conflictData)) { + if (syncConflict.isValid()) { + qCInfo(lcSyncConflictsModel) << syncConflict.mExistingFilePath << syncConflict.mConflictingFilePath << syncConflict.solution(); + ConflictSolver solver; + solver.setLocalVersionFilename(syncConflict.mConflictingFilePath); + solver.setRemoteVersionFilename(syncConflict.mExistingFilePath); + solver.exec(syncConflict.solution()); + } + } +} + +void SyncConflictsModel::updateConflictsData() +{ + _conflictData.clear(); + _conflictData.reserve(_data.size()); + + for (const auto &oneConflict : qAsConst(_data)) { + const auto folder = FolderMan::instance()->folder(oneConflict._folder); + if (!folder) { + qCWarning(lcSyncConflictsModel) << "no Folder instance for" << oneConflict._folder; + _conflictData.push_back({}); + continue; + } + + const auto conflictedRelativePath = oneConflict._file; + const auto baseRelativePath = folder->journalDb() ? folder->journalDb()->conflictFileBaseName(conflictedRelativePath.toUtf8()) : QString{}; + + const auto dir = QDir(folder->path()); + const auto conflictedPath = dir.filePath(conflictedRelativePath); + const auto basePath = dir.filePath(baseRelativePath); + + const auto existingFileInfo = QFileInfo(basePath); + const auto conflictFileInfo = QFileInfo(conflictedPath); + + auto newConflictData = ConflictInfo{ + existingFileInfo.fileName(), + _locale.formattedDataSize(existingFileInfo.size()), + _locale.formattedDataSize(conflictFileInfo.size()), + existingFileInfo.lastModified().toString(), + conflictFileInfo.lastModified().toString(), + QUrl{QStringLiteral("image://tray-image-provider/:/fileicon") + existingFileInfo.filePath()}, + QUrl{QStringLiteral("image://tray-image-provider/:/fileicon") + conflictFileInfo.filePath()}, + ConflictInfo::ConflictSolution::SolutionDeselected, + ConflictInfo::ConflictSolution::SolutionDeselected, + existingFileInfo.filePath(), + conflictFileInfo.filePath(), + }; + + _conflictData.push_back(std::move(newConflictData)); + } +} + +void SyncConflictsModel::setExistingSelected(bool value, + const QModelIndex &index, + int role) +{ + _conflictData[index.row()].mExistingSelected = value ? ConflictInfo::ConflictSolution::SolutionSelected : ConflictInfo::ConflictSolution::SolutionDeselected; + Q_EMIT dataChanged(index, index, {role}); + + if (_conflictData[index.row()].mExistingSelected == ConflictInfo::ConflictSolution::SolutionDeselected && _allExistingsSelected) { + _allExistingsSelected = false; + Q_EMIT allExistingsSelectedChanged(); + } else if (_conflictData[index.row()].mExistingSelected == ConflictInfo::ConflictSolution::SolutionSelected && !_allExistingsSelected) { + const auto deselectedConflictIt = std::find_if(_conflictData.constBegin(), _conflictData.constEnd(), [] (const auto singleConflict) { + return singleConflict.mExistingSelected == ConflictInfo::ConflictSolution::SolutionDeselected; + }); + const auto allSelected = (deselectedConflictIt == _conflictData.constEnd()); + if (allSelected) { + _allExistingsSelected = true; + Q_EMIT allExistingsSelectedChanged(); + } + } +} + +void SyncConflictsModel::setConflictingSelected(bool value, + const QModelIndex &index, + int role) +{ + _conflictData[index.row()].mConflictSelected = value ? ConflictInfo::ConflictSolution::SolutionSelected : ConflictInfo::ConflictSolution::SolutionDeselected; + Q_EMIT dataChanged(index, index, {role}); + + if (_conflictData[index.row()].mConflictSelected == ConflictInfo::ConflictSolution::SolutionDeselected && _allConflictingsSelected) { + _allConflictingsSelected = false; + Q_EMIT allConflictingSelectedChanged(); + } else if (_conflictData[index.row()].mConflictSelected == ConflictInfo::ConflictSolution::SolutionSelected && !_allConflictingsSelected) { + const auto deselectedConflictIt = std::find_if(_conflictData.constBegin(), _conflictData.constEnd(), [] (const auto singleConflict) { + return singleConflict.mConflictSelected == ConflictInfo::ConflictSolution::SolutionDeselected; + }); + const auto allSelected = (deselectedConflictIt == _conflictData.constEnd()); + if (allSelected) { + _allConflictingsSelected = true; + Q_EMIT allConflictingSelectedChanged(); + } + } +} + +ConflictSolver::Solution SyncConflictsModel::ConflictInfo::solution() const +{ + auto result = ConflictSolver::Solution{}; + + if (mConflictSelected == ConflictSolution::SolutionSelected && mExistingSelected == ConflictSolution::SolutionSelected) { + result = ConflictSolver::KeepBothVersions; + } else if (mConflictSelected == ConflictSolution::SolutionDeselected && mExistingSelected == ConflictSolution::SolutionSelected) { + result = ConflictSolver::KeepLocalVersion; + } else if (mConflictSelected == ConflictSolution::SolutionSelected && mExistingSelected == ConflictSolution::SolutionDeselected) { + result = ConflictSolver::KeepRemoteVersion; + } + + return result; +} + +bool SyncConflictsModel::ConflictInfo::isValid() const +{ + return mConflictSelected == ConflictInfo::ConflictSolution::SolutionSelected || mExistingSelected == ConflictInfo::ConflictSolution::SolutionSelected; +} + +} diff --git a/src/gui/syncconflictsmodel.h b/src/gui/syncconflictsmodel.h new file mode 100644 index 0000000000000..af7d8a73eb845 --- /dev/null +++ b/src/gui/syncconflictsmodel.h @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include "tray/activitydata.h" + +#include "conflictsolver.h" + +#include +#include +#include + +namespace OCC { + +class SyncConflictsModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(OCC::ActivityList conflictActivities READ conflictActivities WRITE setConflictActivities NOTIFY conflictActivitiesChanged) + + Q_PROPERTY(bool allExistingsSelected READ allExistingsSelected NOTIFY allExistingsSelectedChanged) + + Q_PROPERTY(bool allConflictingSelected READ allConflictingSelected NOTIFY allConflictingSelectedChanged) + + struct ConflictInfo { + enum class ConflictSolution : bool{ + SolutionSelected = true, + SolutionDeselected = false, + }; + + + QString mExistingFileName; + QString mExistingSize; + QString mConflictSize; + QString mExistingDate; + QString mConflictDate; + QUrl mExistingPreviewUrl; + QUrl mConflictPreviewUrl; + ConflictSolution mExistingSelected = ConflictSolution::SolutionDeselected; + ConflictSolution mConflictSelected = ConflictSolution::SolutionDeselected; + QString mExistingFilePath; + QString mConflictingFilePath; + + [[nodiscard]] ConflictSolver::Solution solution() const; + [[nodiscard]] bool isValid() const; + }; + +public: + enum class SyncConflictRoles : int { + ExistingFileName = Qt::UserRole, + ExistingSize, + ConflictSize, + ExistingDate, + ConflictDate, + ExistingSelected, + ConflictSelected, + ExistingPreviewUrl, + ConflictPreviewUrl, + }; + + Q_ENUM(SyncConflictRoles) + + explicit SyncConflictsModel(QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + [[nodiscard]] bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] Qt::ItemFlags flags(const QModelIndex &index) const override; + + [[nodiscard]] OCC::ActivityList conflictActivities() const; + + [[nodiscard]] bool allExistingsSelected() const; + + [[nodiscard]] bool allConflictingSelected() const; + +public slots: + void setConflictActivities(OCC::ActivityList conflicts); + + void selectAllExisting(bool selected); + + void selectAllConflicting(bool selected); + + void applySolution(); + +signals: + void conflictActivitiesChanged(); + + void allExistingsSelectedChanged(); + + void allConflictingSelectedChanged(); + +private: + void updateConflictsData(); + + void setExistingSelected(bool value, + const QModelIndex &index, + int role); + + void setConflictingSelected(bool value, + const QModelIndex &index, + int role); + + OCC::ActivityList _data; + + QVector _conflictData; + + QLocale _locale; + + bool _allExistingsSelected = false; + + bool _allConflictingsSelected = false; +}; + +} diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index c45b870b09cb9..08872c28925fc 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -285,6 +285,36 @@ void Systray::destroyEditFileLocallyLoadingDialog() _editFileLocallyLoadingDialog = nullptr; } +void Systray::createResolveConflictsDialog(const OCC::ActivityList &allConflicts) +{ + const auto conflictsDialog = std::make_unique(_trayEngine, QStringLiteral("qrc:/qml/src/gui/ResolveConflictsDialog.qml")); + const QVariantMap initialProperties{ + {"allConflicts", QVariant::fromValue(allConflicts)}, + }; + + if(conflictsDialog->isError()) { + qCWarning(lcSystray) << conflictsDialog->errorString(); + return; + } + + // This call dialog gets deallocated on close conditions + // by a call from the QML side to the destroyDialog slot + auto dialog = QScopedPointer(conflictsDialog->createWithInitialProperties(initialProperties)); + if (!dialog) { + return; + } + dialog->setParent(QGuiApplication::instance()); + + auto dialogWindow = qobject_cast(dialog.data()); + if (!dialogWindow) { + return; + } + dialogWindow->show(); + dialogWindow->raise(); + dialogWindow->requestActivate(); + dialog.take(); +} + bool Systray::raiseDialogs() { return raiseFileDetailDialogs(); diff --git a/src/gui/systray.h b/src/gui/systray.h index fdc9861752b48..21607233e6ed3 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -121,6 +121,7 @@ public slots: void createCallDialog(const OCC::Activity &callNotification, const OCC::AccountStatePtr accountState); void createEditFileLocallyLoadingDialog(const QString &fileName); void destroyEditFileLocallyLoadingDialog(); + void createResolveConflictsDialog(const OCC::ActivityList &allConflicts); void slotCurrentUserChanged(); diff --git a/src/gui/tray/SyncStatus.qml b/src/gui/tray/SyncStatus.qml index a1dc370c8555a..40167322c8968 100644 --- a/src/gui/tray/SyncStatus.qml +++ b/src/gui/tray/SyncStatus.qml @@ -121,7 +121,8 @@ RowLayout { contentsFont.bold: true bgColor: Style.currentUserHeaderColor - visible: !syncStatus.syncing && + visible: !activityModel.hasSyncConflicts && + !syncStatus.syncing && NC.UserModel.currentUser.hasLocalFolder && NC.UserModel.currentUser.isConnected enabled: visible @@ -131,4 +132,25 @@ RowLayout { } } } + + CustomButton { + Layout.preferredWidth: syncNowFm.boundingRect(text).width + + leftPadding + + rightPadding + + Style.standardSpacing * 2 + Layout.rightMargin: Style.trayHorizontalMargin + + text: qsTr("Resolve conflicts") + textColor: Style.adjustedCurrentUserHeaderColor + textColorHovered: Style.currentUserHeaderTextColor + contentsFont.bold: true + bgColor: Style.currentUserHeaderColor + + visible: activityModel.hasSyncConflicts && + !syncStatus.syncing && + NC.UserModel.currentUser.hasLocalFolder && + NC.UserModel.currentUser.isConnected + enabled: visible + onClicked: NC.Systray.createResolveConflictsDialog(activityModel.allConflicts); + } } diff --git a/src/gui/tray/activitydata.h b/src/gui/tray/activitydata.h index c4e8947240a70..36671e4739679 100644 --- a/src/gui/tray/activitydata.h +++ b/src/gui/tray/activitydata.h @@ -178,6 +178,7 @@ using ActivityList = QList; } Q_DECLARE_METATYPE(OCC::Activity) +Q_DECLARE_METATYPE(OCC::ActivityList) Q_DECLARE_METATYPE(OCC::Activity::Type) Q_DECLARE_METATYPE(OCC::ActivityLink) Q_DECLARE_METATYPE(OCC::PreviewData) diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index b820ed941c2b1..7b152a9842b04 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -32,7 +32,7 @@ #include #include #include -#include +#include namespace OCC { @@ -428,6 +428,8 @@ void ActivityListModel::startFetchJob() void ActivityListModel::setFinalList(const ActivityList &finalList) { _finalList = finalList; + + emit allConflictsChanged(); } const ActivityList &ActivityListModel::finalList() const @@ -548,6 +550,13 @@ void ActivityListModel::addEntriesToActivityList(const ActivityList &activityLis _finalList.append(activity); } endInsertRows(); + + const auto deselectedConflictIt = std::find_if(_finalList.constBegin(), _finalList.constEnd(), [] (const auto activity) { + return activity._syncFileItemStatus == SyncFileItem::Conflict; + }); + const auto conflictsFound = (deselectedConflictIt != _finalList.constEnd()); + + setHasSyncConflicts(conflictsFound); } void ActivityListModel::addErrorToActivityList(const Activity &activity) @@ -640,34 +649,8 @@ void ActivityListModel::slotTriggerDefaultAction(const int activityIndex) const auto activity = _finalList.at(activityIndex); if (activity._syncFileItemStatus == SyncFileItem::Conflict) { - Q_ASSERT(!activity._file.isEmpty()); - Q_ASSERT(!activity._folder.isEmpty()); - Q_ASSERT(Utility::isConflictFile(activity._file)); - - const auto folder = FolderMan::instance()->folder(activity._folder); + displaySingleConflictDialog(activity); - const auto conflictedRelativePath = activity._file; - const auto baseRelativePath = folder->journalDb()->conflictFileBaseName(conflictedRelativePath.toUtf8()); - - const auto dir = QDir(folder->path()); - const auto conflictedPath = dir.filePath(conflictedRelativePath); - const auto basePath = dir.filePath(baseRelativePath); - - const auto baseName = QFileInfo(basePath).fileName(); - - if (!_currentConflictDialog.isNull()) { - _currentConflictDialog->close(); - } - _currentConflictDialog = new ConflictDialog; - _currentConflictDialog->setBaseFilename(baseName); - _currentConflictDialog->setLocalVersionFilename(conflictedPath); - _currentConflictDialog->setRemoteVersionFilename(basePath); - _currentConflictDialog->setAttribute(Qt::WA_DeleteOnClose); - connect(_currentConflictDialog, &ConflictDialog::accepted, folder, [folder]() { - folder->scheduleThisFolderSoon(); - }); - _currentConflictDialog->open(); - ownCloudGui::raiseDialog(_currentConflictDialog); return; } else if (activity._syncFileItemStatus == SyncFileItem::FileNameClash) { triggerCaseClashAction(activity); @@ -730,6 +713,46 @@ void ActivityListModel::triggerCaseClashAction(Activity activity) ownCloudGui::raiseDialog(_currentCaseClashFilenameDialog); } +void ActivityListModel::displaySingleConflictDialog(const Activity &activity) +{ + Q_ASSERT(!activity._file.isEmpty()); + Q_ASSERT(!activity._folder.isEmpty()); + Q_ASSERT(Utility::isConflictFile(activity._file)); + + const auto folder = FolderMan::instance()->folder(activity._folder); + + const auto conflictedRelativePath = activity._file; + const auto baseRelativePath = folder->journalDb()->conflictFileBaseName(conflictedRelativePath.toUtf8()); + + const auto dir = QDir(folder->path()); + const auto conflictedPath = dir.filePath(conflictedRelativePath); + const auto basePath = dir.filePath(baseRelativePath); + + const auto baseName = QFileInfo(basePath).fileName(); + + if (!_currentConflictDialog.isNull()) { + _currentConflictDialog->close(); + } + _currentConflictDialog = new ConflictDialog; + _currentConflictDialog->setBaseFilename(baseName); + _currentConflictDialog->setLocalVersionFilename(conflictedPath); + _currentConflictDialog->setRemoteVersionFilename(basePath); + _currentConflictDialog->setAttribute(Qt::WA_DeleteOnClose); + connect(_currentConflictDialog, &ConflictDialog::accepted, folder, [folder]() { + folder->scheduleThisFolderSoon(); + }); + _currentConflictDialog->open(); + ownCloudGui::raiseDialog(_currentConflictDialog); +} + +void ActivityListModel::setHasSyncConflicts(bool conflictsFound) +{ + if (_hasSyncConflicts != conflictsFound) { + _hasSyncConflicts = conflictsFound; + emit hasSyncConflictsChanged(); + } +} + void ActivityListModel::slotTriggerAction(const int activityIndex, const int actionIndex) { if (activityIndex < 0 || activityIndex >= _finalList.size()) { @@ -885,4 +908,23 @@ QString ActivityListModel::replyMessageSent(const Activity &activity) const { return activity._talkNotificationData.messageSent; } + +bool ActivityListModel::hasSyncConflicts() const +{ + return _hasSyncConflicts; +} + +ActivityList ActivityListModel::allConflicts() const +{ + auto result = ActivityList{}; + + for(const auto &activity : _finalList) { + if (activity._syncFileItemStatus == SyncFileItem::Conflict) { + result.push_back(activity); + } + } + + return result; +} + } diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 3e21ebd1162f7..05e76bacefe50 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -42,6 +42,8 @@ class ActivityListModel : public QAbstractListModel Q_OBJECT Q_PROPERTY(quint32 maxActionButtons READ maxActionButtons CONSTANT) Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(bool hasSyncConflicts READ hasSyncConflicts NOTIFY hasSyncConflictsChanged) + Q_PROPERTY(OCC::ActivityList allConflicts READ allConflicts NOTIFY allConflictsChanged) public: enum DataRole { @@ -105,6 +107,10 @@ class ActivityListModel : public QAbstractListModel [[nodiscard]] QString replyMessageSent(const Activity &activity) const; + [[nodiscard]] bool hasSyncConflicts() const; + + [[nodiscard]] OCC::ActivityList allConflicts() const; + public slots: void slotRefreshActivity(); void slotRefreshActivityInitial(); @@ -126,6 +132,8 @@ public slots: signals: void accountStateChanged(); + void hasSyncConflictsChanged(); + void allConflictsChanged(); void activityJobStatusCode(int statusCode); void sendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); @@ -162,6 +170,9 @@ private slots: void insertOrRemoveDummyFetchingActivity(); void triggerCaseClashAction(Activity activity); + void displaySingleConflictDialog(const Activity &activity); + void setHasSyncConflicts(bool conflictsFound); + Activity _notificationIgnoredFiles; Activity _dummyFetchingActivities; @@ -190,6 +201,8 @@ private slots: bool _doneFetching = false; bool _hideOldActivities = true; + bool _hasSyncConflicts = false; + static constexpr quint32 MaxActionButtons = 3; }; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d6c27a1304941..2dbb5a9b7f134 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -71,6 +71,7 @@ nextcloud_add_test(ShareeModel) nextcloud_add_test(SortedShareModel) nextcloud_add_test(SecureFileDrop) nextcloud_add_test(FileTagModel) +nextcloud_add_test(SyncConflictsModel) target_link_libraries(SecureFileDropTest PRIVATE Nextcloud::sync) configure_file(fake2eelocksucceeded.json "${PROJECT_BINARY_DIR}/bin/fake2eelocksucceeded.json" COPYONLY) diff --git a/test/testsyncconflictsmodel.cpp b/test/testsyncconflictsmodel.cpp new file mode 100644 index 0000000000000..de1596cc38e5f --- /dev/null +++ b/test/testsyncconflictsmodel.cpp @@ -0,0 +1,115 @@ +/* + * Copyright (C) by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "gui/syncconflictsmodel.h" +#include "folderman.h" +#include "accountstate.h" +#include "configfile.h" +#include "syncfileitem.h" + +#include "syncenginetestutils.h" +#include "testhelper.h" + +#include +#include +#include + +namespace { + +QStringList findConflicts(const FileInfo &dir) +{ + QStringList conflicts; + for (const auto &item : dir.children) { + if (item.name.contains("(conflicted copy")) { + conflicts.append(item.path()); + } + } + return conflicts; +} + +} + +using namespace OCC; + +class TestSyncConflictsModel : public QObject +{ + Q_OBJECT + +private: + +private slots: + void initTestCase() + { + } + + void testSettingConflicts() + { + auto dir = QTemporaryDir {}; + ConfigFile::setConfDir(dir.path()); // we don't want to pollute the user's config file + + FolderMan fm; + + auto account = Account::create(); + auto url = QUrl{"http://example.de"}; + auto cred = new HttpCredentialsTest("testuser", "secret"); + account->setCredentials(cred); + account->setUrl(url); + url.setUserName(cred->user()); + + auto newAccountState{AccountStatePtr{ new AccountState{account}}}; + auto folderman = FolderMan::instance(); + QCOMPARE(folderman, &fm); + + auto fakeFolder = FakeFolder{FileInfo::A12_B12_C12_S12()}; + + QVERIFY(folderman->addFolder(newAccountState.data(), folderDefinition(fakeFolder.localPath()))); + + QVERIFY(fakeFolder.syncOnce()); + + fakeFolder.localModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().appendByte("A/a2"); + + QVERIFY(fakeFolder.syncOnce()); + + OCC::ActivityList allConflicts; + + const auto conflicts = findConflicts(fakeFolder.currentLocalState().children["A"]); + for (const auto &conflict : conflicts) { + auto conflictActivity = OCC::Activity{}; + conflictActivity._file = fakeFolder.localPath() + conflict; + conflictActivity._folder = fakeFolder.localPath(); + allConflicts.push_back(std::move(conflictActivity)); + } + + SyncConflictsModel model; + QAbstractItemModelTester modelTester(&model); + + model.setConflictActivities(allConflicts); + + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingFileName)), QString{"a2"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingSize)), QString{"6 bytes"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictSize)), QString{"5 bytes"}); + QVERIFY(!model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingDate)).toString().isEmpty()); + QVERIFY(!model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictDate)).toString().isEmpty()); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingPreviewUrl)), QVariant::fromValue(QUrl{QStringLiteral("image://tray-image-provider/:/fileicon%1A/a2").arg(fakeFolder.localPath())})); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictPreviewUrl)), QVariant::fromValue(QUrl{QStringLiteral("image://tray-image-provider/:/fileicon%1%2").arg(fakeFolder.localPath(), conflicts.constFirst())})); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingSelected)), false); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictSelected)), false); + } +}; + +QTEST_GUILESS_MAIN(TestSyncConflictsModel) +#include "testsyncconflictsmodel.moc" diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 21bfeb460e883..e9fbdbf2ee68c 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -131,6 +131,11 @@ QtObject { readonly property var fontMetrics: FontMetrics {} + readonly property int bigFontPixelSizeResolveConflictsDialog: 20 + readonly property int fontPixelSizeResolveConflictsDialog: 15 + readonly property int minimumWidthResolveConflictsDialog: 600 + readonly property int minimumHeightResolveConflictsDialog: 800 + readonly property int activityContentSpace: 4 readonly property double smallIconScaleFactor: 0.6